diff --git a/.changeset/tool-call-approval-gating.md b/.changeset/tool-call-approval-gating.md new file mode 100644 index 000000000..eb7bc47eb --- /dev/null +++ b/.changeset/tool-call-approval-gating.md @@ -0,0 +1,55 @@ +--- +'@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 change (types only) + +**This is the primary migration surface for this release.** When you pass a typed +`tools` array to `useChat` / `createChat` / `injectChat`, reading `part.approval` +on a mixed tool-call union **without first narrowing by `part.name`** no longer +compiles. Code that previously did `part.approval?.id` in a generic handler over +all tool-call parts must be updated: + +```ts +// ❌ No longer compiles on a typed mixed union +part.approval?.id + +// ✅ Narrow to an approval-required tool first +if (part.name === 'deleteAccount') part.approval?.id + +// ✅ Or guard with `in` +if ('approval' in part) part.approval?.id + +// ✅ Or type the handler against the base (untyped) ToolCallPart +function handleApproval(part: ToolCallPart) { + return part.approval?.id +} +``` + +Untyped `useChat()` (no inferred `tools` generic) and the base `ToolCallPart` +type are unaffected: `approval` stays available on every tool-call part there. +**Runtime behavior is unchanged** — only TypeScript narrowing is stricter. + +Adds a `TNeedsApproval extends boolean` type parameter (defaulting to `false`) +to the client tool types; existing explicit type arguments keep working via the +default. Literal capture requires `toolDefinition({ needsApproval: true })` at +the call site — a dynamic `needsApproval: boolean` variable will not gate the +type. 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/.changeset/usechat-const-tools-inference.md b/.changeset/usechat-const-tools-inference.md new file mode 100644 index 000000000..fb205f67a --- /dev/null +++ b/.changeset/usechat-const-tools-inference.md @@ -0,0 +1,18 @@ +--- +'@tanstack/ai-angular': patch +'@tanstack/ai-preact': patch +'@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, `injectChat` in Angular) 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/docs/advanced/runtime-context.md b/docs/advanced/runtime-context.md index 18c703fb7..cb9df6b84 100644 --- a/docs/advanced/runtime-context.md +++ b/docs/advanced/runtime-context.md @@ -79,7 +79,6 @@ This inference also works when reusable tools or middleware are declared outside The same rule applies on the client: ```typescript -import { clientTools } from "@tanstack/ai-client"; import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; import { toolDefinition } from "@tanstack/ai"; @@ -99,7 +98,7 @@ const inspectClientContext = toolDefinition({ useChat({ connection: fetchServerSentEvents("/api/chat"), - tools: clientTools(inspectClientContext), + tools: [inspectClientContext], context: { currentTabId: "settings", mode: "debug", @@ -184,7 +183,7 @@ When any tool or middleware in a `chat()` call declares a concrete context type, Client runtime context is local to `ChatClient` and framework hooks. It is passed to client tool implementations and is not serialized to the server. ```typescript -import { createChatClientOptions, clientTools } from "@tanstack/ai-client"; +import { createChatClientOptions } from "@tanstack/ai-client"; import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; import { toolDefinition } from "@tanstack/ai"; @@ -203,7 +202,7 @@ const notifyUser = toolDefinition({ const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), - tools: clientTools(notifyUser), + tools: [notifyUser], context: { currentTabId: "settings", toast: (message) => window.alert(message), @@ -221,7 +220,6 @@ To send serializable client data to the server, use `forwardedProps`, validate i ```typescript import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -import { clientTools } from "@tanstack/ai-client"; import { toolDefinition } from "@tanstack/ai"; type ClientContext = { @@ -240,7 +238,7 @@ const notifyUser = toolDefinition({ // Client useChat({ connection: fetchServerSentEvents("/api/chat"), - tools: clientTools(notifyUser), + tools: [notifyUser], forwardedProps: { tenantId: "tenant_456", }, diff --git a/docs/api/ai-angular.md b/docs/api/ai-angular.md index 438c3bf46..291ad2cc5 100644 --- a/docs/api/ai-angular.md +++ b/docs/api/ai-angular.md @@ -234,7 +234,6 @@ import { Component } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular"; import { - clientTools, createChatClientOptions, type InferChatMessages, } from "@tanstack/ai-client"; @@ -280,7 +279,7 @@ export class TypedChatComponent { }); // Create typed tools array (no 'as const' needed!) - private tools = clientTools(this.updateUI, this.saveToStorage); + private tools = [this.updateUI, this.saveToStorage]; chat = injectChat({ connection: fetchServerSentEvents("/api/chat"), @@ -526,7 +525,6 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`). ```typescript import { - clientTools, createChatClientOptions, type InferChatMessages, } from "@tanstack/ai-client"; @@ -534,7 +532,7 @@ import { fetchServerSentEvents } from "@tanstack/ai-angular"; import { tool1, tool2 } from "./tools"; // Create typed tools array (no 'as const' needed!) -const tools = clientTools(tool1, tool2); +const tools = [tool1, tool2]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index b926516dd..b08624dc4 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -28,7 +28,6 @@ The main client class for managing chat state. ```typescript import { ChatClient, - clientTools, fetchServerSentEvents, type UIMessage, } from "@tanstack/ai-client"; @@ -37,7 +36,7 @@ import { myClientTool } from "./tools"; const client = new ChatClient({ connection: fetchServerSentEvents("/api/chat"), initialMessages: [], - tools: clientTools(myClientTool), + tools: [myClientTool], onMessagesChange: (messages: UIMessage[]) => { console.log("Messages updated:", messages); }, @@ -281,7 +280,7 @@ const adapter = stream(async (messages, data, signal) => { ### `clientTools(...tools)` -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. +**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. ```typescript import { @@ -320,7 +319,7 @@ const tool2Client = myTool2.client((input) => { return { result: input.query }; }); -// Create typed tools array (no 'as const' needed!) +// The explicit-capture form (equivalent to `[tool1Client, tool2Client]`). const tools = clientTools(tool1Client, tool2Client); // 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. ```typescript import { createChatClientOptions, - clientTools, fetchServerSentEvents, type InferChatMessages, } from "@tanstack/ai-client"; import { tool1, tool2 } from "./tools"; -const tools = clientTools(tool1, tool2); +const tools = [tool1, tool2]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), @@ -370,7 +368,6 @@ type ChatMessages = InferChatMessages; ```typescript import { createChatClientOptions, - clientTools, fetchServerSentEvents, } from "@tanstack/ai-client"; import { toolDefinition } from "@tanstack/ai"; @@ -394,7 +391,7 @@ const tool = projectTool.client((input, ctx: { context: ClientCon const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), - tools: clientTools(tool), + tools: [tool], context: { activeProjectId: "project_123", }, @@ -454,12 +451,12 @@ interface ToolCallPart { arguments: string; // JSON string (may be incomplete during streaming) input?: any; // Parsed tool input (typed from tool's inputSchema) state: ToolCallState; - approval?: ApprovalRequest; + approval?: ApprovalRequest; // only on tools declared `needsApproval: true` output?: any; // Tool execution output (typed from tool's outputSchema) } ``` -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. +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. ### `ToolResultPart` diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index d22977c34..8876a6e62 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -27,7 +27,6 @@ Main hook for managing chat state in Preact with full type safety. ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -50,7 +49,7 @@ function ChatComponent() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI); + const tools = [updateUI]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), @@ -246,7 +245,6 @@ export function ChatWithApproval() { ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -282,7 +280,7 @@ export function ChatWithClientTools() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI, saveToStorage); + const tools = [updateUI, saveToStorage]; const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents("/api/chat"), @@ -311,7 +309,6 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`). ```typescript import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -319,7 +316,7 @@ import { fetchServerSentEvents } from "@tanstack/ai-preact"; import { tool1, tool2 } from "./tools"; // Create typed tools array (no 'as const' needed!) -const tools = clientTools(tool1, tool2); +const tools = [tool1, tool2]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index 0cc599357..01eb1587b 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -33,7 +33,6 @@ Main hook for managing chat state in React with full type safety. ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -60,7 +59,7 @@ function ChatComponent() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI); + const tools = [updateUI]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), @@ -275,7 +274,6 @@ export function ChatWithApproval() { ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -319,7 +317,7 @@ export function ChatWithClientTools() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI, saveToStorage); + const tools = [updateUI, saveToStorage]; const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents("/api/chat"), @@ -348,7 +346,6 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`). ```typescript import { - clientTools, createChatClientOptions, fetchServerSentEvents, type InferChatMessages @@ -356,7 +353,7 @@ import { import { tool1, tool2 } from "./tools"; // Create typed tools array (no 'as const' needed!) -const tools = clientTools(tool1, tool2); +const tools = [tool1, tool2]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 855ce883e..46de176a9 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -28,7 +28,6 @@ Main primitive for managing chat state in SolidJS with full type safety. ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-solid"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -51,7 +50,7 @@ function ChatComponent() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI); + const tools = [updateUI]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), @@ -259,7 +258,6 @@ export function ChatWithApproval() { ```tsx import { useChat, fetchServerSentEvents } from "@tanstack/ai-solid"; import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -295,7 +293,7 @@ export function ChatWithClientTools() { }); // Create typed tools array (no 'as const' needed!) - const tools = clientTools(updateUI, saveToStorage); + const tools = [updateUI, saveToStorage]; const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents("/api/chat"), @@ -328,7 +326,6 @@ Helper to create typed chat options (re-exported from `@tanstack/ai-client`). ```typescript import { - clientTools, createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client"; @@ -336,7 +333,7 @@ import { fetchServerSentEvents } from "@tanstack/ai-solid"; import { tool1, tool2 } from "./tools"; // Create typed tools array (no 'as const' needed!) -const tools = clientTools(tool1, tool2); +const tools = [tool1, tool2]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 6f8278043..8e7768b87 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -28,7 +28,6 @@ Factory function for managing chat state in Svelte 5 with full type safety. ```typescript import { createChat, fetchServerSentEvents } from "@tanstack/ai-svelte"; import { - clientTools, createChatClientOptions, type InferChatMessages, } from "@tanstack/ai-client"; @@ -52,7 +51,7 @@ const updateUI = updateUIDef.client((input) => { return { success: true }; }); -const tools = clientTools(updateUI); +const tools = [updateUI]; const chatOptions = createChatClientOptions({ connection: fetchServerSentEvents("/api/chat"), @@ -240,7 +239,6 @@ import {