From 4fcbef43c0e87bcf623b87979aa73debc0822792 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Jul 2026 13:30:25 +0200 Subject: [PATCH 01/14] feat(react,vue,solid,svelte): infer typed chunks from a bare tools array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/usechat-const-tools-inference.md | 16 +++++++++ packages/ai-react/src/use-chat.ts | 2 +- .../ai-react/tests/use-chat-types.test.ts | 36 +++++++++++++++++++ packages/ai-solid/src/use-chat.ts | 2 +- packages/ai-svelte/src/create-chat.svelte.ts | 2 +- packages/ai-vue/src/use-chat.ts | 2 +- 6 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 .changeset/usechat-const-tools-inference.md diff --git a/.changeset/usechat-const-tools-inference.md b/.changeset/usechat-const-tools-inference.md new file mode 100644 index 000000000..9f0f89f79 --- /dev/null +++ b/.changeset/usechat-const-tools-inference.md @@ -0,0 +1,16 @@ +--- +'@tanstack/ai-react': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-vue': patch +--- + +Add the `const` modifier to the `TTools` type parameter of `useChat` +(`createChat` in Svelte) so a plain inline `tools` array now yields full +type-safe message chunks. Previously the array widened to +`Array` and lost the literal tool `name`s that drive the +discriminated `tool-call` part union, so callers had to wrap their tools in +`clientTools(...)` (or add `as const`) to get narrowing. That wrapper is now +optional — `tools: [toolA, toolB]` narrows `part.name`, `part.input`, and +`part.output` on its own. `clientTools(...)` still works and remains useful +for defining a shared tuple outside the hook call. diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index 37444b569..32b3e885a 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -24,7 +24,7 @@ import type { } from './types' export function useChat< - TTools extends ReadonlyArray = any, + const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext, >( diff --git a/packages/ai-react/tests/use-chat-types.test.ts b/packages/ai-react/tests/use-chat-types.test.ts index edeac4045..672cc4a18 100644 --- a/packages/ai-react/tests/use-chat-types.test.ts +++ b/packages/ai-react/tests/use-chat-types.test.ts @@ -58,6 +58,42 @@ describe('useChat() return type', () => { }) }) + describe('with a bare inline tools array (no clientTools / no `as const`)', () => { + it('narrows tool-call parts from a plain array literal', () => { + // Type-only assertion — the closure is never invoked, so the hook never + // runs at runtime (it would throw outside a React renderer). `tsc` + // (test:types) still checks the body, which is what proves the narrowing. + const check = () => { + const guitarTool = toolDefinition({ + name: 'getGuitar', + description: 'Get guitar info', + }).client(() => ({ ok: true })) + const cartTool = toolDefinition({ + name: 'addToCart', + description: 'Add to cart', + }).client(() => ({ ok: true })) + + const { messages } = useChat({ + connection: { connect: async function* () {} }, + // plain array literal — the `const` modifier on useChat's TTools + // captures the tuple + literal tool names, so no `clientTools(...)` + // wrapper and no `as const` are needed for chunk narrowing. + tools: [guitarTool, cartTool], + }) + + const message = messages[0] + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call') { + expectTypeOf(part.name).toEqualTypeOf<'getGuitar' | 'addToCart'>() + } + } + } + } + void check + }) + }) + describe('with typed client tool context', () => { it('requires context matching the tool tuple', () => { type ClientContext = { localUserId: string; a: 'literal' } diff --git a/packages/ai-solid/src/use-chat.ts b/packages/ai-solid/src/use-chat.ts index 9a6e404cf..23d6495e8 100644 --- a/packages/ai-solid/src/use-chat.ts +++ b/packages/ai-solid/src/use-chat.ts @@ -31,7 +31,7 @@ import type { } from './types' export function useChat< - TTools extends ReadonlyArray = any, + const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext, >( diff --git a/packages/ai-svelte/src/create-chat.svelte.ts b/packages/ai-svelte/src/create-chat.svelte.ts index 506d9843f..4aaf99f00 100644 --- a/packages/ai-svelte/src/create-chat.svelte.ts +++ b/packages/ai-svelte/src/create-chat.svelte.ts @@ -52,7 +52,7 @@ import type { * ``` */ export function createChat< - TTools extends ReadonlyArray = any, + const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext, >( diff --git a/packages/ai-vue/src/use-chat.ts b/packages/ai-vue/src/use-chat.ts index 31719591d..8c54bd9cc 100644 --- a/packages/ai-vue/src/use-chat.ts +++ b/packages/ai-vue/src/use-chat.ts @@ -31,7 +31,7 @@ import type { } from './types' export function useChat< - TTools extends ReadonlyArray = any, + const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext, >( From 4a33befce5f1568f66196e1fdff7f861a831312f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Jul 2026 13:30:40 +0200 Subject: [PATCH 02/14] feat(ai): populate parsed input on tool-call parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/tool-call-part-parsed-input.md | 19 +++++++++++++++ packages/ai/src/activities/chat/messages.ts | 9 +++++++ .../chat/stream/message-updaters.ts | 8 ++++++- .../src/activities/chat/stream/processor.ts | 11 ++++++++- packages/ai/src/types.ts | 9 +++++++ packages/ai/tests/message-converters.test.ts | 7 ++++++ packages/ai/tests/stream-processor.test.ts | 24 +++++++++++++++++++ 7 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .changeset/tool-call-part-parsed-input.md diff --git a/.changeset/tool-call-part-parsed-input.md b/.changeset/tool-call-part-parsed-input.md new file mode 100644 index 000000000..53b9a3fcf --- /dev/null +++ b/.changeset/tool-call-part-parsed-input.md @@ -0,0 +1,19 @@ +--- +'@tanstack/ai': minor +--- + +Populate the parsed `input` on tool-call message parts. `ToolCallPart` already +declared a typed `input?` field, but it was never written at runtime — only the +raw `arguments` string (and `output`) were set, so `part.input` was always +`undefined` and consumers had to fall back to `part.input ?? JSON.parse(part.arguments)`. + +`input` is now set from the parsed arguments once they are complete +(`state: 'input-complete'` and later, including `approval-requested`), in the +streaming processor, the `TOOL_CALL_END`-with-parsed-input path, and when +hydrating history via `modelMessagesToUIMessages`. While arguments are still +streaming, `input` stays `undefined` and the raw `arguments` string remains the +live source. A tool call that terminates in an error state may also keep `input` +unset. `arguments` is unchanged, always present, and not deprecated. + +With typed tools (`useChat({ tools })`), `part.input` is fully typed per tool +via the `part.name` discriminant — matching `part.output`. diff --git a/packages/ai/src/activities/chat/messages.ts b/packages/ai/src/activities/chat/messages.ts index 3b7e2c0a2..8d47ba320 100644 --- a/packages/ai/src/activities/chat/messages.ts +++ b/packages/ai/src/activities/chat/messages.ts @@ -444,12 +444,21 @@ export function modelMessageToUIMessage( // Handle tool calls if (modelMessage.toolCalls && modelMessage.toolCalls.length > 0) { for (const toolCall of modelMessage.toolCalls) { + // Model-message arguments are complete, so surface the parsed input. + // A malformed arguments string just leaves `input` undefined. + let input: unknown + try { + input = JSON.parse(toolCall.function.arguments) + } catch { + input = undefined + } parts.push({ type: 'tool-call', id: toolCall.id, name: toolCall.function.name, arguments: toolCall.function.arguments, state: 'input-complete', // Model messages have complete arguments + ...(input !== undefined && { input }), ...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }), }) } diff --git a/packages/ai/src/activities/chat/stream/message-updaters.ts b/packages/ai/src/activities/chat/stream/message-updaters.ts index ccb180d84..72648af11 100644 --- a/packages/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/ai/src/activities/chat/stream/message-updaters.ts @@ -58,6 +58,8 @@ export function updateToolCallPart( name: string arguments: string state: ToolCallState + /** Parsed input — set when the arguments are complete. */ + input?: unknown metadata?: Record }, ): Array { @@ -76,6 +78,9 @@ export function updateToolCallPart( // Gemini's thoughtSignature on TOOL_CALL_START) we must not lose it on // subsequent updates that don't re-supply it. const metadata = toolCall.metadata ?? existing?.metadata + // Same for the parsed input: it's supplied once at completion, so + // subsequent arg-less updates (approval, etc.) must not drop it. + const input = toolCall.input ?? existing?.input const toolCallPart: ToolCallPart = { type: 'tool-call', @@ -83,9 +88,10 @@ export function updateToolCallPart( name: toolCall.name, arguments: toolCall.arguments, state: toolCall.state, - // Carry forward approval and output from the existing part + // Carry forward approval, output and parsed input from the existing part ...(existing?.approval && { approval: { ...existing.approval } }), ...(existing?.output !== undefined && { output: existing.output }), + ...(input !== undefined && { input }), ...(metadata !== undefined && { metadata }), } diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 610846be5..1b2c92be2 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -1890,12 +1890,21 @@ export class StreamProcessor { return } - // Update UIMessage + // Update UIMessage. The arguments are complete now, so surface the parsed + // input on the part. For adapters that skip TOOL_CALL_ARGS the arguments + // string was back-filled from TOOL_CALL_END.input above, so this parse + // matches the canonical input. + // ponytail: reflects the completed-args parse; if a future adapter sends a + // TOOL_CALL_END.input that diverges from the accumulated args, refresh the + // part in handleToolCallEndEvent's override branch too. this.messages = updateToolCallPart(this.messages, messageId, { id: toolCall.id, name: toolCall.name, arguments: toolCall.arguments, state: 'input-complete', + ...(toolCall.parsedArguments !== undefined && { + input: toolCall.parsedArguments, + }), ...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }), }) this.emitMessagesChange() diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6917c8140..983e220e8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -372,6 +372,15 @@ export interface ToolCallPart { id: string name: string arguments: string // JSON string (may be incomplete) + /** + * Parsed tool input. Set from the parsed arguments once they are complete + * (`state: 'input-complete'` and later). `undefined` while the raw + * `arguments` string is still streaming, and may stay `undefined` for a call + * that terminates in an error state — the raw `arguments` string is always + * available as a fallback. Typed per-tool on the client `ToolCallPart` (see + * `@tanstack/ai-client`); `unknown` on this base type. + */ + input?: unknown state: ToolCallState /** Approval metadata if tool requires user approval */ approval?: { diff --git a/packages/ai/tests/message-converters.test.ts b/packages/ai/tests/message-converters.test.ts index 073868fd3..f037a396d 100644 --- a/packages/ai/tests/message-converters.test.ts +++ b/packages/ai/tests/message-converters.test.ts @@ -580,6 +580,7 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city": "NYC"}', + input: { city: 'NYC' }, state: 'input-complete', }, ]) @@ -642,6 +643,7 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city": "NYC"}', + input: { city: 'NYC' }, state: 'input-complete', }, ]) @@ -919,6 +921,7 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', + input: { city: 'NYC' }, state: 'complete', output: { temp: 72 }, }, @@ -997,6 +1000,7 @@ describe('Message Converters', () => { id: 'tc-2', name: 'recommend', arguments: '{"id":7}', + input: { id: 7 }, state: 'complete', output: { recommended: true }, }, @@ -1086,6 +1090,7 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', + input: { city: 'NYC' }, state: 'complete', output: { temp: 72 }, }) @@ -1226,6 +1231,7 @@ describe('Message Converters', () => { id: 'tc-1', name: 'getWeather', arguments: '{"city":"NYC"}', + input: { city: 'NYC' }, state: 'complete', output: { temp: 72 }, }, @@ -1338,6 +1344,7 @@ describe('Message Converters', () => { id: 'tc-2', name: 'recommend', arguments: '{"id":7}', + input: { id: 7 }, state: 'complete', output: { recommended: true }, }, diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index d3b5fd194..2bb9533d3 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -460,6 +460,8 @@ describe('StreamProcessor', () => { expect(toolCallPart.name).toBe('getWeather') expect(toolCallPart.arguments).toBe('{"city":"NYC"}') expect(toolCallPart.state).toBe('input-complete') + // Parsed input is surfaced on the part once the arguments are complete. + expect(toolCallPart.input).toEqual({ city: 'NYC' }) const state = processor.getState() expect(state.content).toBe('') @@ -541,6 +543,26 @@ describe('StreamProcessor', () => { }) }) + it('populates part.input from TOOL_CALL_END.input when no args were streamed', () => { + const processor = new StreamProcessor() + processor.prepareAssistantMessage() + + // Adapter skips TOOL_CALL_ARGS and only sends parsed input on END. + processor.processChunk(ev.toolStart('tc-1', 'getWeather')) + processor.processChunk( + ev.toolEnd('tc-1', 'getWeather', { + input: { city: 'NYC', unit: 'celsius' }, + }), + ) + processor.finalizeStream() + + const toolCallPart = processor + .getMessages()[0]! + .parts.find((p) => p.type === 'tool-call') as ToolCallPart + expect(toolCallPart.state).toBe('input-complete') + expect(toolCallPart.input).toEqual({ city: 'NYC', unit: 'celsius' }) + }) + it('should default tool call index to toolCalls.size when index is not provided', () => { const processor = new StreamProcessor() processor.prepareAssistantMessage() @@ -2670,6 +2692,7 @@ describe('StreamProcessor', () => { id: 'tc-1', name: 'getWeather', arguments: '{"loc":"Berlin"}', + input: { loc: 'Berlin' }, state: 'input-complete', }, { @@ -3073,6 +3096,7 @@ describe('StreamProcessor', () => { id: 'tc-1', name: 'lookupWeather', arguments: '{"location":"Berlin"}', + input: { location: 'Berlin' }, state: 'input-complete', }, { From afba31289a0b16941806e9454487432d926f674e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Jul 2026 13:30:55 +0200 Subject: [PATCH 03/14] 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. --- .changeset/tool-call-approval-gating.md | 31 ++++++++ packages/ai-client/src/types.ts | 26 +++++-- .../tests/infer-chat-messages.test.ts | 76 +++++++++++++++++++ .../activities/chat/tools/tool-definition.ts | 44 ++++++++--- 4 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 .changeset/tool-call-approval-gating.md diff --git a/.changeset/tool-call-approval-gating.md b/.changeset/tool-call-approval-gating.md new file mode 100644 index 000000000..5b6eac587 --- /dev/null +++ b/.changeset/tool-call-approval-gating.md @@ -0,0 +1,31 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +--- + +Gate the tool-call part's `approval` field on the tool's `needsApproval` flag. +Previously `approval?` was declared on every typed tool-call part regardless of +whether the tool could ever request approval. Now the flag is captured as a +literal type (`toolDefinition({ needsApproval: true })` → `true`) and threaded +through `ClientTool` / `ToolDefinitionInstance` / `ToolDefinition`, and +`ToolCallPartForTool` only includes `approval` for tools defined with +`needsApproval: true`: + +```ts +const { messages } = useChat({ tools: [getGuitars, addToCart] }) // addToCart: needsApproval: true +for (const part of message.parts) { + if (part.type !== 'tool-call') continue + if (part.name === 'addToCart') part.approval?.id // ✅ typed + if (part.name === 'getGuitars') part.approval // ✅ compile error — no such field +} +``` + +**Breaking (types only):** when you pass typed `tools`, reading `part.approval` +on a mixed tool-call union without first narrowing by `part.name` no longer +compiles — narrow to a `needsApproval: true` tool first. Untyped `useChat()` +(no `tools` generic) and the base `ToolCallPart` type are unaffected: `approval` +stays available on every tool-call part there. Runtime behavior is unchanged. + +Adds a `TNeedsApproval extends boolean` type parameter (defaulting to `false`) +to the client tool types; existing explicit type arguments keep working via the +default. diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 25e07c42b..8e385d45b 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -166,15 +166,27 @@ type ToolCallPartForTool = T extends AnyClientTool /** Parsed tool input (typed from inputSchema) */ input?: InferToolInput state: ToolCallState - /** Approval metadata if tool requires user approval */ - approval?: { - id: string // Unique approval ID - needsApproval: boolean // Always true if present - approved?: boolean // User's decision (undefined until responded) - } /** Tool execution output (for client tools or after approval) */ output?: InferToolOutput - } + } & (NonNullable extends true + ? { + /** + * Approval metadata — present only on tools defined with + * `needsApproval: true`. Populated once the call reaches + * `state: 'approval-requested'`. `needsApproval` is an optional + * property on the tool, so we index into it (rather than + * `T extends { needsApproval: true }`, which an optional property + * never satisfies) and strip `undefined` before comparing to `true`. + */ + approval?: { + id: string // Unique approval ID + needsApproval: boolean // Always true if present + approved?: boolean // User's decision (undefined until responded) + } + } + : // Tools without `needsApproval: true` never carry an approval field. + // `& unknown` is a no-op intersection (adds nothing). + unknown) : never /** diff --git a/packages/ai-client/tests/infer-chat-messages.test.ts b/packages/ai-client/tests/infer-chat-messages.test.ts index 1aedcaf7d..ca2e1164c 100644 --- a/packages/ai-client/tests/infer-chat-messages.test.ts +++ b/packages/ai-client/tests/infer-chat-messages.test.ts @@ -16,6 +16,7 @@ import { createChatClientOptions } from '../src/types' import type { ChatClientOptions, InferChatMessages, + ToolCallPart, UIMessage, } from '../src/types' @@ -231,3 +232,78 @@ describe('InferChatMessages — untyped fallback', () => { } }) }) + +// =========================== +// Approval gating: `approval` present only for needsApproval tools +// =========================== + +const approvalTool = toolDefinition({ + name: 'deleteAccount', + description: 'Delete the account (needs approval)', + inputSchema: z.object({ accountId: z.string() }), + outputSchema: z.object({ deleted: z.boolean() }), + needsApproval: true, +}).client(() => ({ deleted: true })) + +describe('InferChatMessages — approval gating', () => { + it('exposes `approval` only on tools declared with `needsApproval: true`', () => { + const options = createChatClientOptions({ + connection: stubConnection, + tools: [guitarTool, approvalTool] as const, + }) + + type Messages = InferChatMessages + const messages = [] as Messages + const message = messages[0] + + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call') { + if (part.name === 'deleteAccount') { + // Approval tool → `approval` metadata is accessible + typed. + expectTypeOf(part.approval).toMatchTypeOf< + | { id: string; needsApproval: boolean; approved?: boolean } + | undefined + >() + } + if (part.name === 'getGuitar') { + // Non-approval tool → the `approval` field does not exist on the + // part. This @ts-expect-error is the bidirectional guard: if the + // gate regressed and `approval` reappeared, this directive would + // become unused and fail the type-check. + // @ts-expect-error - `approval` is gated behind `needsApproval: true` + void part.approval + } + } + } + } + }) + + // Escape hatch 1: a GENERIC approval handler over a typed, mixed tool union. + // Blindly reading `part.approval` off the union is a compile error, but an + // `'approval' in part` guard narrows to the approval-bearing members. + it('supports a generic handler via `in` narrowing on the typed union', () => { + const options = createChatClientOptions({ + connection: stubConnection, + tools: [guitarTool, approvalTool] as const, + }) + const messages = [] as InferChatMessages + const message = messages[0] + + if (message?.role === 'assistant') { + for (const part of message.parts) { + if (part.type === 'tool-call' && 'approval' in part && part.approval) { + expectTypeOf(part.approval.id).toEqualTypeOf() + } + } + } + }) + + // Escape hatch 2: type the reusable handler against the base `ToolCallPart` + // (default/untyped), which always carries `approval?` — so a shared approval + // component works across every tool regardless of the caller's tool union. + it('supports a generic handler typed against the base ToolCallPart', () => { + const handleApproval = (part: ToolCallPart) => part.approval?.id + expectTypeOf(handleApproval).returns.toEqualTypeOf() + }) +}) diff --git a/packages/ai/src/activities/chat/tools/tool-definition.ts b/packages/ai/src/activities/chat/tools/tool-definition.ts index 10195b51b..fe62bd4ac 100644 --- a/packages/ai/src/activities/chat/tools/tool-definition.ts +++ b/packages/ai/src/activities/chat/tools/tool-definition.ts @@ -26,6 +26,10 @@ export interface ClientTool< TOutput extends SchemaInput = SchemaInput, TName extends string = string, TContext = unknown, + // Captured as a literal (`true` / `false`) so downstream types — notably + // the tool-call part's `approval` field — can be gated on it. Defaults to + // `false` when the tool config omits `needsApproval`. + TNeedsApproval extends boolean = false, > { __toolSide: 'client' name: TName @@ -37,7 +41,7 @@ export interface ClientTool< // because `undefined` doesn't extend the schema constraint. inputSchema?: TInput outputSchema?: TOutput - needsApproval?: boolean + needsApproval?: TNeedsApproval lazy?: boolean metadata?: Record execute?: ToolExecuteFunction @@ -51,18 +55,22 @@ export interface ToolDefinitionInstance< TOutput extends SchemaInput = SchemaInput, TName extends string = string, TContext = unknown, + TNeedsApproval extends boolean = false, > extends Tool { __toolSide: 'definition' + // Narrow the base `needsApproval?: boolean` to the captured literal so it + // survives into `ToolCallPartForTool`'s approval gate. + needsApproval?: TNeedsApproval } /** * Union type for any kind of client-side tool (client tool or definition) */ export type AnyClientTool = - | (Omit, 'execute'> & { + | (Omit, 'execute'> & { execute?: ((args: any, context?: any) => any) | undefined }) - | (Omit, 'execute'> & { + | (Omit, 'execute'> & { execute?: ((args: any, context?: any) => any) | undefined }) @@ -100,12 +108,13 @@ export interface ToolDefinitionConfig< TInput extends SchemaInput = SchemaInput, TOutput extends SchemaInput = SchemaInput, TName extends string = string, + TNeedsApproval extends boolean = false, > { name: TName description: string inputSchema?: TInput outputSchema?: TOutput - needsApproval?: boolean + needsApproval?: TNeedsApproval lazy?: boolean metadata?: Record } @@ -117,7 +126,14 @@ export interface ToolDefinition< TInput extends SchemaInput = SchemaInput, TOutput extends SchemaInput = SchemaInput, TName extends string = string, -> extends ToolDefinitionInstance { + TNeedsApproval extends boolean = false, +> extends ToolDefinitionInstance< + TInput, + TOutput, + TName, + unknown, + TNeedsApproval +> { /** * Create a server-side tool with execute function */ @@ -126,11 +142,13 @@ export interface ToolDefinition< ) => ServerTool /** - * Create a client-side tool with optional execute function + * Create a client-side tool with optional execute function. + * Carries the definition's `needsApproval` literal through to the client + * tool so the tool-call part's `approval` field stays gated on it. */ client: ( execute?: ToolExecuteFunction, - ) => ClientTool + ) => ClientTool } /** @@ -192,10 +210,14 @@ export function toolDefinition< TInput extends SchemaInput = SchemaInput, TOutput extends SchemaInput = SchemaInput, TName extends string = string, + // `const` forces the literal (`true` / `false`) to be captured from the + // config's optional `needsApproval` — without it TS widens to `boolean`, + // which collapses the approval gate in `ToolCallPartForTool`. + const TNeedsApproval extends boolean = false, >( - config: ToolDefinitionConfig, -): ToolDefinition { - const definition: ToolDefinition = { + config: ToolDefinitionConfig, +): ToolDefinition { + const definition: ToolDefinition = { __toolSide: 'definition', ...config, server( @@ -210,7 +232,7 @@ export function toolDefinition< client( execute?: ToolExecuteFunction, - ): ClientTool { + ): ClientTool { return { __toolSide: 'client', ...config, From 0fc0134a0b45300aadc017e5efa590525ef51145 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Jul 2026 13:31:14 +0200 Subject: [PATCH 04/14] 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/config.json | 9 +- docs/migration/ag-ui-compliance.md | 21 +- docs/tools/client-tools.md | 7 + docs/tools/tool-approval.md | 228 ++++++++++++------ examples/ts-react-chat/src/routeTree.gen.ts | 21 ++ .../ts-react-chat/src/routes/api.tanchat.ts | 2 +- .../src/routes/typesafe-tools.tsx | 197 +++++++++++++++ examples/ts-vue-chat/vite.config.ts | 2 +- .../ai/skills/ai-core/tool-calling/SKILL.md | 12 + .../e2e/src/components/ToolCallDisplay.tsx | 10 + testing/e2e/src/routes/tools-test.tsx | 26 +- testing/e2e/tests/tool-calling.spec.ts | 22 ++ 12 files changed, 474 insertions(+), 83 deletions(-) create mode 100644 examples/ts-react-chat/src/routes/typesafe-tools.tsx diff --git a/docs/config.json b/docs/config.json index ceb80c4f7..bb7fa0fa9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -99,12 +99,14 @@ { "label": "Client Tools", "to": "tools/client-tools", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-08" }, { "label": "Tool Approval Flow", "to": "tools/tool-approval", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-08" }, { "label": "Lazy Tool Discovery", @@ -456,7 +458,8 @@ { "label": "AG-UI Client Compliance", "to": "migration/ag-ui-compliance", - "addedAt": "2026-05-16" + "addedAt": "2026-05-16", + "updatedAt": "2026-07-08" }, { "label": "Sampling → modelOptions", diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 7a555f5d3..8ff4f72d0 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -179,8 +179,9 @@ import { serverTools } from './tools' export async function POST(req: Request) { const params = await chatParamsFromRequest(req) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.5'), messages: params.messages, + // `mergeAgentTools` returns a plain array — pass it straight to `tools`. tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools }) return toServerSentEventsResponse(stream) @@ -189,6 +190,24 @@ export async function POST(req: Request) { `mergeAgentTools` registers client-declared tools as no-execute stubs server-side. The runtime emits a `ClientToolRequest` event when the model calls one; the client executes via its registered handler and posts the result back. +> **Security — merging trusts the client to define part of the tool surface.** +> `params.tools` is attacker-controllable: a malicious or compromised client can +> put any `name` / `description` / `parameters` it likes in `RunAgentInput.tools`. +> Merging them means those definitions are advertised to the model. Server tools +> still win on name collision (a client **cannot** shadow or hijack a server +> tool's `execute`), and client-declared tools are no-execute — they only ever +> run by round-tripping back to that same client. But a client can still **expand +> the advertised tool surface** and **inject arbitrary text into the model's +> context** through tool names and descriptions (a prompt-injection vector). +> +> **The safe default is to register your tool definitions statically** in the +> server's `tools` array (including client-executed tools — a definition with no +> `.server()` still works) and **not** call `mergeAgentTools`. Then any tools a +> client declares in the payload are ignored: the model is never told about +> them, so it never calls them and they can't run. Only reach for +> `mergeAgentTools` when you genuinely want the client to drive tool +> advertisement and you trust that client. + ## `forwardedProps` security (Tier 2+ only) Skip this section if you're on Tier 1. `forwardedProps` is only surfaced when you opt into `chatParamsFromRequest` (or `chatParamsFromRequestBody`). diff --git a/docs/tools/client-tools.md b/docs/tools/client-tools.md index 321bcf604..4feb88cb2 100644 --- a/docs/tools/client-tools.md +++ b/docs/tools/client-tools.md @@ -118,6 +118,13 @@ export async function POST(request: Request) { } ``` +> **Security:** registering the definitions statically (as above) is the safe +> default — the server alone decides which tools the model sees, so a client +> can't advertise tools you didn't sanction. If you'd instead like the client +> to declare its tools per request via AG-UI `RunAgentInput.tools`, use +> [`mergeAgentTools`](../migration/ag-ui-compliance#tier-3--optional-let-the-client-advertise-its-tools) — +> read its security note first, since `params.tools` is client-controlled. + ### Client-Side Create client implementations with automatic execution and full type safety: diff --git a/docs/tools/tool-approval.md b/docs/tools/tool-approval.md index 1d87a3ff8..38ab06249 100644 --- a/docs/tools/tool-approval.md +++ b/docs/tools/tool-approval.md @@ -2,7 +2,7 @@ title: Tool Approval Flow id: tool-approval-flow order: 5 -description: "Require user approval before executing sensitive tools in TanStack AI — approval states, deny flows, and batched approvals with needsApproval." +description: 'Require user approval before executing sensitive tools in TanStack AI — approval states, deny flows, and batched approvals with needsApproval.' keywords: - tanstack ai - tool approval @@ -36,14 +36,14 @@ When a tool requires approval, the typical flow is: Tools can be marked as requiring approval by setting `needsApproval: true` in the definition: ```typescript -import { toolDefinition } from "@tanstack/ai"; -import { z } from "zod"; -import { emailService } from "./email-service"; +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { emailService } from './email-service' // Step 1: Define tool with approval requirement const sendEmailDef = toolDefinition({ - name: "send_email", - description: "Send an email to a recipient", + name: 'send_email', + description: 'Send an email to a recipient', inputSchema: z.object({ to: z.string().email(), subject: z.string(), @@ -54,14 +54,14 @@ const sendEmailDef = toolDefinition({ messageId: z.string(), }), needsApproval: true, // This tool requires approval -}); +}) // Step 2: Create server implementation const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => { // Only executes if approved - await emailService.send({ to, subject, body }); - return { success: true, messageId: "..." }; -}); + await emailService.send({ to, subject, body }) + return { success: true, messageId: '...' } +}) ``` ## Server-Side Approval @@ -69,20 +69,20 @@ const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => { On the server, tools with `needsApproval: true` will pause execution and wait for approval: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { sendEmail } from "./tools"; +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { sendEmail } from './tools' export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages } = await request.json() const stream = chat({ - adapter: openaiText("gpt-5.5"), + adapter: openaiText('gpt-5.5'), messages, tools: [sendEmail], - }); + }) - return toServerSentEventsResponse(stream); + return toServerSentEventsResponse(stream) } ``` @@ -91,12 +91,12 @@ export async function POST(request: Request) { The client receives approval requests and can respond: ```tsx -import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' function ChatComponent() { const { messages, sendMessage, addToolApprovalResponse } = useChat({ - connection: fetchServerSentEvents("/api/chat"), - }); + connection: fetchServerSentEvents('/api/chat'), + }) return (
@@ -105,8 +105,8 @@ function ChatComponent() { {message.parts.map((part) => { // Check for approval requests if ( - part.type === "tool-call" && - part.state === "approval-requested" && + part.type === 'tool-call' && + part.state === 'approval-requested' && part.approval ) { return ( @@ -134,37 +134,125 @@ function ChatComponent() { Deny
- ); + ) } // ... render other parts - return null; + return null })} ))} - ); + ) } ``` +> **Type safety:** When you pass typed `tools` to `useChat`, the `approval` +> field exists **only** on tool-call parts for tools declared with +> `needsApproval: true` — tools without approval have no `approval` field at +> all, so reading it is a compile error that catches a real footgun (checking +> for approval on a tool that can never request it). See +> [Generic approval handlers](#generic-approval-handlers) for how to write a +> tool-agnostic handler under this constraint. + +## Generic Approval Handlers + +A handler that renders an approval prompt for **any** tool (not one specific +tool) is still fully supported — you just can't read `part.approval` off a +typed mixed tool union without first establishing that the field exists. Pick +whichever of these fits: + +**1. Narrow with `'approval' in part`.** This narrows the tool-call union to +exactly the members that can carry approval, so one loop handles every approval +tool with full type safety: + +```tsx +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' + +const deleteData = toolDefinition({ + name: 'delete_data', + description: 'Delete data (requires approval)', + inputSchema: z.object({ key: z.string() }), + needsApproval: true, +}).client(async ({ key }) => ({ deleted: key })) + +const listData = toolDefinition({ + name: 'list_data', + description: 'List available keys', + inputSchema: z.object({}), +}).client(async () => ({ keys: [] as Array })) + +function ApprovalHandler() { + const { messages, addToolApprovalResponse } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + tools: clientTools(deleteData, listData), + }) + + return ( +
+ {messages.flatMap((message) => + message.parts.map((part, i) => { + // `'approval' in part` narrows the union to `needsApproval` tools, + // so this single handler covers every approval tool — no per-tool + // `part.name` branch needed. + if ( + part.type === 'tool-call' && + part.state === 'approval-requested' && + 'approval' in part && + part.approval + ) { + return ( + + ) + } + return null + }), + )} +
+ ) +} +``` + +**2. Type a shared component against the base `ToolCallPart`.** The base type +(from `@tanstack/ai-client`, untyped tools) always carries `approval?`, so a +reusable component works across every tool regardless of the caller's tool +union — this is the [Approval UI Example](#approval-ui-example) below. + +**3. Use an untyped `useChat()`.** With no `tools` generic, every tool-call +part keeps `approval?` exactly as before — no narrowing needed. + ## Approval UI Example Here's a more complete approval UI component: ```tsx -import type { ToolCallPart } from "@tanstack/ai-client"; +import type { ToolCallPart } from '@tanstack/ai-client' function ApprovalPrompt({ part, onApprove, onDeny, }: { - part: ToolCallPart; - onApprove: () => void; - onDeny: () => void; + part: ToolCallPart + onApprove: () => void + onDeny: () => void }) { - // When tools are passed via `clientTools(...)`, `part.input` is the - // parsed, fully-typed argument object. Otherwise parse `part.arguments`. - const args = part.input ?? JSON.parse(part.arguments); + // `part.input` is the parsed, fully-typed argument object — populated once + // the tool's arguments are complete (which they always are at approval + // time). Fall back to parsing the raw `part.arguments` string defensively. + const args = part.input ?? JSON.parse(part.arguments) return (
@@ -191,26 +279,28 @@ function ApprovalPrompt({
- ); + ) } ``` Wire it up from your message renderer. Note the `id` you pass is the **approval id** (`part.approval.id`), not the tool call id: ```tsx ignore -{part.type === "tool-call" && - part.state === "approval-requested" && - part.approval && ( - - addToolApprovalResponse({ id: part.approval!.id, approved: true }) - } - onDeny={() => - addToolApprovalResponse({ id: part.approval!.id, approved: false }) - } - /> - )} +{ + part.type === 'tool-call' && + part.state === 'approval-requested' && + part.approval && ( + + addToolApprovalResponse({ id: part.approval!.id, approved: true }) + } + onDeny={() => + addToolApprovalResponse({ id: part.approval!.id, approved: false }) + } + /> + ) +} ``` ## Client Tools with Approval @@ -218,15 +308,15 @@ Wire it up from your message renderer. Note the `id` you pass is the **approval Client tools can also require approval: ```typescript -import { toolDefinition } from "@tanstack/ai"; -import { z } from "zod"; -import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -import { clientTools } from "@tanstack/ai-client"; +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' // tools/definitions.ts const deleteLocalDataDef = toolDefinition({ - name: "delete_local_data", - description: "Delete data from local storage", + name: 'delete_local_data', + description: 'Delete data from local storage', inputSchema: z.object({ key: z.string(), }), @@ -234,35 +324,35 @@ const deleteLocalDataDef = toolDefinition({ deleted: z.boolean(), }), needsApproval: true, // Requires approval even on client -}); +}) // Client: Create implementation const deleteLocalData = deleteLocalDataDef.client((input) => { // This will only execute after approval - localStorage.removeItem(input.key); - return { deleted: true }; -}); + localStorage.removeItem(input.key) + return { deleted: true } +}) const { messages, addToolApprovalResponse } = useChat({ - connection: fetchServerSentEvents("/api/chat"), + connection: fetchServerSentEvents('/api/chat'), // Wrap client tools in `clientTools(...)` so literal tool-name inference is // preserved — this is what lets `part.name === "delete_local_data"` narrow // `part.input` / `part.output` to this tool's types. tools: clientTools(deleteLocalData), // Automatic execution after approval -}); +}) ``` ## Example: E-commerce Purchase ```typescript -import { toolDefinition } from "@tanstack/ai"; -import { z } from "zod"; -import { createOrder } from "./orders"; +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { createOrder } from './orders' // Define tool with approval requirement const purchaseItemDef = toolDefinition({ - name: "purchase_item", - description: "Purchase an item from the store", + name: 'purchase_item', + description: 'Purchase an item from the store', inputSchema: z.object({ itemId: z.string(), quantity: z.number(), @@ -273,13 +363,15 @@ const purchaseItemDef = toolDefinition({ total: z.number(), }), needsApproval: true, -}); +}) // Create server implementation -const purchaseItem = purchaseItemDef.server(async ({ itemId, quantity, price }) => { - const order = await createOrder({ itemId, quantity, price }); - return { orderId: order.id, total: price * quantity }; -}); +const purchaseItem = purchaseItemDef.server( + async ({ itemId, quantity, price }) => { + const order = await createOrder({ itemId, quantity, price }) + return { orderId: order.id, total: price * quantity } + }, +) ``` The user will see an approval prompt showing the item, quantity, and price before the purchase is made. The tool will only execute after the user approves. diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 69a7764a2..b223bce09 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TypesafeToolsRouteImport } from './routes/typesafe-tools' import { Route as ThreadsRouteImport } from './routes/threads' import { Route as ServerFnChatRouteImport } from './routes/server-fn-chat' import { Route as SandboxesRouteImport } from './routes/sandboxes' @@ -54,6 +55,11 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +const TypesafeToolsRoute = TypesafeToolsRouteImport.update({ + id: '/typesafe-tools', + path: '/typesafe-tools', + getParentRoute: () => rootRouteImport, +} as any) const ThreadsRoute = ThreadsRouteImport.update({ id: '/threads', path: '/threads', @@ -291,6 +297,7 @@ export interface FileRoutesByFullPath { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/typesafe-tools': typeof TypesafeToolsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -337,6 +344,7 @@ export interface FileRoutesByTo { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/typesafe-tools': typeof TypesafeToolsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -384,6 +392,7 @@ export interface FileRoutesById { '/sandboxes': typeof SandboxesRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/typesafe-tools': typeof TypesafeToolsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -432,6 +441,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/typesafe-tools' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -478,6 +488,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/typesafe-tools' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -524,6 +535,7 @@ export interface FileRouteTypes { | '/sandboxes' | '/server-fn-chat' | '/threads' + | '/typesafe-tools' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -571,6 +583,7 @@ export interface RootRouteChildren { SandboxesRoute: typeof SandboxesRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute + TypesafeToolsRoute: typeof TypesafeToolsRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -607,6 +620,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/typesafe-tools': { + id: '/typesafe-tools' + path: '/typesafe-tools' + fullPath: '/typesafe-tools' + preLoaderRoute: typeof TypesafeToolsRouteImport + parentRoute: typeof rootRouteImport + } '/threads': { id: '/threads' path: '/threads' @@ -931,6 +951,7 @@ const rootRouteChildren: RootRouteChildren = { SandboxesRoute: SandboxesRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, + TypesafeToolsRoute: TypesafeToolsRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index a815d1e13..775723aaa 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -358,7 +358,7 @@ export const Route = createFileRoute('/api/tanchat')({ const stream = chat({ ...options, - tools: Object.values(mergedTools), + tools: mergedTools, middleware: [loggingMiddleware, runtimeContextMiddleware], context: runtimeContext, systemPrompts: [SYSTEM_PROMPT], diff --git a/examples/ts-react-chat/src/routes/typesafe-tools.tsx b/examples/ts-react-chat/src/routes/typesafe-tools.tsx new file mode 100644 index 000000000..dbd4610d9 --- /dev/null +++ b/examples/ts-react-chat/src/routes/typesafe-tools.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { + addToCartToolDef, + getGuitarsToolDef, + recommendGuitarToolDef, +} from '@/lib/guitar-tools' + +/** + * Manual type-safety confirmation for passing a BARE inline `tools` array to + * `useChat` — no `clientTools(...)` wrapper and no `as const`. The `const` + * modifier on `useChat`'s `TTools` param now captures the tuple + literal tool + * names, so tool-call parts narrow on `part.name` and expose per-tool typed + * `input` / `output`. + * + * The assertions below are the real proof: this file only compiles if + * inference works. If the `const` modifier regressed, `part.name` would widen + * to `string`, the `===` checks would stop narrowing, and the typed field + * access (`.output.id` as `number`, `.input.quantity` as `number`) would error. + */ +function TypesafeToolsPage() { + const [prompt, setPrompt] = useState('Recommend me an acoustic guitar.') + + const { messages, sendMessage, isLoading, error } = useChat({ + id: 'typesafe-tools-bare-array', + connection: fetchServerSentEvents('/api/tanchat'), + body: { provider: 'openai', model: 'gpt-5.5' }, + // 👇 Bare array literal — no clientTools(), no `as const`. + tools: [ + getGuitarsToolDef, + recommendGuitarToolDef.client(({ id }) => ({ id: Number(id) })), + addToCartToolDef.client((args) => ({ + success: true, + cartId: `CART_${args.guitarId}`, + guitarId: args.guitarId, + quantity: args.quantity, + totalItems: args.quantity, + })), + ], + }) + + const toolCalls = messages + .flatMap((message) => message.parts) + .filter((part) => part.type === 'tool-call') + + // Hard regression guard: this assignment only compiles when the bare array + // preserved the literal tool names. If inference widened `part.name` to + // `string` (the pre-`const` behaviour), assigning it to the literal union + // below is a type error — so this file failing to compile IS the signal. + const _assertToolNames: Array< + 'getGuitars' | 'recommendGuitar' | 'addToCart' + > = toolCalls.map((part) => part.name) + void _assertToolNames + + // `approval` is gated on the tool's `needsApproval` flag. `addToCart` is + // defined with `needsApproval: true`, so its part carries `approval`; + // `getGuitars` isn't, so the field doesn't exist on its part at all. + const _assertApprovalGating = () => { + for (const part of toolCalls) { + if (part.name === 'addToCart') { + void part.approval // ✅ present — addToCart is needsApproval: true + } + if (part.name === 'getGuitars') { + // @ts-expect-error - getGuitars has no needsApproval, so no `approval` + void part.approval + } + } + } + void _assertApprovalGating + + return ( +
+
+
+

+ Type-safety confirmation +

+

+ Bare inline tools array +

+

+ tools is a plain array literal — no{' '} + clientTools(...) wrapper, no as const. + Tool-call parts below are fully narrowed on part.name. +

+
+ +
+