From b8c1615c4ed5345f6fef4f889566391129187925 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 15 Apr 2026 11:07:08 +0200 Subject: [PATCH 1/7] feat(ai): add type-safe tool call events to chat() stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tools with Zod schemas are passed to chat(), the stream chunks now carry type information on TOOL_CALL_START and TOOL_CALL_END events: - toolName narrows to the union of tool name literals - input on TOOL_CALL_END is typed as the union of tool input types Made ToolCallStartEvent and ToolCallEndEvent generic with backward- compatible defaults. Added TypedStreamChunk type that threads through TextActivityOptions, TextActivityResult, chat(), and createChatOptions(). Includes IsAny guard in ToolInputsOf to prevent `any` leaking through InferSchemaType for tools without inputSchema. Fully backward compatible — StreamChunk and AGUIEvent are unchanged, unparameterized event types use string/unknown defaults. --- docs/chat/streaming.md | 38 ++ docs/reference/type-aliases/StreamChunk.md | 36 +- docs/tools/tools.md | 2 + .../ts-react-chat/src/routes/api.tanchat.ts | 62 +++ .../ai/src/activities/chat/index.ts | 31 +- packages/typescript/ai/src/types.ts | 84 +++- .../typescript/ai/tests/type-check.test.ts | 402 +++++++++++++++++- 7 files changed, 634 insertions(+), 21 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 2c799a772..ca968ee66 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -78,6 +78,44 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) > **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content). +### Type-Safe Tool Call Events + +When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. The `toolName` field narrows to the union of your tool name literals, and the `input` field on `TOOL_CALL_END` events is typed as the union of your tool input schemas: + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +const weatherTool = toolDefinition({ + name: "get_weather", + description: "Get weather for a location", + inputSchema: z.object({ + location: z.string(), + unit: z.enum(["celsius", "fahrenheit"]).optional(), + }), +}); + +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [weatherTool], +}); + +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + chunk.toolName; // ✅ typed as "get_weather" (not string) + chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } + } +} +``` + +Without typed tools, `toolName` defaults to `string` and `input` defaults to `unknown` — the same behavior as before. The type narrowing is automatic when you use `toolDefinition()` with Zod schemas. + +> **Note:** When multiple tools are provided, `input` is typed as the union of all tool input types. Checking `toolName === 'get_weather'` does not narrow `input` to that specific tool's input type — if you need per-tool discrimination, use a type guard after the `toolName` check. + +> **Tip:** The typed stream chunk type is exported as `TypedStreamChunk` if you need to annotate variables or function parameters. When used without type arguments, `TypedStreamChunk` is equivalent to `StreamChunk`. + ### Thinking Chunks Thinking/reasoning is represented by AG-UI events `STEP_STARTED` and `STEP_FINISHED`. They stream separately from the final response text: diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index 4c0fb5cdb..35751e864 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -9,7 +9,41 @@ title: StreamChunk type StreamChunk = AGUIEvent; ``` -Defined in: [types.ts:976](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L976) +Defined in: [types.ts:989](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L989) Chunk returned by the SDK during streaming chat completions. Uses the AG-UI protocol event format. + +# Type Alias: TypedStreamChunk + +```ts +type TypedStreamChunk> = ReadonlyArray>> +``` + +Defined in: [types.ts:1033](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1033) + +A variant of `StreamChunk` parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): + +- `TOOL_CALL_START` and `TOOL_CALL_END` events have `toolName` narrowed to the union of known tool name literals. +- `TOOL_CALL_END` events have `input` typed as the union of tool input types. + +When tools are untyped or absent, `TypedStreamChunk` degrades to the same type as `StreamChunk`. + +This is the type returned by `chat()` when streaming is enabled (the default). You don't typically need to reference it directly unless annotating function parameters or return types. + +```ts +import type { TypedStreamChunk } from "@tanstack/ai"; +import { toolDefinition } from "@tanstack/ai"; + +// Given tools created with toolDefinition(): +const weatherTool = toolDefinition({ name: "get_weather", description: "...", inputSchema: /* Zod schema */ }); +const searchTool = toolDefinition({ name: "search", description: "...", inputSchema: /* Zod schema */ }); + +// Without type args — equivalent to StreamChunk +type Chunk = TypedStreamChunk; + +// With specific tools — tool call events are typed +type TypedChunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]>; +``` + +See [Streaming - Type-Safe Tool Call Events](../../chat/streaming) for a practical walkthrough. diff --git a/docs/tools/tools.md b/docs/tools/tools.md index c0a651a95..ac18636c2 100644 --- a/docs/tools/tools.md +++ b/docs/tools/tools.md @@ -78,6 +78,8 @@ const inputSchema: JSONSchema = { > **Note:** When using JSON Schema, TypeScript will infer `any` for input/output types since JSON Schema cannot provide compile-time type information. Zod schemas are recommended for full type safety. +> **Tip:** Type safety from Zod schemas extends beyond tool execution — when you iterate over the stream returned by `chat()`, tool call events have typed `toolName` and `input` fields too. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). + ## Tool Definition Tools are defined using `toolDefinition()` from `@tanstack/ai`: diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index a1eb8ee02..132088010 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -108,6 +108,68 @@ const loggingMiddleware: ChatMiddleware = { }, } +// =========================== +// TypedStreamChunk showcase — type-safe tool call events +// =========================== +// +// When `chat()` receives tools with typed schemas, the returned stream +// carries type information on TOOL_CALL_START and TOOL_CALL_END events. +// No casts, no `as any` — just narrow by `chunk.type` and everything is typed. + +const tools = [ + getGuitars, + recommendGuitarToolDef, + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, + compareGuitars, + calculateFinancing, + searchGuitars, +] as const + +async function typedStreamShowcase() { + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: [{ role: 'user' as const, content: 'Recommend an acoustic guitar' }], + tools, + }) + + for await (const chunk of stream) { + switch (chunk.type) { + case 'TOOL_CALL_START': + // ✅ chunk.toolName is typed as the union of all tool name literals: + // 'getGuitars' | 'recommendGuitar' | 'addToCart' | 'addToWishList' + // | 'getPersonalGuitarPreference' | 'compareGuitars' + // | 'calculateFinancing' | 'searchGuitars' + // + // ❌ Without TypedStreamChunk, this would just be `string` + console.log(`Tool call started: ${chunk.toolName}`) + break + + case 'TOOL_CALL_END': + // ✅ chunk.toolName — same typed literal union as above + // ✅ chunk.input — union of all tool input types, inferred from Zod schemas: + // | {} + // | { id: string | number } + // | { guitarId: string; quantity: number } + // | { guitarId: string } + // | { guitarIds: number[] } + // | { guitarId: number; months: number } + // | { query: string } + console.log(`Tool call ended: ${chunk.toolName}`, chunk.input) + break + + case 'TEXT_MESSAGE_CONTENT': + // Non-tool events are unaffected — still fully typed + console.log(chunk.delta) + break + } + } +} + +// Suppress unused warning — this is a showcase, not called at runtime +void typedStreamShowcase + export const Route = createFileRoute('/api/tanchat')({ server: { handlers: { diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 24cc41529..83b7b1c6b 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -43,6 +43,7 @@ import type { ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, + TypedStreamChunk, } from '../../types' import type { ChatMiddleware, @@ -69,11 +70,13 @@ export const kind = 'text' as const * @template TAdapter - The text adapter type (created by a provider function) * @template TSchema - Optional Standard Schema for structured output * @template TStream - Whether to stream the output (default: true) + * @template TTools - The tools array type for type-safe tool call events in the stream */ export interface TextActivityOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined, TStream extends boolean, + TTools extends ReadonlyArray> = ReadonlyArray>, > { /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ adapter: TAdapter @@ -87,7 +90,7 @@ export interface TextActivityOptions< /** System prompts to prepend to the conversation */ systemPrompts?: TextOptions['systemPrompts'] /** Tools for function calling (auto-executed when called) */ - tools?: TextOptions['tools'] + tools?: TTools /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */ temperature?: TextOptions['temperature'] /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */ @@ -125,7 +128,7 @@ export interface TextActivityOptions< outputSchema?: TSchema /** * Whether to stream the text result. - * When true (default), returns an AsyncIterable for streaming output. + * When true (default), returns an AsyncIterable> for streaming output. * When false, returns a Promise with the collected text content. * * Note: If outputSchema is provided, this option is ignored and the result @@ -186,9 +189,10 @@ export function createChatOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, + TTools extends ReadonlyArray> = ReadonlyArray>, >( - options: TextActivityOptions, -): TextActivityOptions { + options: TextActivityOptions, +): TextActivityOptions { return options } @@ -200,16 +204,20 @@ export function createChatOptions< * Result type for the text activity. * - If outputSchema is provided: Promise> * - If stream is false: Promise - * - Otherwise (stream is true, default): AsyncIterable + * - Otherwise (stream is true, default): AsyncIterable> + * + * When tools with typed schemas are provided, the stream chunks include + * type-safe `toolName` and `input` fields on tool call events. */ export type TextActivityResult< TSchema extends SchemaInput | undefined, TStream extends boolean = true, + TTools extends ReadonlyArray> = ReadonlyArray>, > = TSchema extends SchemaInput ? Promise> : TStream extends false ? Promise - : AsyncIterable + : AsyncIterable> // =========================== // ChatEngine Implementation @@ -1374,9 +1382,10 @@ export function chat< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, + TTools extends ReadonlyArray> = ReadonlyArray>, >( - options: TextActivityOptions, -): TextActivityResult { + options: TextActivityOptions, +): TextActivityResult { const { outputSchema, stream } = options // If outputSchema is provided, run agentic structured output @@ -1387,7 +1396,7 @@ export function chat< SchemaInput, boolean >, - ) as TextActivityResult + ) as TextActivityResult } // If stream is explicitly false, run non-streaming text @@ -1398,13 +1407,13 @@ export function chat< undefined, false >, - ) as TextActivityResult + ) as TextActivityResult } // Otherwise, run streaming text (default) return runStreamingText( options as unknown as TextActivityOptions, - ) as TextActivityResult + ) as TextActivityResult } /** diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 984e15125..1d643a874 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -840,13 +840,18 @@ export interface TextMessageEndEvent extends BaseAGUIEvent { /** * Emitted when a tool call starts. + * + * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). + * When the stream is returned from `chat()` with typed tools, this narrows to + * the union of tool name literals. */ -export interface ToolCallStartEvent extends BaseAGUIEvent { +export interface ToolCallStartEvent + extends BaseAGUIEvent { type: 'TOOL_CALL_START' /** Unique identifier for this tool call */ toolCallId: string /** Name of the tool being called */ - toolName: string + toolName: TToolName /** ID of the parent message that initiated this tool call */ parentMessageId?: string /** Index for parallel tool calls */ @@ -870,15 +875,23 @@ export interface ToolCallArgsEvent extends BaseAGUIEvent { /** * Emitted when a tool call completes. + * + * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). + * @typeParam TInput - Constrained input arguments type. Defaults to `unknown` (untyped). + * When the stream is returned from `chat()` with typed tools, these narrow to + * the union of tool name literals and the union of tool input types respectively. */ -export interface ToolCallEndEvent extends BaseAGUIEvent { +export interface ToolCallEndEvent< + TToolName extends string = string, + TInput = unknown, +> extends BaseAGUIEvent { type: 'TOOL_CALL_END' /** Tool call identifier */ toolCallId: string /** Name of the tool */ - toolName: string + toolName: TToolName /** Final parsed input arguments */ - input?: unknown + input?: TInput /** Tool execution result (if executed) */ result?: string } @@ -975,6 +988,67 @@ export type AGUIEvent = */ export type StreamChunk = AGUIEvent +// ============================================================================ +// Typed Stream Chunks (tool-aware) +// ============================================================================ + +/** + * Extract tool name literals from a tools array type. + * When tools have specific name literals (e.g. `'get_weather'`), returns + * their union. When tools are untyped (generic `string`) or empty, returns `string`. + * @internal + */ +type ToolNamesOf>> = + [TTools[number]] extends [never] + ? string + : string extends TTools[number]['name'] + ? string + : TTools[number]['name'] + +/** + * Detect the `any` type. Returns `true` for `any`, `false` for everything else. + * @internal + */ +type IsAny = 0 extends 1 & T ? true : false + +/** + * Infer the union of tool input types from a tools array type. + * When tools have specific name literals (indicating typed tool definitions), + * returns the union of their inferred input types via `InferSchemaType`. + * When tool names are generic `string` or the tools array is empty, returns `unknown`. + * + * Guards against `any` leaking through `InferSchemaType` when `inputSchema` + * defaults to the broad `SchemaInput` union (which includes `StandardJSONSchemaV1`). + * @internal + */ +type ToolInputsOf>> = + [TTools[number]] extends [never] + ? unknown + : string extends TTools[number]['name'] + ? unknown + : TTools[number] extends { inputSchema?: infer TInput } + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown + +/** + * Stream chunk type parameterized by the tools array for type-safe tool call events. + * + * When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): + * - `TOOL_CALL_START` and `TOOL_CALL_END` events have `toolName` narrowed to + * the union of known tool name literals. + * - `TOOL_CALL_END` events have `input` typed as the union of tool input types. + * + * When tools are untyped or absent, degrades to the same type as `StreamChunk`. + */ +export type TypedStreamChunk< + TTools extends ReadonlyArray> = ReadonlyArray>, +> = + | Exclude + | ToolCallStartEvent> + | ToolCallEndEvent, ToolInputsOf> + // Simple streaming format for basic text completions // Converted to StreamChunk format by convertTextCompletionStream() export interface TextCompletionChunk { diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 124710beb..17c5b2c8b 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -1,13 +1,26 @@ /** - * Type-level tests for TextActivityOptions + * Type-level tests for TextActivityOptions and TypedStreamChunk * These should fail to compile if the types are incorrect */ import { describe, it, expectTypeOf } from 'vitest' -import { createChatOptions } from '../src' +import { z } from 'zod' +import { chat, createChatOptions, toolDefinition } from '../src' +import type { + JSONSchema, + StreamChunk, + Tool, + ToolCallArgsEvent, + ToolCallStartEvent, + ToolCallEndEvent, + TypedStreamChunk, +} from '../src' import type { TextAdapter } from '../src/activities/chat/adapter' -// Mock adapter for testing - simulates OpenAI adapter +// =========================== +// Mock adapter (inline — needed for typeof in generic args) +// =========================== + type MockAdapter = TextAdapter< 'test-model', { validOption: string; anotherOption?: number }, @@ -29,6 +42,8 @@ const mockAdapter = { providerOptions: {} as { validOption: string; anotherOption?: number }, inputModalities: ['text', 'image'] as const, messageMetadataByModality: { + // These `as unknown` casts are necessary — TextAdapter requires all 5 + // modality keys but the mock doesn't have real metadata types for them. text: undefined as unknown, image: undefined as unknown, audio: undefined as unknown, @@ -40,9 +55,77 @@ const mockAdapter = { structuredOutput: async () => ({ data: {}, rawText: '{}' }), } satisfies MockAdapter +// =========================== +// Tool definitions for type tests +// =========================== + +const weatherTool = toolDefinition({ + name: 'get_weather', + description: 'Get weather', + inputSchema: z.object({ + location: z.string(), + unit: z.enum(['celsius', 'fahrenheit']).optional(), + }), + outputSchema: z.object({ + temperature: z.number(), + conditions: z.string(), + }), +}) + +const searchTool = toolDefinition({ + name: 'search', + description: 'Search the web', + inputSchema: z.object({ + query: z.string(), + }), +}) + +const weatherServerTool = weatherTool.server(async () => ({ + temperature: 72, + conditions: 'sunny', +})) + +const searchClientTool = searchTool.client(async () => 'results') + +const noInputTool = toolDefinition({ + name: 'get_time', + description: 'Get the current time', +}) + +const jsonSchemaTool: Tool = { + name: 'json_tool', + description: 'A tool with plain JSON Schema', + inputSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + }, +} + +// =========================== +// Type-level helpers to reduce Extract repetition +// =========================== + +/** Extract the TOOL_CALL_START event from a chunk union */ +type StartEventOf = Extract + +/** Extract the TOOL_CALL_END event from a chunk union */ +type EndEventOf = Extract + +/** Extract the chunk type from an AsyncIterable (e.g. chat() return) */ +type ChunkOf = T extends AsyncIterable ? C : never + +/** Build the full TypedStreamChunk and extract both event types at once */ +type ToolEventsOf>> = { + start: StartEventOf> + end: EndEventOf> +} + +// =========================== +// TextActivityOptions type checking (pre-existing) +// =========================== + describe('TextActivityOptions type checking', () => { it('should allow valid options', () => { - // This should type-check successfully const options = createChatOptions({ adapter: mockAdapter, messages: [{ role: 'user', content: 'Hello' }], @@ -75,3 +158,314 @@ describe('TextActivityOptions type checking', () => { }) }) }) + +// =========================== +// TypedStreamChunk: tool name and input typing +// =========================== + +describe('TypedStreamChunk tool call type safety', () => { + describe('tool name typing', () => { + it('should narrow toolName to literal union on both START and END events', () => { + type E = ToolEventsOf<[typeof weatherTool, typeof searchTool]> + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should narrow toolName to a single literal with one tool', () => { + type E = ToolEventsOf<[typeof weatherTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf().toEqualTypeOf<'get_weather'>() + }) + }) + + describe('tool input typing', () => { + it('should type input as the union of tool input types', () => { + type E = ToolEventsOf<[typeof weatherTool, typeof searchTool]> + + type ExpectedInput = + | { location: string; unit?: 'celsius' | 'fahrenheit' } + | { query: string } + expectTypeOf< + Exclude + >().toEqualTypeOf() + }) + + it('should type input correctly with a single tool', () => { + type E = ToolEventsOf<[typeof searchTool]> + + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + + it('should produce unknown input for tools without inputSchema', () => { + type E = ToolEventsOf<[typeof noInputTool]> + + // Use toBeUnknown() instead of toEqualTypeOf() — + // the latter can't distinguish `any` from `unknown` in vitest. + expectTypeOf< + Exclude + >().toBeUnknown() + }) + + it('should produce unknown input for plain JSON Schema tools', () => { + type E = ToolEventsOf<[typeof jsonSchemaTool]> + + expectTypeOf< + Exclude + >().toBeUnknown() + }) + + it('should preserve tool names when mixing Zod and no-schema tools', () => { + type E = ToolEventsOf<[typeof searchTool, typeof noInputTool]> + + expectTypeOf().toEqualTypeOf< + 'search' | 'get_time' + >() + }) + }) + + describe('server and client tool variants', () => { + it('should type ServerTool name and input from .server()', () => { + type E = ToolEventsOf<[typeof weatherServerTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf< + Exclude + >().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + }) + + it('should type ClientTool name from .client()', () => { + type E = ToolEventsOf<[typeof searchClientTool]> + + expectTypeOf().toEqualTypeOf<'search'>() + }) + + it('should deduplicate names across definition, server, and client variants', () => { + type E = ToolEventsOf< + [typeof weatherTool, typeof weatherServerTool, typeof searchClientTool] + > + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + }) + + describe('non-tool events are preserved', () => { + it('should include all non-tool-call AG-UI events in the union', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + + // Every AG-UI event type should still be extractable + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf< + Extract + >().not.toBeNever() + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + }) + + it('should keep ToolCallArgsEvent unparameterized (string delta, no toolName)', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool]> + type ArgsEvent = Extract + + expectTypeOf().not.toBeNever() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toMatchTypeOf() + }) + }) +}) + +// =========================== +// chat() return type integration +// =========================== + +describe('chat() tool type inference', () => { + it('should infer typed tool names through chat() return type', () => { + type Chunk = ChunkOf< + ReturnType< + typeof chat< + typeof mockAdapter, + undefined, + true, + [typeof weatherTool, typeof searchTool] + > + > + > + + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should infer TTools from options.tools without explicit type args', () => { + // This is the actual user-facing API — if inference breaks, users silently + // get `string` for toolName even when passing typed tools. + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherTool, searchTool], + }) + type Chunk = ChunkOf + + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + expectTypeOf['toolName']>().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) + + it('should return Promise when stream: false, regardless of tools', () => { + type Result = ReturnType< + typeof chat + > + + expectTypeOf().toEqualTypeOf>() + }) + + it('should return Promise when outputSchema is provided', () => { + const schema = z.object({ summary: z.string() }) + type Result = ReturnType< + typeof chat + > + + expectTypeOf().toEqualTypeOf>() + }) +}) + +// =========================== +// createChatOptions() preserves TTools +// =========================== + +describe('createChatOptions() tool type preservation', () => { + it('should preserve specific tool types through options helper', () => { + const opts = createChatOptions({ + adapter: mockAdapter, + tools: [weatherTool, searchTool], + }) + + type ToolsType = Exclude + + // Use union check — tuple ordering is not guaranteed across TS versions + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) +}) + +// =========================== +// Fallback / default behavior +// =========================== + +describe('TypedStreamChunk fallback behavior', () => { + it('should fallback to string/unknown with no tools (default generic)', () => { + type Chunk = ChunkOf>> + + expectTypeOf['toolName']>().toEqualTypeOf() + expectTypeOf['toolName']>().toEqualTypeOf() + expectTypeOf< + Exclude['input'], undefined> + >().toBeUnknown() + }) + + it('should fallback to string/unknown with empty tools array', () => { + type E = ToolEventsOf<[]> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf< + Exclude + >().toBeUnknown() + }) + + it('should fallback to string/unknown when used without type args', () => { + type E = { + start: StartEventOf + end: EndEventOf + } + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf< + Exclude + >().toBeUnknown() + }) + + it('should handle readonly tools array (as const)', () => { + const tools = [weatherTool, searchTool] as const + type E = ToolEventsOf + + expectTypeOf().toEqualTypeOf< + 'get_weather' | 'search' + >() + }) +}) + +// =========================== +// Backward compatibility +// =========================== + +describe('backward compatibility', () => { + it('should preserve unparameterized ToolCallStartEvent/ToolCallEndEvent defaults', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf< + Exclude + >().toBeUnknown() + }) + + it('should treat explicit defaults as identical to unparameterized', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf< + ToolCallEndEvent + >().toEqualTypeOf() + }) + + it('should make typed events assignable to untyped events', () => { + expectTypeOf< + ToolCallStartEvent<'get_weather'> + >().toMatchTypeOf() + expectTypeOf< + ToolCallEndEvent<'get_weather', { location: string }> + >().toMatchTypeOf() + }) + + it('should make TypedStreamChunk assignable to StreamChunk', () => { + type Typed = TypedStreamChunk<[typeof weatherTool]> + expectTypeOf().toMatchTypeOf() + }) + + it('should keep StreamChunk itself unchanged', () => { + type Start = Extract + expectTypeOf().toEqualTypeOf() + }) +}) From a2c2f9c97ae052b30ed5df9a9cba467b1555c4e4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:08:40 +0000 Subject: [PATCH 2/7] ci: apply automated fixes --- .../ts-react-chat/src/routes/api.tanchat.ts | 4 +- .../ai/src/activities/chat/index.ts | 16 +++++-- packages/typescript/ai/src/types.ts | 44 +++++++++++-------- .../typescript/ai/tests/type-check.test.ts | 36 +++++---------- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 132088010..a9d99db92 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -130,7 +130,9 @@ const tools = [ async function typedStreamShowcase() { const stream = chat({ adapter: openaiText('gpt-4o'), - messages: [{ role: 'user' as const, content: 'Recommend an acoustic guitar' }], + messages: [ + { role: 'user' as const, content: 'Recommend an acoustic guitar' }, + ], tools, }) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 83b7b1c6b..18932fea2 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -76,7 +76,9 @@ export interface TextActivityOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined, TStream extends boolean, - TTools extends ReadonlyArray> = ReadonlyArray>, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, > { /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ adapter: TAdapter @@ -189,7 +191,9 @@ export function createChatOptions< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, - TTools extends ReadonlyArray> = ReadonlyArray>, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, >( options: TextActivityOptions, ): TextActivityOptions { @@ -212,7 +216,9 @@ export function createChatOptions< export type TextActivityResult< TSchema extends SchemaInput | undefined, TStream extends boolean = true, - TTools extends ReadonlyArray> = ReadonlyArray>, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, > = TSchema extends SchemaInput ? Promise> : TStream extends false @@ -1382,7 +1388,9 @@ export function chat< TAdapter extends AnyTextAdapter, TSchema extends SchemaInput | undefined = undefined, TStream extends boolean = true, - TTools extends ReadonlyArray> = ReadonlyArray>, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, >( options: TextActivityOptions, ): TextActivityResult { diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 1d643a874..3a784372b 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -845,8 +845,9 @@ export interface TextMessageEndEvent extends BaseAGUIEvent { * When the stream is returned from `chat()` with typed tools, this narrows to * the union of tool name literals. */ -export interface ToolCallStartEvent - extends BaseAGUIEvent { +export interface ToolCallStartEvent< + TToolName extends string = string, +> extends BaseAGUIEvent { type: 'TOOL_CALL_START' /** Unique identifier for this tool call */ toolCallId: string @@ -998,12 +999,13 @@ export type StreamChunk = AGUIEvent * their union. When tools are untyped (generic `string`) or empty, returns `string`. * @internal */ -type ToolNamesOf>> = - [TTools[number]] extends [never] +type ToolNamesOf>> = [ + TTools[number], +] extends [never] + ? string + : string extends TTools[number]['name'] ? string - : string extends TTools[number]['name'] - ? string - : TTools[number]['name'] + : TTools[number]['name'] /** * Detect the `any` type. Returns `true` for `any`, `false` for everything else. @@ -1021,16 +1023,17 @@ type IsAny = 0 extends 1 & T ? true : false * defaults to the broad `SchemaInput` union (which includes `StandardJSONSchemaV1`). * @internal */ -type ToolInputsOf>> = - [TTools[number]] extends [never] +type ToolInputsOf>> = [ + TTools[number], +] extends [never] + ? unknown + : string extends TTools[number]['name'] ? unknown - : string extends TTools[number]['name'] - ? unknown - : TTools[number] extends { inputSchema?: infer TInput } - ? IsAny>> extends true - ? unknown - : InferSchemaType> - : unknown + : TTools[number] extends { inputSchema?: infer TInput } + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown /** * Stream chunk type parameterized by the tools array for type-safe tool call events. @@ -1043,9 +1046,14 @@ type ToolInputsOf>> = * When tools are untyped or absent, degrades to the same type as `StreamChunk`. */ export type TypedStreamChunk< - TTools extends ReadonlyArray> = ReadonlyArray>, + TTools extends ReadonlyArray> = ReadonlyArray< + Tool + >, > = - | Exclude + | Exclude< + StreamChunk, + { type: 'TOOL_CALL_START' } | { type: 'TOOL_CALL_END' } + > | ToolCallStartEvent> | ToolCallEndEvent, ToolInputsOf> diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 17c5b2c8b..a3c411dce 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -209,17 +209,13 @@ describe('TypedStreamChunk tool call type safety', () => { // Use toBeUnknown() instead of toEqualTypeOf() — // the latter can't distinguish `any` from `unknown` in vitest. - expectTypeOf< - Exclude - >().toBeUnknown() + expectTypeOf>().toBeUnknown() }) it('should produce unknown input for plain JSON Schema tools', () => { type E = ToolEventsOf<[typeof jsonSchemaTool]> - expectTypeOf< - Exclude - >().toBeUnknown() + expectTypeOf>().toBeUnknown() }) it('should preserve tool names when mixing Zod and no-schema tools', () => { @@ -236,9 +232,7 @@ describe('TypedStreamChunk tool call type safety', () => { type E = ToolEventsOf<[typeof weatherServerTool]> expectTypeOf().toEqualTypeOf<'get_weather'>() - expectTypeOf< - Exclude - >().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ location: string unit?: 'celsius' | 'fahrenheit' }>() @@ -283,9 +277,7 @@ describe('TypedStreamChunk tool call type safety', () => { expectTypeOf< Extract >().not.toBeNever() - expectTypeOf< - Extract - >().not.toBeNever() + expectTypeOf>().not.toBeNever() expectTypeOf>().not.toBeNever() expectTypeOf>().not.toBeNever() }) @@ -392,9 +384,7 @@ describe('TypedStreamChunk fallback behavior', () => { expectTypeOf['toolName']>().toEqualTypeOf() expectTypeOf['toolName']>().toEqualTypeOf() - expectTypeOf< - Exclude['input'], undefined> - >().toBeUnknown() + expectTypeOf['input'], undefined>>().toBeUnknown() }) it('should fallback to string/unknown with empty tools array', () => { @@ -402,9 +392,7 @@ describe('TypedStreamChunk fallback behavior', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf< - Exclude - >().toBeUnknown() + expectTypeOf>().toBeUnknown() }) it('should fallback to string/unknown when used without type args', () => { @@ -415,9 +403,7 @@ describe('TypedStreamChunk fallback behavior', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf< - Exclude - >().toBeUnknown() + expectTypeOf>().toBeUnknown() }) it('should handle readonly tools array (as const)', () => { @@ -438,13 +424,13 @@ describe('backward compatibility', () => { it('should preserve unparameterized ToolCallStartEvent/ToolCallEndEvent defaults', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - expectTypeOf< - Exclude - >().toBeUnknown() + expectTypeOf>().toBeUnknown() }) it('should treat explicit defaults as identical to unparameterized', () => { - expectTypeOf>().toEqualTypeOf() + expectTypeOf< + ToolCallStartEvent + >().toEqualTypeOf() expectTypeOf< ToolCallEndEvent >().toEqualTypeOf() From 5a37cb89876918215ef7457321309db6ba458a16 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 16 Apr 2026 12:58:10 +0200 Subject: [PATCH 3/7] feat(ai): make tool call events a discriminated union for per-tool input narrowing Replace flat toolName/input unions with distributive conditional types so checking toolName === 'x' narrows input to that specific tool's type. --- docs/chat/streaming.md | 26 ++++- docs/reference/type-aliases/StreamChunk.md | 5 +- .../ts-react-chat/src/routes/api.tanchat.ts | 22 +++-- packages/typescript/ai/src/types.ts | 94 +++++++++++-------- .../typescript/ai/tests/type-check.test.ts | 88 +++++++++++++++++ 5 files changed, 185 insertions(+), 50 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index ca968ee66..0039b2a3c 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -112,7 +112,31 @@ for await (const chunk of stream) { Without typed tools, `toolName` defaults to `string` and `input` defaults to `unknown` — the same behavior as before. The type narrowing is automatic when you use `toolDefinition()` with Zod schemas. -> **Note:** When multiple tools are provided, `input` is typed as the union of all tool input types. Checking `toolName === 'get_weather'` does not narrow `input` to that specific tool's input type — if you need per-tool discrimination, use a type guard after the `toolName` check. +When multiple tools are provided, tool call events form a **discriminated union** — checking `toolName` narrows `input` to that specific tool's type: + +```typescript +const searchTool = toolDefinition({ + name: "search", + inputSchema: z.object({ query: z.string() }), +}); + +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [weatherTool, searchTool], +}); + +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + if (chunk.toolName === "get_weather") { + chunk.input; // ✅ { location: string; unit?: "celsius" | "fahrenheit" } + } + if (chunk.toolName === "search") { + chunk.input; // ✅ { query: string } + } + } +} +``` > **Tip:** The typed stream chunk type is exported as `TypedStreamChunk` if you need to annotate variables or function parameters. When used without type arguments, `TypedStreamChunk` is equivalent to `StreamChunk`. diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index 35751e864..fd6f79e6d 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -24,8 +24,9 @@ Defined in: [types.ts:1033](https://github.com/TanStack/ai/blob/main/packages/ty A variant of `StreamChunk` parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): -- `TOOL_CALL_START` and `TOOL_CALL_END` events have `toolName` narrowed to the union of known tool name literals. -- `TOOL_CALL_END` events have `input` typed as the union of tool input types. +- `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** over tool names. +- Checking `toolName === 'x'` narrows `input` to that specific tool's input type. +- `TOOL_CALL_END` events have `input` typed per-tool via Zod schema inference. When tools are untyped or absent, `TypedStreamChunk` degrades to the same type as `StreamChunk`. diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index a9d99db92..2649cde38 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -149,16 +149,18 @@ async function typedStreamShowcase() { break case 'TOOL_CALL_END': - // ✅ chunk.toolName — same typed literal union as above - // ✅ chunk.input — union of all tool input types, inferred from Zod schemas: - // | {} - // | { id: string | number } - // | { guitarId: string; quantity: number } - // | { guitarId: string } - // | { guitarIds: number[] } - // | { guitarId: number; months: number } - // | { query: string } - console.log(`Tool call ended: ${chunk.toolName}`, chunk.input) + // ✅ Discriminated union — checking toolName narrows input to that tool's type + if (chunk.toolName === 'searchGuitars') { + // ✅ chunk.input is { query: string } (not the full union) + console.log(`Searching for: ${chunk.input?.query}`) + } else if (chunk.toolName === 'calculateFinancing') { + // ✅ chunk.input is { guitarId: number; months: number } + console.log( + `Financing guitar ${chunk.input?.guitarId} for ${chunk.input?.months} months`, + ) + } else { + console.log(`Tool call ended: ${chunk.toolName}`, chunk.input) + } break case 'TEXT_MESSAGE_CONTENT': diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 3a784372b..51508a740 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -994,54 +994,72 @@ export type StreamChunk = AGUIEvent // ============================================================================ /** - * Extract tool name literals from a tools array type. - * When tools have specific name literals (e.g. `'get_weather'`), returns - * their union. When tools are untyped (generic `string`) or empty, returns `string`. + * Detect the `any` type. Returns `true` for `any`, `false` for everything else. + * @internal + */ +type IsAny = 0 extends 1 & T ? true : false + +/** + * Check whether the tools array carries typed tool definitions. + * Returns `false` for empty arrays or arrays with generic `string` names. * @internal */ -type ToolNamesOf>> = [ +type HasTypedTools>> = [ TTools[number], ] extends [never] - ? string + ? false : string extends TTools[number]['name'] - ? string - : TTools[number]['name'] + ? false + : true /** - * Detect the `any` type. Returns `true` for `any`, `false` for everything else. + * Safely infer input type for a single tool, guarding against `any` leaks. + * Returns `unknown` when the tool has no inputSchema or when InferSchemaType + * produces `any` (e.g. for plain JSON Schema tools). * @internal */ -type IsAny = 0 extends 1 & T ? true : false +type SafeToolInput> = T extends { + inputSchema?: infer TInput +} + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown /** - * Infer the union of tool input types from a tools array type. - * When tools have specific name literals (indicating typed tool definitions), - * returns the union of their inferred input types via `InferSchemaType`. - * When tool names are generic `string` or the tools array is empty, returns `unknown`. - * - * Guards against `any` leaking through `InferSchemaType` when `inputSchema` - * defaults to the broad `SchemaInput` union (which includes `StandardJSONSchemaV1`). + * Distribute over each tool to create a per-tool `ToolCallStartEvent`. + * This produces a discriminated union — one variant per tool name literal. * @internal */ -type ToolInputsOf>> = [ - TTools[number], -] extends [never] - ? unknown - : string extends TTools[number]['name'] - ? unknown - : TTools[number] extends { inputSchema?: infer TInput } - ? IsAny>> extends true - ? unknown - : InferSchemaType> - : unknown +type DistributedToolCallStart< + TTools extends ReadonlyArray>, +> = TTools[number] extends infer T + ? T extends Tool + ? ToolCallStartEvent + : never + : never + +/** + * Distribute over each tool to create a per-tool `ToolCallEndEvent`. + * Each variant pairs the tool's name literal with its specific input type, + * enabling discriminated narrowing: checking `toolName === 'x'` narrows `input`. + * @internal + */ +type DistributedToolCallEnd< + TTools extends ReadonlyArray>, +> = TTools[number] extends infer T + ? T extends Tool + ? ToolCallEndEvent>> + : never + : never /** * Stream chunk type parameterized by the tools array for type-safe tool call events. * * When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): - * - `TOOL_CALL_START` and `TOOL_CALL_END` events have `toolName` narrowed to - * the union of known tool name literals. - * - `TOOL_CALL_END` events have `input` typed as the union of tool input types. + * - `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** + * over tool names — checking `toolName === 'x'` narrows `input` to that tool's type. + * - `TOOL_CALL_END` events have `input` typed per-tool via Zod schema inference. * * When tools are untyped or absent, degrades to the same type as `StreamChunk`. */ @@ -1049,13 +1067,15 @@ export type TypedStreamChunk< TTools extends ReadonlyArray> = ReadonlyArray< Tool >, -> = - | Exclude< - StreamChunk, - { type: 'TOOL_CALL_START' } | { type: 'TOOL_CALL_END' } - > - | ToolCallStartEvent> - | ToolCallEndEvent, ToolInputsOf> +> = HasTypedTools extends true + ? + | Exclude< + StreamChunk, + { type: 'TOOL_CALL_START' } | { type: 'TOOL_CALL_END' } + > + | DistributedToolCallStart + | DistributedToolCallEnd + : StreamChunk // Simple streaming format for basic text completions // Converted to StreamChunk format by convertTextCompletionStream() diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index a3c411dce..465cca54c 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -227,6 +227,94 @@ describe('TypedStreamChunk tool call type safety', () => { }) }) + describe('discriminated union narrowing', () => { + it('should narrow input to specific tool type when checking toolName', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]> + type End = Extract + + // Narrowing by toolName should give the specific tool's input type + type WeatherEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + + type SearchEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ query: string }>() + }) + + it('should narrow START events by toolName', () => { + type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]> + type Start = Extract + + type WeatherStart = Extract + expectTypeOf().toEqualTypeOf<'get_weather'>() + + type SearchStart = Extract + expectTypeOf().toEqualTypeOf<'search'>() + }) + + it('should narrow input with three or more tools', () => { + type Chunk = TypedStreamChunk< + [typeof weatherTool, typeof searchTool, typeof noInputTool] + > + type End = Extract + + type WeatherEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + + type SearchEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ query: string }>() + + type TimeEnd = Extract + expectTypeOf>().toBeUnknown() + }) + + it('should narrow input through chat() return type', () => { + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherTool, searchTool], + }) + type Chunk = ChunkOf + type End = Extract + + type WeatherEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + + type SearchEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ query: string }>() + }) + + it('should narrow input with server tool variants', () => { + type Chunk = TypedStreamChunk< + [typeof weatherServerTool, typeof searchClientTool] + > + type End = Extract + + type WeatherEnd = Extract + expectTypeOf< + Exclude + >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + + type SearchEnd = Extract + // searchClientTool doesn't have a Zod inputSchema on the client variant, + // so its input should be narrowed per-tool (query: string from the base def) + expectTypeOf< + Exclude + >().toEqualTypeOf<{ query: string }>() + }) + }) + describe('server and client tool variants', () => { it('should type ServerTool name and input from .server()', () => { type E = ToolEventsOf<[typeof weatherServerTool]> From 5e3ebd7d56362b9a2bf44c0c917bf515604197f4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:59:06 +0000 Subject: [PATCH 4/7] ci: apply automated fixes --- packages/typescript/ai/src/types.ts | 30 +++++------ .../typescript/ai/tests/type-check.test.ts | 52 ++++++++++--------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 51508a740..4e8a0a174 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1045,13 +1045,12 @@ type DistributedToolCallStart< * enabling discriminated narrowing: checking `toolName === 'x'` narrows `input`. * @internal */ -type DistributedToolCallEnd< - TTools extends ReadonlyArray>, -> = TTools[number] extends infer T - ? T extends Tool - ? ToolCallEndEvent>> +type DistributedToolCallEnd>> = + TTools[number] extends infer T + ? T extends Tool + ? ToolCallEndEvent>> + : never : never - : never /** * Stream chunk type parameterized by the tools array for type-safe tool call events. @@ -1067,15 +1066,16 @@ export type TypedStreamChunk< TTools extends ReadonlyArray> = ReadonlyArray< Tool >, -> = HasTypedTools extends true - ? - | Exclude< - StreamChunk, - { type: 'TOOL_CALL_START' } | { type: 'TOOL_CALL_END' } - > - | DistributedToolCallStart - | DistributedToolCallEnd - : StreamChunk +> = + HasTypedTools extends true + ? + | Exclude< + StreamChunk, + { type: 'TOOL_CALL_START' } | { type: 'TOOL_CALL_END' } + > + | DistributedToolCallStart + | DistributedToolCallEnd + : StreamChunk // Simple streaming format for basic text completions // Converted to StreamChunk format by convertTextCompletionStream() diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 465cca54c..35587fef8 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -234,14 +234,15 @@ describe('TypedStreamChunk tool call type safety', () => { // Narrowing by toolName should give the specific tool's input type type WeatherEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() type SearchEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ query: string }>() + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() }) it('should narrow START events by toolName', () => { @@ -262,14 +263,15 @@ describe('TypedStreamChunk tool call type safety', () => { type End = Extract type WeatherEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() type SearchEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ query: string }>() + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() type TimeEnd = Extract expectTypeOf>().toBeUnknown() @@ -285,14 +287,15 @@ describe('TypedStreamChunk tool call type safety', () => { type End = Extract type WeatherEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() type SearchEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ query: string }>() + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() }) it('should narrow input with server tool variants', () => { @@ -302,16 +305,17 @@ describe('TypedStreamChunk tool call type safety', () => { type End = Extract type WeatherEnd = Extract - expectTypeOf< - Exclude - >().toEqualTypeOf<{ location: string; unit?: 'celsius' | 'fahrenheit' }>() + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() type SearchEnd = Extract // searchClientTool doesn't have a Zod inputSchema on the client variant, // so its input should be narrowed per-tool (query: string from the base def) - expectTypeOf< - Exclude - >().toEqualTypeOf<{ query: string }>() + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() }) }) From 9a1df7ef5efdca5adce3bf80617666f4d6ff83d6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 16 Apr 2026 13:45:03 +0200 Subject: [PATCH 5/7] docs: improve type-safe tool call event documentation - Show practical property access after discriminated narrowing - Add description field to searchTool example for consistency - Add cross-link from server-tools to streaming type safety - Fix stale line number references in StreamChunk.md --- docs/chat/streaming.md | 7 +++++-- docs/reference/type-aliases/StreamChunk.md | 4 ++-- docs/tools/server-tools.md | 2 ++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 0039b2a3c..ea8720393 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -117,6 +117,7 @@ When multiple tools are provided, tool call events form a **discriminated union* ```typescript const searchTool = toolDefinition({ name: "search", + description: "Search the web", inputSchema: z.object({ query: z.string() }), }); @@ -129,10 +130,12 @@ const stream = chat({ for await (const chunk of stream) { if (chunk.type === "TOOL_CALL_END") { if (chunk.toolName === "get_weather") { - chunk.input; // ✅ { location: string; unit?: "celsius" | "fahrenheit" } + // ✅ input is narrowed to { location: string; unit?: "celsius" | "fahrenheit" } + console.log(`Weather in ${chunk.input?.location}`); } if (chunk.toolName === "search") { - chunk.input; // ✅ { query: string } + // ✅ input is narrowed to { query: string } + console.log(`Searched for: ${chunk.input?.query}`); } } } diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index fd6f79e6d..09e18f3b8 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -9,7 +9,7 @@ title: StreamChunk type StreamChunk = AGUIEvent; ``` -Defined in: [types.ts:989](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L989) +Defined in: [types.ts:990](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L990) Chunk returned by the SDK during streaming chat completions. Uses the AG-UI protocol event format. @@ -20,7 +20,7 @@ Uses the AG-UI protocol event format. type TypedStreamChunk> = ReadonlyArray>> ``` -Defined in: [types.ts:1033](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1033) +Defined in: [types.ts:1066](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1066) A variant of `StreamChunk` parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): diff --git a/docs/tools/server-tools.md b/docs/tools/server-tools.md index bcae69ecf..0731247c1 100644 --- a/docs/tools/server-tools.md +++ b/docs/tools/server-tools.md @@ -299,6 +299,8 @@ const getUserData = getUserDataDef.server(async (args) => { > **Note:** JSON Schema tools skip runtime validation. Zod schemas are recommended for full type safety and validation. +> **Tip:** When you pass server tools to `chat()`, the returned stream is fully typed — `toolName` narrows to your tool name literals and `input` narrows per-tool when you check the name. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). + ## Best Practices 1. **Keep tools focused** - Each tool should do one thing well From d46cd6db97eb3f77dd4cf9f38581e2376ffe56f9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 16 Apr 2026 13:57:06 +0200 Subject: [PATCH 6/7] fix(ai): resolve tsc errors in discriminated union types and fix docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove T & Tool intersection in DistributedToolCallEnd that caused tsc to resolve input as unknown for all tools - Relax SafeToolInput constraint to structural match (no generic bound) - Fix "Zod schema inference" → "Standard Schema inference" in JSDoc/docs - Fix off-by-one line number in StreamChunk.md reference - Fix misleading test comment about searchClientTool - Add | undefined to input type annotation in streaming docs - Broaden server-tools.md tip to cover all typed tool variants - Add tests for mixed Zod+JSON Schema and chat() with server/client tools --- docs/chat/streaming.md | 2 +- docs/reference/type-aliases/StreamChunk.md | 4 +- docs/tools/server-tools.md | 2 +- packages/typescript/ai/src/types.ts | 6 +-- .../typescript/ai/tests/type-check.test.ts | 39 ++++++++++++++++++- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index ea8720393..0e1401d9e 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -105,7 +105,7 @@ const stream = chat({ for await (const chunk of stream) { if (chunk.type === "TOOL_CALL_END") { chunk.toolName; // ✅ typed as "get_weather" (not string) - chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } + chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } | undefined } } ``` diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index 09e18f3b8..f3de1b3c2 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -20,13 +20,13 @@ Uses the AG-UI protocol event format. type TypedStreamChunk> = ReadonlyArray>> ``` -Defined in: [types.ts:1066](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1066) +Defined in: [types.ts:1065](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1065) A variant of `StreamChunk` parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): - `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** over tool names. - Checking `toolName === 'x'` narrows `input` to that specific tool's input type. -- `TOOL_CALL_END` events have `input` typed per-tool via Zod schema inference. +- `TOOL_CALL_END` events have `input` typed per-tool via Standard Schema inference. When tools are untyped or absent, `TypedStreamChunk` degrades to the same type as `StreamChunk`. diff --git a/docs/tools/server-tools.md b/docs/tools/server-tools.md index 0731247c1..5f07ac2b0 100644 --- a/docs/tools/server-tools.md +++ b/docs/tools/server-tools.md @@ -299,7 +299,7 @@ const getUserData = getUserDataDef.server(async (args) => { > **Note:** JSON Schema tools skip runtime validation. Zod schemas are recommended for full type safety and validation. -> **Tip:** When you pass server tools to `chat()`, the returned stream is fully typed — `toolName` narrows to your tool name literals and `input` narrows per-tool when you check the name. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). +> **Tip:** When you pass typed tools (server, client, or definition) to `chat()`, the returned stream is fully typed — `toolName` narrows to your tool name literals and `input` narrows per-tool when you check the name. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events). ## Best Practices diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 4e8a0a174..ed69050fb 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1018,7 +1018,7 @@ type HasTypedTools>> = [ * produces `any` (e.g. for plain JSON Schema tools). * @internal */ -type SafeToolInput> = T extends { +type SafeToolInput = T extends { inputSchema?: infer TInput } ? IsAny>> extends true @@ -1048,7 +1048,7 @@ type DistributedToolCallStart< type DistributedToolCallEnd>> = TTools[number] extends infer T ? T extends Tool - ? ToolCallEndEvent>> + ? ToolCallEndEvent> : never : never @@ -1058,7 +1058,7 @@ type DistributedToolCallEnd>> = * When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`): * - `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** * over tool names — checking `toolName === 'x'` narrows `input` to that tool's type. - * - `TOOL_CALL_END` events have `input` typed per-tool via Zod schema inference. + * - `TOOL_CALL_END` events have `input` typed per-tool via Standard Schema inference. * * When tools are untyped or absent, degrades to the same type as `StreamChunk`. */ diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 35587fef8..ed6cee51d 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -311,8 +311,7 @@ describe('TypedStreamChunk tool call type safety', () => { }>() type SearchEnd = Extract - // searchClientTool doesn't have a Zod inputSchema on the client variant, - // so its input should be narrowed per-tool (query: string from the base def) + // .client() preserves the original inputSchema type from the base definition expectTypeOf>().toEqualTypeOf<{ query: string }>() @@ -345,6 +344,42 @@ describe('TypedStreamChunk tool call type safety', () => { 'get_weather' | 'search' >() }) + + it('should narrow input through chat() with server/client tools', () => { + const stream = chat({ + adapter: mockAdapter, + messages: [], + tools: [weatherServerTool, searchClientTool], + }) + type Chunk = ChunkOf + type End = Extract + + type WeatherEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + location: string + unit?: 'celsius' | 'fahrenheit' + }>() + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + }) + }) + + describe('mixed schema types', () => { + it('should narrow per-tool when mixing Zod and JSON Schema tools', () => { + type Chunk = TypedStreamChunk<[typeof searchTool, typeof jsonSchemaTool]> + type End = Extract + + type SearchEnd = Extract + expectTypeOf>().toEqualTypeOf<{ + query: string + }>() + + type JsonEnd = Extract + expectTypeOf>().toBeUnknown() + }) }) describe('non-tool events are preserved', () => { From f2aaac0e21989d8436ee517f7f1290b0fb6b2e7c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 12:19:47 +0200 Subject: [PATCH 7/7] fix(ai): restore discriminated union narrowing on typed tool-call events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's upstream bump to `@ag-ui/core` switched the AG-UI event interfaces to `z.infer<...>` of a `"passthrough"` `z.ZodObject`, which introduces an `[k: string]: unknown` index signature. `ToolCallStartEvent` was declared as `Omit & { toolCallName: TToolName; ... }` — and `Omit` on an index-signature'd type collapses the re-declared field in ways that destroy discriminated-union narrowing on `StreamChunk`. Consequences before this change: - `Extract['toolName']` resolved to `never`, so every type-check test around `TypedStreamChunk` failed. - `switch (chunk.type) { case 'TOOL_CALL_START': ... }` in `chat/index.ts` and `handleToolCallStartEvent`'s `Extract<..., {type:'TOOL_CALL_START'}>` parameter couldn't narrow, cascading 60 tsc errors through `activities/chat/**`, `middlewares/content-guard.ts`, and tests. Redesign (types.ts): - `ToolCallStartEvent` now `extends AGUIToolCallStartEvent` with **no `Omit`**. The AG-UI `toolCallName: string` is inherited verbatim on the base interface; narrowing to a literal happens purely through intersection in the per-tool variants. - `ToolCallEndEvent` extends cleanly the same way. `toolName` is kept required (matches pre-merge TanStack surface and every adapter emits it). - `HasTypedTools` now partitions out `ProviderTool` before checking `string extends ... ['name']`. Provider tools carry opaque provider metadata with a generic `string` name — without filtering, a user passing `[webSearchTool, myTypedTool]` would silently fall through to the untyped branch. - `DistributedToolCallStart` / `DistributedToolCallEnd` now distribute over `NonProviderTools` and match any tool-like shape via `T extends { name: infer TName extends string }` — picking up `Tool`, `ServerTool`, and `ClientTool` uniformly. - The `ProviderTool` partition uses a structural brand match (`{ readonly '~provider': string; readonly '~toolKind': string }`) to avoid a circular import between `types.ts` and `./tools/provider-tool.ts`. Test fix (tests/tool-calls-null-input.test.ts): - Two fixture calls to `manager.completeToolCall(...)` now include `toolCallName` / `toolName`. The runtime type requires them; the pre-merge test relied on the optional-ness accidentally introduced by the Omit-based surface. Minor bucket from the review: - docs/chat/streaming.md: replace the hallucinated `gpt-5.2` model id with `gpt-4o` in all four occurrences (lines 27, 49, 109, 134). - examples/ts-react-chat/src/routes/api.tanchat.ts: remove the dead `typedStreamShowcase` function (`void typedStreamShowcase`). - packages/typescript/ai-svelte/src/create-chat.svelte.ts: wrap `onResponse`, `onChunk`, and `onCustomEvent` the same way `onFinish`/`onError` already were, so callers can mutate the `options` object and propagate new callbacks (matches the React/Preact/Vue/Solid sibling wrappers). Comment explains why. - Add `.changeset/svelte-callback-propagation.md` (patch bump). Verification: - `pnpm --filter @tanstack/ai test:types` emits zero errors. - `pnpm --filter @tanstack/ai test:lib` 735/735 passing. - `pnpm --filter @tanstack/ai-openai test:lib` 131/131 passing. - `pnpm --filter @tanstack/ai-anthropic test:lib` 62/62 passing. - `pnpm --filter @tanstack/ai-svelte test:lib` 53/53 passing. - `pnpm --filter ... test:types` green across the same four packages. --- .changeset/svelte-callback-propagation.md | 7 + docs/chat/streaming.md | 8 +- .../ts-react-chat/src/routes/api.tanchat.ts | 66 -------- .../ai-svelte/src/create-chat.svelte.ts | 24 ++- .../ai/src/activities/chat/index.ts | 2 +- packages/typescript/ai/src/index.ts | 2 +- .../typescript/ai/src/tools/provider-tool.ts | 25 ---- packages/typescript/ai/src/types.ts | 141 ++++++++++++++---- .../ai/tests/tool-calls-null-input.test.ts | 4 + 9 files changed, 153 insertions(+), 126 deletions(-) create mode 100644 .changeset/svelte-callback-propagation.md delete mode 100644 packages/typescript/ai/src/tools/provider-tool.ts diff --git a/.changeset/svelte-callback-propagation.md b/.changeset/svelte-callback-propagation.md new file mode 100644 index 000000000..7174c1aed --- /dev/null +++ b/.changeset/svelte-callback-propagation.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-svelte': patch +--- + +fix(ai-svelte): propagate `createChat` callback changes uniformly + +`onResponse`, `onChunk`, and `onCustomEvent` were passed as direct references to the underlying `ChatClient`, while `onFinish` and `onError` were wrapped to read from `options.onX?.(...)` at call time. This meant callers who mutated the options object in-place (or invoked `client.updateOptions(...)`) would see their replacement propagate for the latter two but silently miss for the former three. All five user-supplied callbacks now go through the same indirection, matching the React / Preact / Vue / Solid sibling wrappers. diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 018310391..d1233e5aa 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -24,7 +24,7 @@ import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-5.2"), + adapter: openaiText("gpt-4o"), messages, }); @@ -46,7 +46,7 @@ export async function POST(request: Request) { const { messages } = await request.json(); const stream = chat({ - adapter: openaiText("gpt-5.2"), + adapter: openaiText("gpt-4o"), messages, }); @@ -106,7 +106,7 @@ const weatherTool = toolDefinition({ }); const stream = chat({ - adapter: openaiText("gpt-5.2"), + adapter: openaiText("gpt-4o"), messages, tools: [weatherTool], }); @@ -131,7 +131,7 @@ const searchTool = toolDefinition({ }); const stream = chat({ - adapter: openaiText("gpt-5.2"), + adapter: openaiText("gpt-4o"), messages, tools: [weatherTool, searchTool], }); diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 7fbe9b27f..f571fd9c7 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -108,72 +108,6 @@ const loggingMiddleware: ChatMiddleware = { }, } -// =========================== -// TypedStreamChunk showcase — type-safe tool call events -// =========================== -// -// When `chat()` receives tools with typed schemas, the returned stream -// carries type information on TOOL_CALL_START and TOOL_CALL_END events. -// No casts, no `as any` — just narrow by `chunk.type` and everything is typed. - -const tools = [ - getGuitars, - recommendGuitarToolDef, - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - compareGuitars, - calculateFinancing, - searchGuitars, -] as const - -async function typedStreamShowcase() { - const stream = chat({ - adapter: openaiText('gpt-4o'), - messages: [ - { role: 'user' as const, content: 'Recommend an acoustic guitar' }, - ], - tools, - }) - - for await (const chunk of stream) { - switch (chunk.type) { - case 'TOOL_CALL_START': - // ✅ chunk.toolName is typed as the union of all tool name literals: - // 'getGuitars' | 'recommendGuitar' | 'addToCart' | 'addToWishList' - // | 'getPersonalGuitarPreference' | 'compareGuitars' - // | 'calculateFinancing' | 'searchGuitars' - // - // ❌ Without TypedStreamChunk, this would just be `string` - console.log(`Tool call started: ${chunk.toolName}`) - break - - case 'TOOL_CALL_END': - // ✅ Discriminated union — checking toolName narrows input to that tool's type - if (chunk.toolName === 'searchGuitars') { - // ✅ chunk.input is { query: string } (not the full union) - console.log(`Searching for: ${chunk.input?.query}`) - } else if (chunk.toolName === 'calculateFinancing') { - // ✅ chunk.input is { guitarId: number; months: number } - console.log( - `Financing guitar ${chunk.input?.guitarId} for ${chunk.input?.months} months`, - ) - } else { - console.log(`Tool call ended: ${chunk.toolName}`, chunk.input) - } - break - - case 'TEXT_MESSAGE_CONTENT': - // Non-tool events are unaffected — still fully typed - console.log(chunk.delta) - break - } - } -} - -// Suppress unused warning — this is a showcase, not called at runtime -void typedStreamShowcase - export const Route = createFileRoute('/api/tanchat')({ server: { handlers: { diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts index 6d5115fbc..82f5a88fb 100644 --- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts +++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts @@ -55,14 +55,28 @@ export function createChat = any>( let connectionStatus = $state('disconnected') let sessionGenerating = $state(false) - // Create ChatClient instance + // Create ChatClient instance. + // + // Svelte's `createChat` runs once per instance, so `options` is captured by + // reference at creation time. Wrapping each user-supplied callback through + // `options.onX?.(...)` lets callers mutate the options object in place (or + // call `client.updateOptions(...)` imperatively) and have the next invocation + // pick up the new function — without this indirection, those five callbacks + // would be frozen to whatever was passed at `createChat(...)` time, which + // diverges from the React/Preact/Vue/Solid sibling wrappers. This is the + // same uniform treatment applied to `onFinish`/`onError`; the other three + // (`onResponse`, `onChunk`, `onCustomEvent`) used to be direct references. const client = new ChatClient({ connection: options.connection, id: clientId, initialMessages: options.initialMessages, body: options.body, - onResponse: options.onResponse, - onChunk: options.onChunk, + onResponse: (response) => { + options.onResponse?.(response) + }, + onChunk: (chunk) => { + options.onChunk?.(chunk) + }, onFinish: (message) => { options.onFinish?.(message) }, @@ -70,7 +84,9 @@ export function createChat = any>( options.onError?.(err) }, tools: options.tools, - onCustomEvent: options.onCustomEvent, + onCustomEvent: (eventType, data, context) => { + options.onCustomEvent?.(eventType, data, context) + }, streamProcessor: options.streamProcessor, onMessagesChange: (newMessages: Array>) => { messages = newMessages diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 1960db1b9..6b6e0307b 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -35,6 +35,7 @@ import type { CustomEvent, InferSchemaType, ModelMessage, + ProviderTool, RunFinishedEvent, SchemaInput, StreamChunk, @@ -55,7 +56,6 @@ import type { } from './middleware/types' import type { InternalLogger } from '../../logger/internal-logger' import type { DebugOption } from '../../logger/types' -import type { ProviderTool } from '../../tools/provider-tool' // =========================== // Activity Kind diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index ef45543be..f86241ecb 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -68,7 +68,7 @@ export { export { ToolCallManager } from './activities/chat/tools/tool-calls' // Provider tool type -export type { ProviderTool } from './tools/provider-tool' +export type { ProviderTool } from './types' // Agent loop strategies export { diff --git a/packages/typescript/ai/src/tools/provider-tool.ts b/packages/typescript/ai/src/tools/provider-tool.ts deleted file mode 100644 index 780ee106c..000000000 --- a/packages/typescript/ai/src/tools/provider-tool.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Tool } from '../types' - -/** - * A provider-specific tool produced by an adapter-package factory - * (e.g. `webSearchTool` from `@tanstack/ai-anthropic/tools`). - * - * The two `~`-prefixed fields are type-only phantom brands — they are never - * assigned at runtime. They allow the core type system to match a factory's - * output against the selected model's `supports.tools` list and surface a - * compile-time error when the combination is unsupported. - * - * User-defined tools (via `toolDefinition()`) remain plain `Tool` and stay - * assignable to any model. - * - * @template TProvider - Provider identifier (e.g. `'anthropic'`, `'openai'`). - * @template TKind - Canonical tool-kind string matching the provider's - * `supports.tools` entries (e.g. `'web_search'`, `'code_execution'`). - */ -export interface ProviderTool< - TProvider extends string, - TKind extends string, -> extends Tool { - readonly '~provider': TProvider - readonly '~toolKind': TKind -} diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 5c0e9c685..ba11ca714 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -894,17 +894,31 @@ export interface TextMessageEndEvent extends AGUITextMessageEndEvent { * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). * When the stream is returned from `chat()` with typed tools, this narrows to * the union of tool name literals on both `toolCallName` and the deprecated - * `toolName` alias. - */ -export interface ToolCallStartEvent - extends Omit { - /** Name of the tool being called (from @ag-ui/core spec) */ - toolCallName: TToolName + * `toolName` alias via the `DistributedToolCallStart` intersection — the base + * interface intentionally keeps `toolCallName` at `string` so the + * `AGUIToolCallStartEvent` parent (which uses a passthrough index signature) + * remains a compatible supertype without triggering `Omit`-induced + * discriminant collapse. + * + * Note: `TToolName` is preserved on the `toolName` (TanStack-only) field and + * appears as a literal in the discriminated `DistributedToolCallStart` + * variants of `TypedStreamChunk`. Consumers narrowing through + * `TypedStreamChunk` get the literal. Consumers reading a bare + * `ToolCallStartEvent<'x'>['toolCallName']` get `string` — use the + * distributed variant (via `TypedStreamChunk`) for discriminated narrowing. + */ +export interface ToolCallStartEvent< + TToolName extends string = string, +> extends AGUIToolCallStartEvent { /** Model identifier for multi-model support */ model?: string /** * @deprecated Use `toolCallName` instead (from @ag-ui/core spec). * Kept for backward compatibility. + * + * This field carries the `TToolName` literal in typed streams. For + * `toolCallName` narrowing, use `TypedStreamChunk` — its + * `DistributedToolCallStart` variants intersect an override in. */ toolName: TToolName /** Index for parallel tool calls */ @@ -943,13 +957,23 @@ export interface ToolCallEndEvent< > extends AGUIToolCallEndEvent { /** Model identifier for multi-model support */ model?: string - /** Name of the tool that completed */ + /** + * Name of the tool that completed (from @ag-ui/core spec). + * + * `AGUIToolCallEndEvent` does not declare `toolCallName`, so re-declaring + * it here as optional is safe — it extends the base shape rather than + * narrowing an existing field. `DistributedToolCallEnd` intersects an + * override to make it required and narrowed to the tool's literal name + * in `TypedStreamChunk`. + */ toolCallName?: TToolName /** * @deprecated Use `toolCallName` instead. - * Kept for backward compatibility. + * Kept for backward compatibility. Required so that consumers who rely on + * the TanStack surface (pre-ag-ui-merge) can continue to read `toolName` + * without an `undefined` check — every adapter populates this field. */ - toolName?: TToolName + toolName: TToolName /** Final parsed input arguments (TanStack AI internal) */ input?: TInput /** Tool execution result (TanStack AI internal) */ @@ -1171,22 +1195,67 @@ export type StreamChunk = AGUIEvent // Typed Stream Chunks (tool-aware) // ============================================================================ +/** + * A provider-specific tool produced by an adapter-package factory + * (e.g. `webSearchTool` from `@tanstack/ai-anthropic/tools`). + * + * The two `~`-prefixed fields are type-only phantom brands — they are never + * assigned at runtime. They allow the core type system to match a factory's + * output against the selected model's `supports.tools` list and surface a + * compile-time error when the combination is unsupported. + * + * User-defined tools (via `toolDefinition()`) remain plain `Tool` and stay + * assignable to any model. + * + * @template TProvider - Provider identifier (e.g. `'anthropic'`, `'openai'`). + * @template TKind - Canonical tool-kind string matching the provider's + * `supports.tools` entries (e.g. `'web_search'`, `'code_execution'`). + */ +export interface ProviderTool< + TProvider extends string, + TKind extends string, +> extends Tool { + readonly '~provider': TProvider + readonly '~toolKind': TKind +} + /** * Detect the `any` type. Returns `true` for `any`, `false` for everything else. * @internal */ type IsAny = 0 extends 1 & T ? true : false +/** + * Partition out provider-specific tools from a tools array. `ProviderTool` + * carries opaque provider metadata (e.g. `webSearchTool` from + * `@tanstack/ai-anthropic`) and intentionally has a generic `string` name — + * if we included it in the discriminated union, it would widen `toolName` + * back to `string` and defeat the entire typing exercise. + * + * @internal + */ +type NonProviderTools>> = + Exclude> + /** * Check whether the tools array carries typed tool definitions. - * Returns `false` for empty arrays or arrays with generic `string` names. + * Returns `false` for empty arrays or arrays whose only entries are + * `ProviderTool`s (which have generic `string` names). + * + * The partitioning step matters: a user who passes + * `[webSearchTool, myTypedTool]` should still get typed narrowing for + * `myTypedTool`. Evaluating `string extends TTools[number]['name']` without + * filtering provider tools first would always return `false` (because + * `ProviderTool`'s `name` is `string`) and silently fall through to the + * untyped branch. + * * @internal */ type HasTypedTools>> = [ - TTools[number], + NonProviderTools, ] extends [never] ? false - : string extends TTools[number]['name'] + : string extends NonProviderTools['name'] ? false : true @@ -1205,32 +1274,54 @@ type SafeToolInput = T extends { : unknown /** - * Distribute over each tool to create a per-tool `ToolCallStartEvent`. + * Distribute over each non-provider tool to create a per-tool + * `ToolCallStartEvent`. + * * This produces a discriminated union — one variant per tool name literal. + * We distribute over `NonProviderTools` (not `TTools[number]`) so + * that provider tools with generic `string` names do not leak into the + * union and widen `toolCallName` / `toolName` back to `string`. + * + * The trailing `& { toolCallName: TName; toolName: TName }` intersection + * narrows the base `AGUIToolCallStartEvent['toolCallName']` (declared as + * `string`) to the literal name — TypeScript intersects `string & TName` + * down to `TName` for literal `TName`. + * + * The `name` parameter constraint on the inner `extends` picks up any + * tool-like shape — including `ServerTool`, `ClientTool`, and the bare + * `Tool` definition — because all three expose `name: TName`. * @internal */ type DistributedToolCallStart< TTools extends ReadonlyArray>, -> = TTools[number] extends infer T - ? T extends Tool - ? ToolCallStartEvent & { toolCallName: TName; toolName: TName } +> = + NonProviderTools extends infer T + ? T extends { name: infer TName extends string } + ? ToolCallStartEvent & { toolCallName: TName; toolName: TName } + : never : never - : never /** - * Distribute over each tool to create a per-tool `ToolCallEndEvent`. + * Distribute over each non-provider tool to create a per-tool + * `ToolCallEndEvent`. + * * Each variant pairs the tool's name literal with its specific input type, - * enabling discriminated narrowing: checking `toolName === 'x'` narrows `input`. + * enabling discriminated narrowing: checking `toolName === 'x'` narrows + * `input`. + * + * `toolName`/`toolCallName` are intersected as required in the distributed + * variants so that `Extract<..., { toolName: 'x' }>` works for consumers + * relying on the discriminated-union pattern, even though the base + * interface keeps them optional for compatibility with the broader AG-UI + * surface. * - * `toolName`/`toolCallName` are marked required in the distributed variants so - * that `Extract<..., { toolName: 'x' }>` works for consumers relying on the - * discriminated-union pattern, even though the base interface keeps them - * optional for compatibility with the broader AG-UI surface. + * Distribution happens over `NonProviderTools` for the same + * reason as in `DistributedToolCallStart`. * @internal */ type DistributedToolCallEnd>> = - TTools[number] extends infer T - ? T extends Tool + NonProviderTools extends infer T + ? T extends { name: infer TName extends string } ? ToolCallEndEvent> & { toolCallName: TName toolName: TName diff --git a/packages/typescript/ai/tests/tool-calls-null-input.test.ts b/packages/typescript/ai/tests/tool-calls-null-input.test.ts index 10f90dada..8a7640945 100644 --- a/packages/typescript/ai/tests/tool-calls-null-input.test.ts +++ b/packages/typescript/ai/tests/tool-calls-null-input.test.ts @@ -115,6 +115,8 @@ describe('null tool input normalization', () => { manager.completeToolCall({ type: EventType.TOOL_CALL_END, toolCallId: 'tc-1', + toolCallName: 'test_tool', + toolName: 'test_tool', timestamp: Date.now(), input: null as unknown, }) @@ -139,6 +141,8 @@ describe('null tool input normalization', () => { manager.completeToolCall({ type: EventType.TOOL_CALL_END, toolCallId: 'tc-1', + toolCallName: 'test_tool', + toolName: 'test_tool', timestamp: Date.now(), input: { location: 'NYC' }, })