Skip to content

Commit f830d9e

Browse files
AlemTuzlakautofix-ci[bot]tombeckenham
authored
feat: type-safe tool-call parts in useChat (typed chunks, parsed input, gated approval) (#918)
* feat(react,vue,solid,svelte): infer typed chunks from a bare tools array Add the `const` modifier to the `TTools` type param of useChat/createChat so a plain inline `tools: [a, b]` array captures the tuple + literal tool names and yields a typed, discriminated tool-call part union — no `clientTools(...)` wrapper or `as const` needed. clientTools() still works. * feat(ai): populate parsed input on tool-call parts ToolCallPart already declared a typed `input?` field but it was never written at runtime — only the raw `arguments` string and `output` were set. Populate `input` from the parsed arguments once complete (in the stream processor's completeToolCall, the TOOL_CALL_END parsed-input path, and history hydration in modelMessagesToUIMessages), carrying it forward through the part updaters. `arguments` is unchanged and not deprecated. * feat(ai,ai-client): gate tool-call `approval` on needsApproval Capture `needsApproval` as a literal type param (TNeedsApproval) on the client tool types, and include the `approval` field on a tool-call part only when the tool was defined with `needsApproval: true`. Non-approval tools have no `approval` field (reading it is a compile error). Generic handlers still work via an `'approval' in part` guard or the base ToolCallPart type; untyped useChat() is unaffected. * docs,examples,e2e: cover typed tool-call parts + AG-UI tool-forwarding safety - Document parsed `input`, approval gating (+ generic-handler escape hatches), and the AG-UI client-tool forwarding security tradeoff (manual registration is the safe default; mergeAgentTools trusts the client) in tool-approval, client-tools, ag-ui-compliance docs and the tool-calling skill. - Drop the redundant Object.values() around mergeAgentTools (it already returns an array) in the react/vue examples. - Add the /typesafe-tools demo route and e2e assertions for input population. * docs: prefer plain `tools` arrays, document clientTools as an optional escape hatch A plain `tools: [a, b]` array (inline or a separate const) now narrows tool names, inputs and outputs on its own, so drop `clientTools(...)` from the framework/API/tool docs and demote it to an optional identity helper documented once in client-tools + the ai-client reference. Adds a regression test proving a separately-declared const array narrows without clientTools or `as const`. * fix(examples,e2e): resolve type errors surfaced by precise tool inference - ts-solid-chat: pass a real `fetchServerSentEvents` connection to useChat instead of re-passing `chatOptions.connection` (typed `| undefined`, which no longer satisfies the connection/fetcher transport XOR now that the tools tuple is precise); guard `part.approval` with `'approval' in part`. - e2e tools-test: cast the untyped history-fixture `initialMessages` to the typed message shape so they don't conflict with the concrete tools tuple. * fix: address PR review — sync TOOL_CALL_END.input override + doc nits - processor: when TOOL_CALL_END provides a parsed `input` that diverges from the accumulated args parse, refresh the rendered tool-call part so `part.input` reflects the canonical override (not the stale parse). Adds a divergent-input test. - docs/skill: render `part.input` directly in approval examples (parts have parsed input by approval time); note the approval gate in the ai-client ToolCallPart reference; use gpt-5.5 across all ag-ui-compliance tiers. * ci: apply automated fixes * fix(e2e): type-check Gemini video mock config `Omit<GeminiVideoConfig, 'apiKey'>` drops the inherited `GoogleGenAIOptions` keys (a tsc quirk from the config's own `allowUrlFetch` member — the empty image/audio configs are unaffected), so the `httpOptions` mock-routing literal was rejected only here. Cast to the param type; the mock base URL is required at runtime. Pre-existing on main, surfaced now that `test:pr` type-checks examples/testing and this PR makes the e2e app affected. * ci: apply automated fixes * fix: address PR review follow-ups — rebase, parity, tests, safety - Rebase onto main (resolve solid-chat + e2e merge conflicts) - Extend `const TTools` to Preact `useChat` and Angular `injectChat` - Add message-updaters tests for `input` carry-forward through approval - Guard `JSON.stringify(chunk.input)` in stream processor - Strengthen approval-gating changeset migration notes; include angular/preact in the const-tools inference changeset * ci: apply automated fixes * fix(examples): link Type-Safe Tools demo from home page and nav The /typesafe-tools route was added in PR #918 but had no discoverability entry points — add it to the empty-state grid on the home page and to the Examples section in the header drawer. * fix(examples): remove duplicate connection in ts-solid-chat useChat Spreading chatOptions already supplies the SSE connection. Re-specifying connection conflicted with the ChatTransport XOR and broke test:types. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
1 parent e0bbbdd commit f830d9e

48 files changed

Lines changed: 1059 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-client': minor
4+
---
5+
6+
Gate the tool-call part's `approval` field on the tool's `needsApproval` flag.
7+
Previously `approval?` was declared on every typed tool-call part regardless of
8+
whether the tool could ever request approval. Now the flag is captured as a
9+
literal type (`toolDefinition({ needsApproval: true })``true`) and threaded
10+
through `ClientTool` / `ToolDefinitionInstance` / `ToolDefinition`, and
11+
`ToolCallPartForTool` only includes `approval` for tools defined with
12+
`needsApproval: true`:
13+
14+
```ts
15+
const { messages } = useChat({ tools: [getGuitars, addToCart] }) // addToCart: needsApproval: true
16+
for (const part of message.parts) {
17+
if (part.type !== 'tool-call') continue
18+
if (part.name === 'addToCart') part.approval?.id // ✅ typed
19+
if (part.name === 'getGuitars') part.approval // ✅ compile error — no such field
20+
}
21+
```
22+
23+
## ⚠️ Breaking change (types only)
24+
25+
**This is the primary migration surface for this release.** When you pass a typed
26+
`tools` array to `useChat` / `createChat` / `injectChat`, reading `part.approval`
27+
on a mixed tool-call union **without first narrowing by `part.name`** no longer
28+
compiles. Code that previously did `part.approval?.id` in a generic handler over
29+
all tool-call parts must be updated:
30+
31+
```ts
32+
// ❌ No longer compiles on a typed mixed union
33+
part.approval?.id
34+
35+
// ✅ Narrow to an approval-required tool first
36+
if (part.name === 'deleteAccount') part.approval?.id
37+
38+
// ✅ Or guard with `in`
39+
if ('approval' in part) part.approval?.id
40+
41+
// ✅ Or type the handler against the base (untyped) ToolCallPart
42+
function handleApproval(part: ToolCallPart) {
43+
return part.approval?.id
44+
}
45+
```
46+
47+
Untyped `useChat()` (no inferred `tools` generic) and the base `ToolCallPart`
48+
type are unaffected: `approval` stays available on every tool-call part there.
49+
**Runtime behavior is unchanged** — only TypeScript narrowing is stricter.
50+
51+
Adds a `TNeedsApproval extends boolean` type parameter (defaulting to `false`)
52+
to the client tool types; existing explicit type arguments keep working via the
53+
default. Literal capture requires `toolDefinition({ needsApproval: true })` at
54+
the call site — a dynamic `needsApproval: boolean` variable will not gate the
55+
type.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@tanstack/ai': minor
3+
---
4+
5+
Populate the parsed `input` on tool-call message parts. `ToolCallPart` already
6+
declared a typed `input?` field, but it was never written at runtime — only the
7+
raw `arguments` string (and `output`) were set, so `part.input` was always
8+
`undefined` and consumers had to fall back to `part.input ?? JSON.parse(part.arguments)`.
9+
10+
`input` is now set from the parsed arguments once they are complete
11+
(`state: 'input-complete'` and later, including `approval-requested`), in the
12+
streaming processor, the `TOOL_CALL_END`-with-parsed-input path, and when
13+
hydrating history via `modelMessagesToUIMessages`. While arguments are still
14+
streaming, `input` stays `undefined` and the raw `arguments` string remains the
15+
live source. A tool call that terminates in an error state may also keep `input`
16+
unset. `arguments` is unchanged, always present, and not deprecated.
17+
18+
With typed tools (`useChat({ tools })`), `part.input` is fully typed per tool
19+
via the `part.name` discriminant — matching `part.output`.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@tanstack/ai-angular': patch
3+
'@tanstack/ai-preact': patch
4+
'@tanstack/ai-react': patch
5+
'@tanstack/ai-solid': patch
6+
'@tanstack/ai-svelte': patch
7+
'@tanstack/ai-vue': patch
8+
---
9+
10+
Add the `const` modifier to the `TTools` type parameter of `useChat`
11+
(`createChat` in Svelte, `injectChat` in Angular) so a plain inline `tools` array
12+
now yields full type-safe message chunks. Previously the array widened to
13+
`Array<Union>` and lost the literal tool `name`s that drive the
14+
discriminated `tool-call` part union, so callers had to wrap their tools in
15+
`clientTools(...)` (or add `as const`) to get narrowing. That wrapper is now
16+
optional — `tools: [toolA, toolB]` narrows `part.name`, `part.input`, and
17+
`part.output` on its own. `clientTools(...)` still works and remains useful
18+
for defining a shared tuple outside the hook call.

docs/advanced/runtime-context.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ This inference also works when reusable tools or middleware are declared outside
7979
The same rule applies on the client:
8080

8181
```typescript
82-
import { clientTools } from "@tanstack/ai-client";
8382
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
8483
import { toolDefinition } from "@tanstack/ai";
8584

@@ -99,7 +98,7 @@ const inspectClientContext = toolDefinition({
9998

10099
useChat({
101100
connection: fetchServerSentEvents("/api/chat"),
102-
tools: clientTools(inspectClientContext),
101+
tools: [inspectClientContext],
103102
context: {
104103
currentTabId: "settings",
105104
mode: "debug",
@@ -184,7 +183,7 @@ When any tool or middleware in a `chat()` call declares a concrete context type,
184183
Client runtime context is local to `ChatClient` and framework hooks. It is passed to client tool implementations and is not serialized to the server.
185184

186185
```typescript
187-
import { createChatClientOptions, clientTools } from "@tanstack/ai-client";
186+
import { createChatClientOptions } from "@tanstack/ai-client";
188187
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
189188
import { toolDefinition } from "@tanstack/ai";
190189

@@ -203,7 +202,7 @@ const notifyUser = toolDefinition({
203202

204203
const chatOptions = createChatClientOptions({
205204
connection: fetchServerSentEvents("/api/chat"),
206-
tools: clientTools(notifyUser),
205+
tools: [notifyUser],
207206
context: {
208207
currentTabId: "settings",
209208
toast: (message) => window.alert(message),
@@ -221,7 +220,6 @@ To send serializable client data to the server, use `forwardedProps`, validate i
221220

222221
```typescript
223222
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
224-
import { clientTools } from "@tanstack/ai-client";
225223
import { toolDefinition } from "@tanstack/ai";
226224

227225
type ClientContext = {
@@ -240,7 +238,7 @@ const notifyUser = toolDefinition({
240238
// Client
241239
useChat({
242240
connection: fetchServerSentEvents("/api/chat"),
243-
tools: clientTools(notifyUser),
241+
tools: [notifyUser],
244242
forwardedProps: {
245243
tenantId: "tenant_456",
246244
},

docs/api/ai-angular.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ import { Component } from "@angular/core";
234234
import { CommonModule } from "@angular/common";
235235
import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
236236
import {
237-
clientTools,
238237
createChatClientOptions,
239238
type InferChatMessages,
240239
} from "@tanstack/ai-client";
@@ -280,7 +279,7 @@ export class TypedChatComponent {
280279
});
281280

282281
// Create typed tools array (no 'as const' needed!)
283-
private tools = clientTools(this.updateUI, this.saveToStorage);
282+
private tools = [this.updateUI, this.saveToStorage];
284283

285284
chat = injectChat({
286285
connection: fetchServerSentEvents("/api/chat"),
@@ -526,15 +525,14 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
526525

527526
```typescript
528527
import {
529-
clientTools,
530528
createChatClientOptions,
531529
type InferChatMessages,
532530
} from "@tanstack/ai-client";
533531
import { fetchServerSentEvents } from "@tanstack/ai-angular";
534532
import { tool1, tool2 } from "./tools";
535533

536534
// Create typed tools array (no 'as const' needed!)
537-
const tools = clientTools(tool1, tool2);
535+
const tools = [tool1, tool2];
538536

539537
const chatOptions = createChatClientOptions({
540538
connection: fetchServerSentEvents("/api/chat"),

docs/api/ai-client.md

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ The main client class for managing chat state.
2828
```typescript
2929
import {
3030
ChatClient,
31-
clientTools,
3231
fetchServerSentEvents,
3332
type UIMessage,
3433
} from "@tanstack/ai-client";
@@ -37,7 +36,7 @@ import { myClientTool } from "./tools";
3736
const client = new ChatClient({
3837
connection: fetchServerSentEvents("/api/chat"),
3938
initialMessages: [],
40-
tools: clientTools(myClientTool),
39+
tools: [myClientTool],
4140
onMessagesChange: (messages: UIMessage[]) => {
4241
console.log("Messages updated:", messages);
4342
},
@@ -281,7 +280,7 @@ const adapter = stream(async (messages, data, signal) => {
281280

282281
### `clientTools(...tools)`
283282

284-
Creates a typed array of client tools with proper type inference. This eliminates the need for `as const` when defining tool arrays and enables proper discriminated union type narrowing.
283+
**Optional.** A plain array `tools: [tool1, tool2]` — already narrows tool names, inputs and outputs without any wrapper or `as const`. `clientTools()` is an identity helper that performs the same capture explicitly; reach for it only when you want to build a shared, reusable tools tuple outside the hook/options call.
285284

286285
```typescript
287286
import {
@@ -320,7 +319,7 @@ const tool2Client = myTool2.client((input) => {
320319
return { result: input.query };
321320
});
322321

323-
// Create typed tools array (no 'as const' needed!)
322+
// The explicit-capture form (equivalent to `[tool1Client, tool2Client]`).
324323
const tools = clientTools(tool1Client, tool2Client);
325324

326325
// Now when you use these tools in chat options:
@@ -348,13 +347,12 @@ Helper function to create typed chat client options with proper type inference.
348347
```typescript
349348
import {
350349
createChatClientOptions,
351-
clientTools,
352350
fetchServerSentEvents,
353351
type InferChatMessages,
354352
} from "@tanstack/ai-client";
355353
import { tool1, tool2 } from "./tools";
356354

357-
const tools = clientTools(tool1, tool2);
355+
const tools = [tool1, tool2];
358356

359357
const chatOptions = createChatClientOptions({
360358
connection: fetchServerSentEvents("/api/chat"),
@@ -370,7 +368,6 @@ type ChatMessages = InferChatMessages<typeof chatOptions>;
370368
```typescript
371369
import {
372370
createChatClientOptions,
373-
clientTools,
374371
fetchServerSentEvents,
375372
} from "@tanstack/ai-client";
376373
import { toolDefinition } from "@tanstack/ai";
@@ -394,7 +391,7 @@ const tool = projectTool.client<ClientContext>((input, ctx: { context: ClientCon
394391

395392
const chatOptions = createChatClientOptions({
396393
connection: fetchServerSentEvents("/api/chat"),
397-
tools: clientTools(tool),
394+
tools: [tool],
398395
context: {
399396
activeProjectId: "project_123",
400397
},
@@ -454,12 +451,12 @@ interface ToolCallPart {
454451
arguments: string; // JSON string (may be incomplete during streaming)
455452
input?: any; // Parsed tool input (typed from tool's inputSchema)
456453
state: ToolCallState;
457-
approval?: ApprovalRequest;
454+
approval?: ApprovalRequest; // only on tools declared `needsApproval: true`
458455
output?: any; // Tool execution output (typed from tool's outputSchema)
459456
}
460457
```
461458

462-
When using typed tools with `clientTools()` and `createChatClientOptions()`, the `input` and `output` fields are automatically typed based on your tool's Zod schemas, and `name` becomes a discriminated union enabling type narrowing.
459+
When you pass a typed `tools` array (a plain array works — `clientTools()` is optional), the `input` and `output` fields are automatically typed based on your tool's Zod schemas, and `name` becomes a discriminated union enabling type narrowing. The `approval` field is present **only** on parts for tools declared with `needsApproval: true` — narrow by `part.name` (or guard with `'approval' in part`) before accessing it.
463460

464461
### `ToolResultPart`
465462

docs/api/ai-preact.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ Main hook for managing chat state in Preact with full type safety.
2727
```tsx
2828
import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
2929
import {
30-
clientTools,
3130
createChatClientOptions,
3231
type InferChatMessages
3332
} from "@tanstack/ai-client";
@@ -50,7 +49,7 @@ function ChatComponent() {
5049
});
5150

5251
// Create typed tools array (no 'as const' needed!)
53-
const tools = clientTools(updateUI);
52+
const tools = [updateUI];
5453

5554
const chatOptions = createChatClientOptions({
5655
connection: fetchServerSentEvents("/api/chat"),
@@ -246,7 +245,6 @@ export function ChatWithApproval() {
246245
```tsx
247246
import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
248247
import {
249-
clientTools,
250248
createChatClientOptions,
251249
type InferChatMessages
252250
} from "@tanstack/ai-client";
@@ -282,7 +280,7 @@ export function ChatWithClientTools() {
282280
});
283281

284282
// Create typed tools array (no 'as const' needed!)
285-
const tools = clientTools(updateUI, saveToStorage);
283+
const tools = [updateUI, saveToStorage];
286284

287285
const { messages, sendMessage } = useChat({
288286
connection: fetchServerSentEvents("/api/chat"),
@@ -311,15 +309,14 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
311309

312310
```typescript
313311
import {
314-
clientTools,
315312
createChatClientOptions,
316313
type InferChatMessages
317314
} from "@tanstack/ai-client";
318315
import { fetchServerSentEvents } from "@tanstack/ai-preact";
319316
import { tool1, tool2 } from "./tools";
320317

321318
// Create typed tools array (no 'as const' needed!)
322-
const tools = clientTools(tool1, tool2);
319+
const tools = [tool1, tool2];
323320

324321
const chatOptions = createChatClientOptions({
325322
connection: fetchServerSentEvents("/api/chat"),

docs/api/ai-react.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ Main hook for managing chat state in React with full type safety.
3333
```tsx
3434
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
3535
import {
36-
clientTools,
3736
createChatClientOptions,
3837
type InferChatMessages
3938
} from "@tanstack/ai-client";
@@ -60,7 +59,7 @@ function ChatComponent() {
6059
});
6160

6261
// Create typed tools array (no 'as const' needed!)
63-
const tools = clientTools(updateUI);
62+
const tools = [updateUI];
6463

6564
const chatOptions = createChatClientOptions({
6665
connection: fetchServerSentEvents("/api/chat"),
@@ -275,7 +274,6 @@ export function ChatWithApproval() {
275274
```tsx
276275
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
277276
import {
278-
clientTools,
279277
createChatClientOptions,
280278
type InferChatMessages
281279
} from "@tanstack/ai-client";
@@ -319,7 +317,7 @@ export function ChatWithClientTools() {
319317
});
320318

321319
// Create typed tools array (no 'as const' needed!)
322-
const tools = clientTools(updateUI, saveToStorage);
320+
const tools = [updateUI, saveToStorage];
323321

324322
const { messages, sendMessage } = useChat({
325323
connection: fetchServerSentEvents("/api/chat"),
@@ -348,15 +346,14 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
348346

349347
```typescript
350348
import {
351-
clientTools,
352349
createChatClientOptions,
353350
fetchServerSentEvents,
354351
type InferChatMessages
355352
} from "@tanstack/ai-client";
356353
import { tool1, tool2 } from "./tools";
357354

358355
// Create typed tools array (no 'as const' needed!)
359-
const tools = clientTools(tool1, tool2);
356+
const tools = [tool1, tool2];
360357

361358
const chatOptions = createChatClientOptions({
362359
connection: fetchServerSentEvents("/api/chat"),

0 commit comments

Comments
 (0)