From b8c1615c4ed5345f6fef4f889566391129187925 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 15 Apr 2026 11:07:08 +0200 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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' }, }) From 2b0bd96e5ecc1fb7e39ea8c9b04335ffa6057c15 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 25 May 2026 15:16:11 +0200 Subject: [PATCH 08/20] fix(ai): address Round 1 CR findings on type-safe stream chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bucket (a) fixes from a 7-agent unbiased review: 1. ai-svelte createChat: restore awaitable onResponse contract - Was: `void options.onResponse?.(response)` discarded the Promise - Now: `(response) => options.onResponse?.(response)` returns it - ChatClient awaits onResponse() (chat-client.ts:611) — voiding broke the await and let async user work race with stream cleanup. Matches Vue/Solid/Preact pattern; the void was a lint-silencer mistake introduced during the main-merge resolution. - Call sites: 1 (the wrapper itself, rewritten). The wrapper's callee contract is `(response?: Response) => void | Promise` — returning the Promise still satisfies it. 2. docs: split TypedStreamChunk into its own reference page - StreamChunk.md previously held two `# Type Alias:` H1s under a single id/title, breaking the per-alias-file convention and leaving the TypedStreamChunk type signature truncated (no `= ...` body). - New TypedStreamChunk.md includes the full conditional-type body and replaces the invalid `inputSchema: /* Zod schema */` placeholder with real Zod expressions. - StreamChunk.md trimmed back to one alias plus a cross-link. 3. type-check tests: guard NonProviderTools partitioning - Added a `fakeProviderTool` fixture and two regression tests: - ProviderTool-only arrays fall back to untyped events - Mixed [ProviderTool, typed tool] arrays still narrow the typed tool - Without these, a regression in HasTypedTools that fails to strip ProviderTool would silently widen toolName back to string. Verification: - pnpm --filter @tanstack/ai test:types: green - pnpm --filter @tanstack/ai-svelte test:types: green - pnpm --filter @tanstack/ai test:lib: 940 passed (+2 new) - pnpm --filter @tanstack/ai-svelte test:eslint: 0 errors --- docs/reference/type-aliases/StreamChunk.md | 35 +--------- .../type-aliases/TypedStreamChunk.md | 67 +++++++++++++++++++ .../ai-svelte/src/create-chat.svelte.ts | 6 +- packages/typescript/ai/src/types.ts | 6 +- .../typescript/ai/tests/type-check.test.ts | 33 +++++++++ 5 files changed, 103 insertions(+), 44 deletions(-) create mode 100644 docs/reference/type-aliases/TypedStreamChunk.md diff --git a/docs/reference/type-aliases/StreamChunk.md b/docs/reference/type-aliases/StreamChunk.md index 4b6f19601..a4f89717c 100644 --- a/docs/reference/type-aliases/StreamChunk.md +++ b/docs/reference/type-aliases/StreamChunk.md @@ -14,37 +14,4 @@ Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai 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: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 Standard Schema inference. - -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. +For the tool-aware variant that narrows `TOOL_CALL_START`/`TOOL_CALL_END` events by tool name and `CUSTOM` events by tagged literal name, see [`TypedStreamChunk`](./TypedStreamChunk). diff --git a/docs/reference/type-aliases/TypedStreamChunk.md b/docs/reference/type-aliases/TypedStreamChunk.md new file mode 100644 index 000000000..9455f34dc --- /dev/null +++ b/docs/reference/type-aliases/TypedStreamChunk.md @@ -0,0 +1,67 @@ +--- +id: TypedStreamChunk +title: TypedStreamChunk +--- + +# Type Alias: TypedStreamChunk\ + +```ts +type TypedStreamChunk< + TTools extends ReadonlyArray> = ReadonlyArray>, +> = + HasTypedTools extends true + ? + | Exclude< + StreamChunk, + | { type: 'TOOL_CALL_START' } + | { type: 'TOOL_CALL_END' } + | { type: 'CUSTOM' } + > + | DistributedToolCallStart + | DistributedToolCallEnd + | TaggedCustomEvent + : StreamChunk; +``` + +Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts) + +A variant of [`StreamChunk`](./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 Standard Schema inference. +- `CUSTOM` events with literal tagged names (`structured-output.start`, `structured-output.complete`, `approval-requested`, `tool-input-available`) narrow `value` to the corresponding payload via the [`TaggedCustomEvent`](./TaggedCustomEvent) union. + +When tools are untyped or absent, `TypedStreamChunk` falls back to plain `StreamChunk` so existing consumers that pass streams as `AsyncIterable` keep working. + +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 { chat, toolDefinition, type TypedStreamChunk } 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() }), +}); + +const searchTool = toolDefinition({ + name: "search", + description: "Search the web", + inputSchema: z.object({ query: z.string() }), +}); + +// Inferred from `chat()` — typed tool call events plus tagged CUSTOM events +const stream = chat({ + adapter: openaiText("gpt-4o"), + messages, + tools: [weatherTool, searchTool], +}); + +// Explicit annotation +type Chunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]>; +``` + +See [Streaming - Type-Safe Tool Call Events](../../chat/streaming) for a practical walkthrough. diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts index 0905c5545..e917b8a1e 100644 --- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts +++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts @@ -103,11 +103,7 @@ export function createChat< ...(options.forwardedProps !== undefined && { forwardedProps: options.forwardedProps, }), - onResponse: (response) => { - // Forward the call but ignore any Promise the caller returns — - // ChatClient's onResponse contract is fire-and-forget. - void options.onResponse?.(response) - }, + onResponse: (response) => options.onResponse?.(response), onChunk: (chunk: StreamChunk) => { options.onChunk?.(chunk) }, diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index bdcae4c0d..306ba2ff6 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1296,11 +1296,7 @@ export interface ToolInputAvailableEvent extends CustomEvent { * outside the literal-`name` narrows or cast explicitly. */ export type StructuredOutputStream = AsyncIterable< - | Exclude - | StructuredOutputStartEvent - | StructuredOutputCompleteEvent - | ApprovalRequestedEvent - | ToolInputAvailableEvent + Exclude | TaggedCustomEvent > // ============================================================================ diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 8f5d22412..a89e5c4eb 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { chat, createChatOptions, toolDefinition } from '../src' import type { JSONSchema, + ProviderTool, StreamChunk, Tool, ToolCallArgsEvent, @@ -104,6 +105,16 @@ const jsonSchemaTool: Tool = { }, } +// Provider tools carry opaque metadata and intentionally have a generic +// `string` name — used here to assert `NonProviderTools` correctly partitions +// them out so they don't widen the typed tool-call discriminated union. +const fakeProviderTool: ProviderTool<'fake-provider', 'web_search'> = { + name: 'web_search', + description: 'Provider-native web search', + '~provider': 'fake-provider', + '~toolKind': 'web_search', +} + // =========================== // Type-level helpers to reduce Extract repetition // =========================== @@ -551,6 +562,28 @@ describe('TypedStreamChunk fallback behavior', () => { 'get_weather' | 'search' >() }) + + it('should fall back to untyped events when the array contains ONLY ProviderTools', () => { + // ProviderTool has `name: string` (generic), so HasTypedTools must report + // false for arrays containing only ProviderTools — otherwise the + // discriminated union would widen `toolName` back to `string` and defeat + // the entire typing exercise. Regression guard for NonProviderTools. + type E = ToolEventsOf<[typeof fakeProviderTool]> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toBeUnknown() + }) + + it('should keep narrowing on user tools when mixed with ProviderTools', () => { + // The motivating mixed case: a user passes `[webSearchTool, myTypedTool]` + // — they should still get typed narrowing for `myTypedTool`. Partition + // strips the ProviderTool, leaving the typed tool's literal name. + type E = ToolEventsOf<[typeof fakeProviderTool, typeof weatherTool]> + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf().toEqualTypeOf<'get_weather'>() + }) }) // =========================== From f66ad738c5b6b2fb15ab3c78a7bf2517e41fe361 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 25 May 2026 15:36:29 +0200 Subject: [PATCH 09/20] docs: add TaggedCustomEvent reference page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new TypedStreamChunk reference page introduced in 2b0bd96e links to ./TaggedCustomEvent, but the corresponding page didn't exist yet — CI's verify-links script flagged it as broken. Materialize the page manually (TypeDoc would generate it on the next docs regen) so the cross-link resolves and consumers landing on TypedStreamChunk can follow through to the tagged-CUSTOM-event union description. The page documents the four tagged variants (structured-output.*, approval-requested, tool-input-available), the T parameterization on StructuredOutputCompleteEvent, and the user-emitted-event caveat that keeps the bare CustomEvent out of the union to avoid value: any poisoning. Verification: - node scripts/verify-links.ts: green (0 broken links across 299 files) Call-site enumeration: - TypedStreamChunk.md:33 — './TaggedCustomEvent' link now resolves. - StreamChunk.md, no link to TaggedCustomEvent (unchanged). - No other files reference the page. --- .../type-aliases/TaggedCustomEvent.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/reference/type-aliases/TaggedCustomEvent.md diff --git a/docs/reference/type-aliases/TaggedCustomEvent.md b/docs/reference/type-aliases/TaggedCustomEvent.md new file mode 100644 index 000000000..04140ca18 --- /dev/null +++ b/docs/reference/type-aliases/TaggedCustomEvent.md @@ -0,0 +1,32 @@ +--- +id: TaggedCustomEvent +title: TaggedCustomEvent +--- + +# Type Alias: TaggedCustomEvent\ + +```ts +type TaggedCustomEvent = + | StructuredOutputStartEvent + | StructuredOutputCompleteEvent + | ApprovalRequestedEvent + | ToolInputAvailableEvent; +``` + +Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts) + +Discriminated union of the orchestrator-tagged `CUSTOM` events. Each variant has a literal `name`, so a single narrow on `chunk.name` yields a typed `value` with no helper or cast: + +```ts +if (chunk.type === 'CUSTOM' && chunk.name === 'approval-requested') { + chunk.value.toolCallId // typed as string +} +``` + +The `StructuredOutputCompleteEvent` value is parameterized by `T`, which the chat orchestrator narrows to the schema's inferred type after Standard Schema validation. Adapters always emit it with `T = unknown`. + +`TaggedCustomEvent` is included in [`TypedStreamChunk`](./TypedStreamChunk)'s typed-tools branch so consumers iterating `chat()` streams get tagged narrowing alongside the per-tool `TOOL_CALL_START`/`TOOL_CALL_END` typing. + +## Caveat: user-emitted custom events + +Tools can emit arbitrary user-defined custom events via the `emitCustomEvent(name, value)` context API. Those flow through the stream at runtime but are intentionally absent from this union — including a bare `CustomEvent` (whose `value: any` would poison the union) would collapse `chunk.value` back to `any` after the narrow. If you rely on `emitCustomEvent`, branch on `CUSTOM` outside the literal-`name` narrows or cast the chunk to [`StreamChunk`](./StreamChunk) to recover the wider shape. From c42100f0942037dff8b31c4fc966101c764b4716 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 25 May 2026 15:43:08 +0200 Subject: [PATCH 10/20] docs(ai): fix TypedStreamChunk JSDoc to match no-typed-tools fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 CR confirmation flagged that the JSDoc claimed 'tagged custom events still narrow' in the no-typed-tools branch, but the implementation falls back to plain StreamChunk (no TaggedCustomEvent union) for back-compat with AsyncIterable consumers. Consumers reading the JSDoc would write code assuming tagged narrowing on bare chat() calls and find runtime CustomEvent shapes that don't match the type-narrowed expectation. Rewrite the fallback paragraph to state the actual behavior: plain StreamChunk fallback, opt into typed tools to get TaggedCustomEvent narrowing alongside per-tool TOOL_CALL_*. Call-site enumeration: - types.ts:1571 TypedStreamChunk — JSDoc only, no signature change. - Implementation at 1576-1587 is unchanged; verify-types green. - docs/reference/type-aliases/TypedStreamChunk.md already accurately describes the fallback (line 31 'falls back to plain StreamChunk') so the reference doc and source-of-truth comment are now consistent. Verification: - pnpm --filter @tanstack/ai test:types: green - node scripts/verify-links.ts: 0 broken links --- packages/typescript/ai/src/types.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 306ba2ff6..3cb26f577 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1564,9 +1564,11 @@ export type TaggedCustomEvent = * but are excluded from the type to avoid `any` poisoning the union; cast to * `StreamChunk` if you need to read those. * - * When tools are untyped or absent, tool-call events stay generic - * (`toolName: string`, `input: unknown`) but tagged custom events still - * narrow. + * When tools are untyped or absent, `TypedStreamChunk` falls back to plain + * `StreamChunk` — tagged custom events are NOT narrowed in that branch. + * This preserves back-compat with consumers that pass `chat()`'s stream as + * `AsyncIterable` (e.g. `toServerSentEventsResponse`). To get + * tagged narrowing, pass typed tools via `toolDefinition()`. */ export type TypedStreamChunk< TTools extends ReadonlyArray> = ReadonlyArray< From c768cce98a7a8280e04f47485e2ae53270b62226 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 25 May 2026 15:48:40 +0200 Subject: [PATCH 11/20] refactor(ai): include TTools in streaming-structured chat() return cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 CR confirmation flagged that the streaming-structured branch of chat() cast its return to TextActivityResult while the other three branches use TextActivityResult. The cast is type-equivalent today (the structured-stream branch of TextActivityResult doesn't reference TTools) but the visual asymmetry is a future-rot hazard — a later refactor that does parameterize the structured branch on TTools would silently miss this site. Call-site enumeration: - chat/index.ts:2161 — the cast itself, updated. - No other call sites cast to TextActivityResult; all consumers receive the return value from chat()'s narrowed conditional type. Verification: - pnpm --filter @tanstack/ai test:types: green - node scripts/verify-links.ts: 0 broken links --- packages/typescript/ai/src/activities/chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index a3df05792..07ff4ae95 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -2158,7 +2158,7 @@ export function chat< ...options, outputSchema, stream, - }) as TextActivityResult + }) as TextActivityResult } // If outputSchema is provided, run agentic structured output (Promise) From 3569aa041d172dcafd6387e42af1e83422880250 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 14:49:40 +0200 Subject: [PATCH 12/20] =?UTF-8?q?feat(ai):=20narrow=20stream=20chunks=20vi?= =?UTF-8?q?a=20Pick=20+=20EventType=20literals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@ag-ui/core`'s event schemas use Zod's `.passthrough()` mode, which leaks an index signature into every `z.infer`. That index signature pollutes our discriminated-union narrowing — any property access on `chunk.` succeeds regardless of variant, and `chunk.type === 'X'` does not actually isolate the matching event. Each TanStack event interface now uses `Pick` to pull in the AGUI-spec'd fields without inheriting the passthrough index signature, then declares `type` as the matching `EventType` enum literal (e.g. `EventType.RUN_STARTED`). Because the enum's runtime value IS the corresponding string, both narrowing forms now work: if (chunk.type === EventType.RUN_STARTED) { ... } // enum form if (chunk.type === 'RUN_STARTED') { ... } // string form `chunk.` IntelliSense now shows only the common base fields until a narrow happens, and after narrowing only the fields of the actual variant. A per-event compile-time drift guard (`AssertSatisfiesAGUI`) checks that every field we mirror is still assignable to the AGUI-derived shape. It catches field-type drift but not new fields, so when upgrading `@ag-ui/core`, recheck that each `Pick<>` field list still matches the upstream schema. CustomEvent and RunErrorEvent additionally allow optional `threadId`/`runId` — TanStack engine routing metadata that `strip-to-spec-middleware` removes before the event hits the wire. Tests updated: - `tool-call-manager.test.ts` factories now mirror `toolCallName` to the deprecated `toolName` alias instead of relying on the passthrough index signature to hide the missing required field. - `strip-to-spec-middleware.test.ts` and `stream-to-response.test.ts` now go through `as unknown as` when bridging `Record` and the stricter `AGUIEvent` shape. --- packages/ai/src/strip-to-spec-middleware.ts | 7 +- packages/ai/src/types.ts | 376 ++++++++++++++++-- packages/ai/tests/stream-to-response.test.ts | 2 +- .../ai/tests/strip-to-spec-middleware.test.ts | 6 +- packages/ai/tests/tool-call-manager.test.ts | 10 +- 5 files changed, 365 insertions(+), 36 deletions(-) diff --git a/packages/ai/src/strip-to-spec-middleware.ts b/packages/ai/src/strip-to-spec-middleware.ts index c4648702f..2953899ff 100644 --- a/packages/ai/src/strip-to-spec-middleware.ts +++ b/packages/ai/src/strip-to-spec-middleware.ts @@ -13,8 +13,11 @@ import type { StreamChunk } from './types' export function stripToSpec(chunk: StreamChunk): StreamChunk { // Only strip the deprecated nested error object from RUN_ERROR if (chunk.type === 'RUN_ERROR' && 'error' in chunk) { - const { error: _deprecated, ...rest } = chunk as Record - return rest as StreamChunk + const { error: _deprecated, ...rest } = chunk as unknown as Record< + string, + unknown + > + return rest as unknown as StreamChunk } return chunk } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 518015156..22c789646 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -6,7 +6,6 @@ import type { InternalLogger } from './logger/internal-logger' import type { SystemPrompt } from './system-prompts' import type { ProviderTool } from './tools/provider-tool' import type { - BaseEvent as AGUIBaseEvent, CustomEvent as AGUICustomEvent, MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, @@ -905,17 +904,55 @@ export type AGUIEventType = `${EventType}` export type StreamChunkType = AGUIEventType /** - * Base structure for AG-UI events. - * Extends @ag-ui/core BaseEvent with TanStack AI additions. + * Base structure for TanStack AI events. + * + * We deliberately do NOT `extends AGUIBaseEvent` here. @ag-ui/core's + * schemas use Zod's `.passthrough()` mode, which leaks an index + * signature into every `z.infer` event type. That index + * signature pollutes discriminated-union narrowing — `chunk.foo` would + * succeed on every variant regardless of which event it is — and + * collapses IntelliSense back to "every property from every variant". + * + * Instead, each event below uses `Pick` + * to pull in the AGUI-spec'd fields without inheriting the index + * signature, then declares `type` as the matching `EventType` enum + * literal (e.g. `EventType.RUN_STARTED`). Because the enum's runtime + * value IS the string `"RUN_STARTED"`, TypeScript accepts narrowing + * in BOTH forms: + * + * if (chunk.type === EventType.RUN_STARTED) { ... } // enum + * if (chunk.type === 'RUN_STARTED') { ... } // string + * + * The per-event drift guards below fail to compile if @ag-ui/core ever + * changes the shape of a field we mirror — they catch field-type drift + * but NOT new fields, so when upgrading @ag-ui/core, re-check that the + * `Pick<>` field lists still match the AGUI schema. * * @ag-ui/core provides: `type`, `timestamp?`, `rawEvent?` * TanStack AI adds: `model?` */ -export interface BaseAGUIEvent extends AGUIBaseEvent { +export interface BaseAGUIEvent { + /** Discriminator carried by every event variant. */ + type: EventType + /** Optional event timestamp (ms since epoch). */ + timestamp?: number + /** Optional opaque provider payload. */ + rawEvent?: unknown /** Model identifier for multi-model support */ model?: string } +/** + * Compile-time drift guard. Errors with a descriptive type if the + * TanStack event's fields are not assignable to the corresponding + * AGUI event's `Pick`'d shape. Used to catch breaking changes when + * @ag-ui/core releases a new version. + * @internal + */ +type AssertSatisfiesAGUI = TanStack extends AGUIShape + ? true + : ['DRIFT: TanStack event no longer satisfies AGUI shape', TanStack, AGUIShape] + // ============================================================================ // AG-UI Event Interfaces // ============================================================================ @@ -927,10 +964,24 @@ export interface BaseAGUIEvent extends AGUIBaseEvent { * @ag-ui/core provides: `threadId`, `runId`, `parentRunId?`, `input?` * TanStack AI adds: `model?` */ -export interface RunStartedEvent extends AGUIRunStartedEvent { +export interface RunStartedEvent + extends Pick< + AGUIRunStartedEvent, + 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' + > { + type: EventType.RUN_STARTED /** Model identifier for multi-model support */ model?: string } +type _RunStartedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIRunStartedEvent, + 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' + > +> +const _runStartedDriftCheck: _RunStartedDriftCheck = true +void _runStartedDriftCheck /** * Emitted when a run completes successfully. @@ -938,7 +989,12 @@ export interface RunStartedEvent extends AGUIRunStartedEvent { * @ag-ui/core provides: `threadId`, `runId`, `result?` * TanStack AI adds: `model?`, `finishReason?`, `usage?` */ -export interface RunFinishedEvent extends AGUIRunFinishedEvent { +export interface RunFinishedEvent + extends Pick< + AGUIRunFinishedEvent, + 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' + > { + type: EventType.RUN_FINISHED /** Model identifier for multi-model support */ model?: string /** Why the generation stopped */ @@ -950,6 +1006,15 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { totalTokens: number } } +type _RunFinishedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIRunFinishedEvent, + 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' + > +> +const _runFinishedDriftCheck: _RunFinishedDriftCheck = true +void _runFinishedDriftCheck /** * Emitted when an error occurs during a run. @@ -957,9 +1022,21 @@ export interface RunFinishedEvent extends AGUIRunFinishedEvent { * @ag-ui/core provides: `message`, `code?` * TanStack AI adds: `model?`, `error?` (deprecated nested form) */ -export interface RunErrorEvent extends AGUIRunErrorEvent { +export interface RunErrorEvent + extends Pick< + AGUIRunErrorEvent, + 'message' | 'code' | 'timestamp' | 'rawEvent' + > { + type: EventType.RUN_ERROR /** Model identifier for multi-model support */ model?: string + /** + * Routing metadata the TanStack engine attaches when emitting error + * events. Stripped by `strip-to-spec-middleware` before going on the + * wire so the AG-UI consumer never sees them. + */ + threadId?: string + runId?: string /** * @deprecated Use top-level `message` and `code` fields instead. * Kept for backward compatibility. @@ -971,6 +1048,12 @@ export interface RunErrorEvent extends AGUIRunErrorEvent { } | undefined } +type _RunErrorDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _runErrorDriftCheck: _RunErrorDriftCheck = true +void _runErrorDriftCheck /** * Emitted when a text message starts. @@ -978,10 +1061,24 @@ export interface RunErrorEvent extends AGUIRunErrorEvent { * @ag-ui/core provides: `messageId`, `role?`, `name?` * TanStack AI adds: `model?` */ -export interface TextMessageStartEvent extends AGUITextMessageStartEvent { +export interface TextMessageStartEvent + extends Pick< + AGUITextMessageStartEvent, + 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' + > { + type: EventType.TEXT_MESSAGE_START /** Model identifier for multi-model support */ model?: string } +type _TextMessageStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUITextMessageStartEvent, + 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' + > +> +const _textMessageStartDriftCheck: _TextMessageStartDriftCheck = true +void _textMessageStartDriftCheck /** * Emitted when text content is generated (streaming tokens). @@ -989,12 +1086,26 @@ export interface TextMessageStartEvent extends AGUITextMessageStartEvent { * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?`, `content?` (accumulated) */ -export interface TextMessageContentEvent extends AGUITextMessageContentEvent { +export interface TextMessageContentEvent + extends Pick< + AGUITextMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > { + type: EventType.TEXT_MESSAGE_CONTENT /** Model identifier for multi-model support */ model?: string /** Full accumulated content so far (TanStack AI internal, for debugging) */ content?: string } +type _TextMessageContentDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUITextMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > +> +const _textMessageContentDriftCheck: _TextMessageContentDriftCheck = true +void _textMessageContentDriftCheck /** * Emitted when a text message completes. @@ -1002,10 +1113,21 @@ export interface TextMessageContentEvent extends AGUITextMessageContentEvent { * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface TextMessageEndEvent extends AGUITextMessageEndEvent { +export interface TextMessageEndEvent + extends Pick< + AGUITextMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' + > { + type: EventType.TEXT_MESSAGE_END /** Model identifier for multi-model support */ model?: string } +type _TextMessageEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _textMessageEndDriftCheck: _TextMessageEndDriftCheck = true +void _textMessageEndDriftCheck /** * Emitted when a tool call starts. @@ -1029,9 +1151,12 @@ export interface TextMessageEndEvent extends AGUITextMessageEndEvent { * `ToolCallStartEvent<'x'>['toolCallName']` get `string` — use the * distributed variant (via `TypedStreamChunk`) for discriminated narrowing. */ -export interface ToolCallStartEvent< - TToolName extends string = string, -> extends AGUIToolCallStartEvent { +export interface ToolCallStartEvent + extends Pick< + AGUIToolCallStartEvent, + 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' + > { + type: EventType.TOOL_CALL_START /** Model identifier for multi-model support */ model?: string /** @@ -1051,6 +1176,15 @@ export interface ToolCallStartEvent< * `TToolCallMetadata` shape when emitting. */ metadata?: Record } +type _ToolCallStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIToolCallStartEvent, + 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' + > +> +const _toolCallStartDriftCheck: _ToolCallStartDriftCheck = true +void _toolCallStartDriftCheck /** * Emitted when tool call arguments are streaming. @@ -1058,12 +1192,23 @@ export interface ToolCallStartEvent< * @ag-ui/core provides: `toolCallId`, `delta` * TanStack AI adds: `model?`, `args?` (accumulated) */ -export interface ToolCallArgsEvent extends AGUIToolCallArgsEvent { +export interface ToolCallArgsEvent + extends Pick< + AGUIToolCallArgsEvent, + 'toolCallId' | 'delta' | 'timestamp' | 'rawEvent' + > { + type: EventType.TOOL_CALL_ARGS /** Model identifier for multi-model support */ model?: string /** Full accumulated arguments so far (TanStack AI internal) */ args?: string } +type _ToolCallArgsDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _toolCallArgsDriftCheck: _ToolCallArgsDriftCheck = true +void _toolCallArgsDriftCheck /** * Emitted when a tool call completes. @@ -1079,7 +1224,11 @@ export interface ToolCallArgsEvent extends AGUIToolCallArgsEvent { export interface ToolCallEndEvent< TToolName extends string = string, TInput = unknown, -> extends AGUIToolCallEndEvent { +> extends Pick< + AGUIToolCallEndEvent, + 'toolCallId' | 'timestamp' | 'rawEvent' + > { + type: EventType.TOOL_CALL_END /** Model identifier for multi-model support */ model?: string /** @@ -1104,6 +1253,15 @@ export interface ToolCallEndEvent< /** Tool execution result (TanStack AI internal) */ result?: string } +type _ToolCallEndDriftCheck = AssertSatisfiesAGUI< + Omit< + ToolCallEndEvent, + 'type' | 'model' | 'toolCallName' | 'toolName' | 'input' | 'result' + >, + Pick +> +const _toolCallEndDriftCheck: _ToolCallEndDriftCheck = true +void _toolCallEndDriftCheck /** * Emitted when a tool call result is available. @@ -1111,10 +1269,24 @@ export interface ToolCallEndEvent< * @ag-ui/core provides: `messageId`, `toolCallId`, `content`, `role?` * TanStack AI adds: `model?` */ -export interface ToolCallResultEvent extends AGUIToolCallResultEvent { +export interface ToolCallResultEvent + extends Pick< + AGUIToolCallResultEvent, + 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' + > { + type: EventType.TOOL_CALL_RESULT /** Model identifier for multi-model support */ model?: string } +type _ToolCallResultDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIToolCallResultEvent, + 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' + > +> +const _toolCallResultDriftCheck: _ToolCallResultDriftCheck = true +void _toolCallResultDriftCheck /** * Emitted when a thinking/reasoning step starts. @@ -1122,7 +1294,12 @@ export interface ToolCallResultEvent extends AGUIToolCallResultEvent { * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `stepType?` */ -export interface StepStartedEvent extends AGUIStepStartedEvent { +export interface StepStartedEvent + extends Pick< + AGUIStepStartedEvent, + 'stepName' | 'timestamp' | 'rawEvent' + > { + type: EventType.STEP_STARTED /** Model identifier for multi-model support */ model?: string /** @@ -1133,6 +1310,12 @@ export interface StepStartedEvent extends AGUIStepStartedEvent { /** Type of step (e.g., 'thinking', 'planning') */ stepType?: string } +type _StepStartedDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stepStartedDriftCheck: _StepStartedDriftCheck = true +void _stepStartedDriftCheck /** * Emitted when a thinking/reasoning step finishes. @@ -1140,7 +1323,12 @@ export interface StepStartedEvent extends AGUIStepStartedEvent { * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `delta?`, `content?` */ -export interface StepFinishedEvent extends AGUIStepFinishedEvent { +export interface StepFinishedEvent + extends Pick< + AGUIStepFinishedEvent, + 'stepName' | 'timestamp' | 'rawEvent' + > { + type: EventType.STEP_FINISHED /** Model identifier for multi-model support */ model?: string /** @@ -1155,6 +1343,15 @@ export interface StepFinishedEvent extends AGUIStepFinishedEvent { /** Provider signature for the thinking block */ signature?: string } +type _StepFinishedDriftCheck = AssertSatisfiesAGUI< + Omit< + StepFinishedEvent, + 'type' | 'model' | 'stepId' | 'delta' | 'content' | 'signature' + >, + Pick +> +const _stepFinishedDriftCheck: _StepFinishedDriftCheck = true +void _stepFinishedDriftCheck /** * Emitted to provide a snapshot of all messages in a conversation. @@ -1168,10 +1365,21 @@ export interface StepFinishedEvent extends AGUIStepFinishedEvent { * Note: The `messages` field uses the @ag-ui/core Message type. * Use converters to transform to/from TanStack UIMessage format. */ -export interface MessagesSnapshotEvent extends AGUIMessagesSnapshotEvent { +export interface MessagesSnapshotEvent + extends Pick< + AGUIMessagesSnapshotEvent, + 'messages' | 'timestamp' | 'rawEvent' + > { + type: EventType.MESSAGES_SNAPSHOT /** Model identifier for multi-model support */ model?: string } +type _MessagesSnapshotDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _messagesSnapshotDriftCheck: _MessagesSnapshotDriftCheck = true +void _messagesSnapshotDriftCheck /** * Emitted to provide a full state snapshot. @@ -1179,7 +1387,12 @@ export interface MessagesSnapshotEvent extends AGUIMessagesSnapshotEvent { * @ag-ui/core provides: `snapshot` (any) * TanStack AI adds: `model?`, `state?` (deprecated alias for snapshot) */ -export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { +export interface StateSnapshotEvent + extends Pick< + AGUIStateSnapshotEvent, + 'snapshot' | 'timestamp' | 'rawEvent' + > { + type: EventType.STATE_SNAPSHOT /** Model identifier for multi-model support */ model?: string /** @@ -1188,6 +1401,12 @@ export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { */ state?: Record } +type _StateSnapshotDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stateSnapshotDriftCheck: _StateSnapshotDriftCheck = true +void _stateSnapshotDriftCheck /** * Emitted to provide an incremental state update. @@ -1195,10 +1414,18 @@ export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { * @ag-ui/core provides: `delta` (any[] - JSON Patch RFC 6902) * TanStack AI adds: `model?` */ -export interface StateDeltaEvent extends AGUIStateDeltaEvent { +export interface StateDeltaEvent + extends Pick { + type: EventType.STATE_DELTA /** Model identifier for multi-model support */ model?: string } +type _StateDeltaDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _stateDeltaDriftCheck: _StateDeltaDriftCheck = true +void _stateDeltaDriftCheck /** * Custom event for extensibility. @@ -1206,10 +1433,26 @@ export interface StateDeltaEvent extends AGUIStateDeltaEvent { * @ag-ui/core provides: `name`, `value` * TanStack AI adds: `model?` */ -export interface CustomEvent extends AGUICustomEvent { +export interface CustomEvent + extends Pick { + type: EventType.CUSTOM /** Model identifier for multi-model support */ model?: string + /** + * Routing metadata the TanStack engine attaches when emitting CUSTOM + * events that need to be correlated with a specific thread/run. + * Stripped by `strip-to-spec-middleware` before going on the wire so + * the AG-UI consumer never sees them. + */ + threadId?: string + runId?: string } +type _CustomDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _customDriftCheck: _CustomDriftCheck = true +void _customDriftCheck /** * Final event of a streaming structured-output run. Carries the validated @@ -1325,10 +1568,21 @@ export type StructuredOutputStream = AsyncIterable< * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningStartEvent extends AGUIReasoningStartEvent { +export interface ReasoningStartEvent + extends Pick< + AGUIReasoningStartEvent, + 'messageId' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_START /** Model identifier for multi-model support */ model?: string } +type _ReasoningStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningStartDriftCheck: _ReasoningStartDriftCheck = true +void _reasoningStartDriftCheck /** * Emitted when a reasoning message starts. @@ -1336,10 +1590,24 @@ export interface ReasoningStartEvent extends AGUIReasoningStartEvent { * @ag-ui/core provides: `messageId`, `role` ("reasoning") * TanStack AI adds: `model?` */ -export interface ReasoningMessageStartEvent extends AGUIReasoningMessageStartEvent { +export interface ReasoningMessageStartEvent + extends Pick< + AGUIReasoningMessageStartEvent, + 'messageId' | 'role' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_MESSAGE_START /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageStartDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningMessageStartEvent, + 'messageId' | 'role' | 'timestamp' | 'rawEvent' + > +> +const _reasoningMessageStartDriftCheck: _ReasoningMessageStartDriftCheck = true +void _reasoningMessageStartDriftCheck /** * Emitted when reasoning message content is generated. @@ -1347,10 +1615,25 @@ export interface ReasoningMessageStartEvent extends AGUIReasoningMessageStartEve * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?` */ -export interface ReasoningMessageContentEvent extends AGUIReasoningMessageContentEvent { +export interface ReasoningMessageContentEvent + extends Pick< + AGUIReasoningMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_MESSAGE_CONTENT /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageContentDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' + > +> +const _reasoningMessageContentDriftCheck: _ReasoningMessageContentDriftCheck = + true +void _reasoningMessageContentDriftCheck /** * Emitted when a reasoning message ends. @@ -1358,10 +1641,21 @@ export interface ReasoningMessageContentEvent extends AGUIReasoningMessageConten * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningMessageEndEvent extends AGUIReasoningMessageEndEvent { +export interface ReasoningMessageEndEvent + extends Pick< + AGUIReasoningMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_MESSAGE_END /** Model identifier for multi-model support */ model?: string } +type _ReasoningMessageEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningMessageEndDriftCheck: _ReasoningMessageEndDriftCheck = true +void _reasoningMessageEndDriftCheck /** * Emitted when reasoning ends for a message. @@ -1369,10 +1663,21 @@ export interface ReasoningMessageEndEvent extends AGUIReasoningMessageEndEvent { * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningEndEvent extends AGUIReasoningEndEvent { +export interface ReasoningEndEvent + extends Pick< + AGUIReasoningEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_END /** Model identifier for multi-model support */ model?: string } +type _ReasoningEndDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick +> +const _reasoningEndDriftCheck: _ReasoningEndDriftCheck = true +void _reasoningEndDriftCheck /** * Emitted for encrypted reasoning values. @@ -1380,10 +1685,25 @@ export interface ReasoningEndEvent extends AGUIReasoningEndEvent { * @ag-ui/core provides: `subtype`, `entityId`, `encryptedValue` * TanStack AI adds: `model?` */ -export interface ReasoningEncryptedValueEvent extends AGUIReasoningEncryptedValueEvent { +export interface ReasoningEncryptedValueEvent + extends Pick< + AGUIReasoningEncryptedValueEvent, + 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' + > { + type: EventType.REASONING_ENCRYPTED_VALUE /** Model identifier for multi-model support */ model?: string } +type _ReasoningEncryptedValueDriftCheck = AssertSatisfiesAGUI< + Omit, + Pick< + AGUIReasoningEncryptedValueEvent, + 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' + > +> +const _reasoningEncryptedValueDriftCheck: _ReasoningEncryptedValueDriftCheck = + true +void _reasoningEncryptedValueDriftCheck // ============================================================================ // AG-UI Event Union diff --git a/packages/ai/tests/stream-to-response.test.ts b/packages/ai/tests/stream-to-response.test.ts index 420f62794..ad5844b30 100644 --- a/packages/ai/tests/stream-to-response.test.ts +++ b/packages/ai/tests/stream-to-response.test.ts @@ -11,7 +11,7 @@ async function* createMockStream( chunks: Array>, ): AsyncGenerator { for (const chunk of chunks) { - yield chunk as StreamChunk + yield chunk as unknown as StreamChunk } } diff --git a/packages/ai/tests/strip-to-spec-middleware.test.ts b/packages/ai/tests/strip-to-spec-middleware.test.ts index e27a7e7a7..15d23b7b6 100644 --- a/packages/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/ai/tests/strip-to-spec-middleware.test.ts @@ -21,7 +21,7 @@ describe('stripToSpec', () => { error: { message: 'Something went wrong' }, model: 'gpt-4o', }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).not.toHaveProperty('error') expect(result).toHaveProperty('message', 'Something went wrong') expect(result).toHaveProperty('code', 'INTERNAL_ERROR') @@ -49,7 +49,7 @@ describe('stripToSpec', () => { finishReason: 'stop', usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).toHaveProperty('model', 'gpt-4o') expect(result).toHaveProperty('finishReason', 'stop') expect(result).toHaveProperty('usage') @@ -64,7 +64,7 @@ describe('stripToSpec', () => { result: '{"items":[]}', model: 'gpt-4o', }) - const result = stripToSpec(chunk) as Record + const result = stripToSpec(chunk) as unknown as Record expect(result).toHaveProperty('toolName', 'getTodos') expect(result).toHaveProperty('toolCallName', 'getTodos') expect(result).toHaveProperty('input') diff --git a/packages/ai/tests/tool-call-manager.test.ts b/packages/ai/tests/tool-call-manager.test.ts index 5927e1682..9b9d734f2 100644 --- a/packages/ai/tests/tool-call-manager.test.ts +++ b/packages/ai/tests/tool-call-manager.test.ts @@ -15,11 +15,14 @@ import type { /** Helper to create a ToolCallStartEvent from plain fields (avoids EventType enum issues). */ function toolCallStart( - fields: Omit, + fields: Omit, ): ToolCallStartEvent { return { type: 'TOOL_CALL_START' as any, timestamp: Date.now(), + // `toolName` is the deprecated alias of `toolCallName`; mirror it + // so tests don't have to write both. + toolName: fields.toolCallName, ...fields, } as ToolCallStartEvent } @@ -37,11 +40,14 @@ function toolCallArgs( /** Helper to create a ToolCallEndEvent from plain fields. */ function toolCallEnd( - fields: Omit, + fields: Omit & { + toolCallName: string + }, ): ToolCallEndEvent { return { type: 'TOOL_CALL_END' as any, timestamp: Date.now(), + toolName: fields.toolCallName, ...fields, } as ToolCallEndEvent } From 0a9c07e4c17d8826bdbcddab6eb8a25f0919070b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 15:18:36 +0200 Subject: [PATCH 13/20] fix(ai): switch chunk.type from EventType enum literal to plain string literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit declared each event's \`type\` field using the \`EventType\` enum literal (e.g. \`EventType.RUN_STARTED\`). That gave correct narrowing for \`chunk.type === EventType.X\`, but VS Code does NOT offer string suggestions after \`chunk.type === "\` when the LHS is an enum literal — IntelliSense only proposes \`EventType.*\` enum members. The whole point of this refactor is the bare-string form autocompleting, so flip each event's \`type\` to the plain string literal (e.g. \`'RUN_STARTED'\`). Both forms still narrow because each \`EventType\` member's runtime value IS the matching string, so \`EventType.RUN_STARTED\` remains assignable to \`type: 'RUN_STARTED'\` slots. Test-helper updates required: - \`stream-processor.test.ts\` \`chunk()\` factory now coerces its type param through a template-literal wrapper (\`type: \`${T}\`\`) so callers passing \`EventType.X\` still produce a usable \`Extract\` (without the coercion, Extract returned \`never\` because the string-literal variant does not extend the strict enum literal). - Five \`Extract\` predicates in the OpenRouter test suite are now \`Extract\` — same reason. --- .../tests/openrouter-adapter.test.ts | 4 +- .../openrouter-responses-adapter.test.ts | 6 +- packages/ai/src/types.ts | 61 ++++++++++--------- packages/ai/tests/stream-processor.test.ts | 6 +- 4 files changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index e010db062..5d710379b 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -2055,7 +2055,7 @@ describe('OpenRouter convertMessage fail-loud guards', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -2081,7 +2081,7 @@ describe('OpenRouter convertMessage fail-loud guards', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 17d86ec5e..af87862af 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -208,7 +208,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -228,7 +228,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() @@ -515,7 +515,7 @@ describe('OpenRouter responses adapter — request shape', () => { events.push(evt) } const runError = events.find( - (e): e is Extract => + (e): e is Extract => e.type === EventType.RUN_ERROR, ) expect(runError).toBeDefined() diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 22c789646..7011517a3 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -915,13 +915,16 @@ export type StreamChunkType = AGUIEventType * * Instead, each event below uses `Pick` * to pull in the AGUI-spec'd fields without inheriting the index - * signature, then declares `type` as the matching `EventType` enum - * literal (e.g. `EventType.RUN_STARTED`). Because the enum's runtime - * value IS the string `"RUN_STARTED"`, TypeScript accepts narrowing - * in BOTH forms: + * signature, then declares `type` as a plain string literal + * (e.g. `'RUN_STARTED'`). This is the form TypeScript's IntelliSense + * uses when offering suggestions after `chunk.type === "`, so the + * common discriminator narrow autocompletes. Comparing against the + * `EventType` enum still works because each enum member's runtime + * value IS that same string — `EventType.RUN_STARTED` is assignable + * to `'RUN_STARTED'`: * - * if (chunk.type === EventType.RUN_STARTED) { ... } // enum - * if (chunk.type === 'RUN_STARTED') { ... } // string + * if (chunk.type === 'RUN_STARTED') { ... } // string (autocompletes) + * if (chunk.type === EventType.RUN_STARTED) { ... } // enum (also narrows) * * The per-event drift guards below fail to compile if @ag-ui/core ever * changes the shape of a field we mirror — they catch field-type drift @@ -933,7 +936,7 @@ export type StreamChunkType = AGUIEventType */ export interface BaseAGUIEvent { /** Discriminator carried by every event variant. */ - type: EventType + type: AGUIEventType /** Optional event timestamp (ms since epoch). */ timestamp?: number /** Optional opaque provider payload. */ @@ -969,7 +972,7 @@ export interface RunStartedEvent AGUIRunStartedEvent, 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' > { - type: EventType.RUN_STARTED + type: 'RUN_STARTED' /** Model identifier for multi-model support */ model?: string } @@ -994,7 +997,7 @@ export interface RunFinishedEvent AGUIRunFinishedEvent, 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' > { - type: EventType.RUN_FINISHED + type: 'RUN_FINISHED' /** Model identifier for multi-model support */ model?: string /** Why the generation stopped */ @@ -1027,7 +1030,7 @@ export interface RunErrorEvent AGUIRunErrorEvent, 'message' | 'code' | 'timestamp' | 'rawEvent' > { - type: EventType.RUN_ERROR + type: 'RUN_ERROR' /** Model identifier for multi-model support */ model?: string /** @@ -1066,7 +1069,7 @@ export interface TextMessageStartEvent AGUITextMessageStartEvent, 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' > { - type: EventType.TEXT_MESSAGE_START + type: 'TEXT_MESSAGE_START' /** Model identifier for multi-model support */ model?: string } @@ -1091,7 +1094,7 @@ export interface TextMessageContentEvent AGUITextMessageContentEvent, 'messageId' | 'delta' | 'timestamp' | 'rawEvent' > { - type: EventType.TEXT_MESSAGE_CONTENT + type: 'TEXT_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string /** Full accumulated content so far (TanStack AI internal, for debugging) */ @@ -1118,7 +1121,7 @@ export interface TextMessageEndEvent AGUITextMessageEndEvent, 'messageId' | 'timestamp' | 'rawEvent' > { - type: EventType.TEXT_MESSAGE_END + type: 'TEXT_MESSAGE_END' /** Model identifier for multi-model support */ model?: string } @@ -1156,7 +1159,7 @@ export interface ToolCallStartEvent AGUIToolCallStartEvent, 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' > { - type: EventType.TOOL_CALL_START + type: 'TOOL_CALL_START' /** Model identifier for multi-model support */ model?: string /** @@ -1197,7 +1200,7 @@ export interface ToolCallArgsEvent AGUIToolCallArgsEvent, 'toolCallId' | 'delta' | 'timestamp' | 'rawEvent' > { - type: EventType.TOOL_CALL_ARGS + type: 'TOOL_CALL_ARGS' /** Model identifier for multi-model support */ model?: string /** Full accumulated arguments so far (TanStack AI internal) */ @@ -1228,7 +1231,7 @@ export interface ToolCallEndEvent< AGUIToolCallEndEvent, 'toolCallId' | 'timestamp' | 'rawEvent' > { - type: EventType.TOOL_CALL_END + type: 'TOOL_CALL_END' /** Model identifier for multi-model support */ model?: string /** @@ -1274,7 +1277,7 @@ export interface ToolCallResultEvent AGUIToolCallResultEvent, 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' > { - type: EventType.TOOL_CALL_RESULT + type: 'TOOL_CALL_RESULT' /** Model identifier for multi-model support */ model?: string } @@ -1299,7 +1302,7 @@ export interface StepStartedEvent AGUIStepStartedEvent, 'stepName' | 'timestamp' | 'rawEvent' > { - type: EventType.STEP_STARTED + type: 'STEP_STARTED' /** Model identifier for multi-model support */ model?: string /** @@ -1328,7 +1331,7 @@ export interface StepFinishedEvent AGUIStepFinishedEvent, 'stepName' | 'timestamp' | 'rawEvent' > { - type: EventType.STEP_FINISHED + type: 'STEP_FINISHED' /** Model identifier for multi-model support */ model?: string /** @@ -1370,7 +1373,7 @@ export interface MessagesSnapshotEvent AGUIMessagesSnapshotEvent, 'messages' | 'timestamp' | 'rawEvent' > { - type: EventType.MESSAGES_SNAPSHOT + type: 'MESSAGES_SNAPSHOT' /** Model identifier for multi-model support */ model?: string } @@ -1392,7 +1395,7 @@ export interface StateSnapshotEvent AGUIStateSnapshotEvent, 'snapshot' | 'timestamp' | 'rawEvent' > { - type: EventType.STATE_SNAPSHOT + type: 'STATE_SNAPSHOT' /** Model identifier for multi-model support */ model?: string /** @@ -1416,7 +1419,7 @@ void _stateSnapshotDriftCheck */ export interface StateDeltaEvent extends Pick { - type: EventType.STATE_DELTA + type: 'STATE_DELTA' /** Model identifier for multi-model support */ model?: string } @@ -1435,7 +1438,7 @@ void _stateDeltaDriftCheck */ export interface CustomEvent extends Pick { - type: EventType.CUSTOM + type: 'CUSTOM' /** Model identifier for multi-model support */ model?: string /** @@ -1573,7 +1576,7 @@ export interface ReasoningStartEvent AGUIReasoningStartEvent, 'messageId' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_START + type: 'REASONING_START' /** Model identifier for multi-model support */ model?: string } @@ -1595,7 +1598,7 @@ export interface ReasoningMessageStartEvent AGUIReasoningMessageStartEvent, 'messageId' | 'role' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_MESSAGE_START + type: 'REASONING_MESSAGE_START' /** Model identifier for multi-model support */ model?: string } @@ -1620,7 +1623,7 @@ export interface ReasoningMessageContentEvent AGUIReasoningMessageContentEvent, 'messageId' | 'delta' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_MESSAGE_CONTENT + type: 'REASONING_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string } @@ -1646,7 +1649,7 @@ export interface ReasoningMessageEndEvent AGUIReasoningMessageEndEvent, 'messageId' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_MESSAGE_END + type: 'REASONING_MESSAGE_END' /** Model identifier for multi-model support */ model?: string } @@ -1668,7 +1671,7 @@ export interface ReasoningEndEvent AGUIReasoningEndEvent, 'messageId' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_END + type: 'REASONING_END' /** Model identifier for multi-model support */ model?: string } @@ -1690,7 +1693,7 @@ export interface ReasoningEncryptedValueEvent AGUIReasoningEncryptedValueEvent, 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' > { - type: EventType.REASONING_ENCRYPTED_VALUE + type: 'REASONING_ENCRYPTED_VALUE' /** Model identifier for multi-model support */ model?: string } diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 2e1f613bd..de34b452e 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -23,10 +23,10 @@ import type { function chunk( type: T, fields?: Record, -): Extract { - return { type, timestamp: Date.now(), ...fields } as Extract< +): Extract { + return { type, timestamp: Date.now(), ...fields } as unknown as Extract< StreamChunk, - { type: T } + { type: `${T}` } > } From 573e413c2da8eea534ebb9b73f708d7296999330 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 13:20:04 +0000 Subject: [PATCH 14/20] ci: apply automated fixes --- packages/ai/src/types.ts | 207 ++++++++++++++++++--------------------- 1 file changed, 98 insertions(+), 109 deletions(-) diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7011517a3..c3699d963 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -954,7 +954,11 @@ export interface BaseAGUIEvent { */ type AssertSatisfiesAGUI = TanStack extends AGUIShape ? true - : ['DRIFT: TanStack event no longer satisfies AGUI shape', TanStack, AGUIShape] + : [ + 'DRIFT: TanStack event no longer satisfies AGUI shape', + TanStack, + AGUIShape, + ] // ============================================================================ // AG-UI Event Interfaces @@ -967,11 +971,10 @@ type AssertSatisfiesAGUI = TanStack extends AGUIShape * @ag-ui/core provides: `threadId`, `runId`, `parentRunId?`, `input?` * TanStack AI adds: `model?` */ -export interface RunStartedEvent - extends Pick< - AGUIRunStartedEvent, - 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' - > { +export interface RunStartedEvent extends Pick< + AGUIRunStartedEvent, + 'threadId' | 'runId' | 'parentRunId' | 'input' | 'timestamp' | 'rawEvent' +> { type: 'RUN_STARTED' /** Model identifier for multi-model support */ model?: string @@ -992,11 +995,10 @@ void _runStartedDriftCheck * @ag-ui/core provides: `threadId`, `runId`, `result?` * TanStack AI adds: `model?`, `finishReason?`, `usage?` */ -export interface RunFinishedEvent - extends Pick< - AGUIRunFinishedEvent, - 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' - > { +export interface RunFinishedEvent extends Pick< + AGUIRunFinishedEvent, + 'threadId' | 'runId' | 'result' | 'timestamp' | 'rawEvent' +> { type: 'RUN_FINISHED' /** Model identifier for multi-model support */ model?: string @@ -1025,11 +1027,10 @@ void _runFinishedDriftCheck * @ag-ui/core provides: `message`, `code?` * TanStack AI adds: `model?`, `error?` (deprecated nested form) */ -export interface RunErrorEvent - extends Pick< - AGUIRunErrorEvent, - 'message' | 'code' | 'timestamp' | 'rawEvent' - > { +export interface RunErrorEvent extends Pick< + AGUIRunErrorEvent, + 'message' | 'code' | 'timestamp' | 'rawEvent' +> { type: 'RUN_ERROR' /** Model identifier for multi-model support */ model?: string @@ -1064,11 +1065,10 @@ void _runErrorDriftCheck * @ag-ui/core provides: `messageId`, `role?`, `name?` * TanStack AI adds: `model?` */ -export interface TextMessageStartEvent - extends Pick< - AGUITextMessageStartEvent, - 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' - > { +export interface TextMessageStartEvent extends Pick< + AGUITextMessageStartEvent, + 'messageId' | 'role' | 'name' | 'timestamp' | 'rawEvent' +> { type: 'TEXT_MESSAGE_START' /** Model identifier for multi-model support */ model?: string @@ -1089,11 +1089,10 @@ void _textMessageStartDriftCheck * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?`, `content?` (accumulated) */ -export interface TextMessageContentEvent - extends Pick< - AGUITextMessageContentEvent, - 'messageId' | 'delta' | 'timestamp' | 'rawEvent' - > { +export interface TextMessageContentEvent extends Pick< + AGUITextMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' +> { type: 'TEXT_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string @@ -1116,11 +1115,10 @@ void _textMessageContentDriftCheck * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface TextMessageEndEvent - extends Pick< - AGUITextMessageEndEvent, - 'messageId' | 'timestamp' | 'rawEvent' - > { +export interface TextMessageEndEvent extends Pick< + AGUITextMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { type: 'TEXT_MESSAGE_END' /** Model identifier for multi-model support */ model?: string @@ -1154,11 +1152,12 @@ void _textMessageEndDriftCheck * `ToolCallStartEvent<'x'>['toolCallName']` get `string` — use the * distributed variant (via `TypedStreamChunk`) for discriminated narrowing. */ -export interface ToolCallStartEvent - extends Pick< - AGUIToolCallStartEvent, - 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' - > { +export interface ToolCallStartEvent< + TToolName extends string = string, +> extends Pick< + AGUIToolCallStartEvent, + 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' +> { type: 'TOOL_CALL_START' /** Model identifier for multi-model support */ model?: string @@ -1180,7 +1179,10 @@ export interface ToolCallStartEvent metadata?: Record } type _ToolCallStartDriftCheck = AssertSatisfiesAGUI< - Omit, + Omit< + ToolCallStartEvent, + 'type' | 'model' | 'toolName' | 'index' | 'metadata' + >, Pick< AGUIToolCallStartEvent, 'toolCallId' | 'toolCallName' | 'parentMessageId' | 'timestamp' | 'rawEvent' @@ -1195,11 +1197,10 @@ void _toolCallStartDriftCheck * @ag-ui/core provides: `toolCallId`, `delta` * TanStack AI adds: `model?`, `args?` (accumulated) */ -export interface ToolCallArgsEvent - extends Pick< - AGUIToolCallArgsEvent, - 'toolCallId' | 'delta' | 'timestamp' | 'rawEvent' - > { +export interface ToolCallArgsEvent extends Pick< + AGUIToolCallArgsEvent, + 'toolCallId' | 'delta' | 'timestamp' | 'rawEvent' +> { type: 'TOOL_CALL_ARGS' /** Model identifier for multi-model support */ model?: string @@ -1227,10 +1228,7 @@ void _toolCallArgsDriftCheck export interface ToolCallEndEvent< TToolName extends string = string, TInput = unknown, -> extends Pick< - AGUIToolCallEndEvent, - 'toolCallId' | 'timestamp' | 'rawEvent' - > { +> extends Pick { type: 'TOOL_CALL_END' /** Model identifier for multi-model support */ model?: string @@ -1272,11 +1270,10 @@ void _toolCallEndDriftCheck * @ag-ui/core provides: `messageId`, `toolCallId`, `content`, `role?` * TanStack AI adds: `model?` */ -export interface ToolCallResultEvent - extends Pick< - AGUIToolCallResultEvent, - 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' - > { +export interface ToolCallResultEvent extends Pick< + AGUIToolCallResultEvent, + 'messageId' | 'toolCallId' | 'content' | 'role' | 'timestamp' | 'rawEvent' +> { type: 'TOOL_CALL_RESULT' /** Model identifier for multi-model support */ model?: string @@ -1297,11 +1294,10 @@ void _toolCallResultDriftCheck * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `stepType?` */ -export interface StepStartedEvent - extends Pick< - AGUIStepStartedEvent, - 'stepName' | 'timestamp' | 'rawEvent' - > { +export interface StepStartedEvent extends Pick< + AGUIStepStartedEvent, + 'stepName' | 'timestamp' | 'rawEvent' +> { type: 'STEP_STARTED' /** Model identifier for multi-model support */ model?: string @@ -1326,11 +1322,10 @@ void _stepStartedDriftCheck * @ag-ui/core provides: `stepName` * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `delta?`, `content?` */ -export interface StepFinishedEvent - extends Pick< - AGUIStepFinishedEvent, - 'stepName' | 'timestamp' | 'rawEvent' - > { +export interface StepFinishedEvent extends Pick< + AGUIStepFinishedEvent, + 'stepName' | 'timestamp' | 'rawEvent' +> { type: 'STEP_FINISHED' /** Model identifier for multi-model support */ model?: string @@ -1368,11 +1363,10 @@ void _stepFinishedDriftCheck * Note: The `messages` field uses the @ag-ui/core Message type. * Use converters to transform to/from TanStack UIMessage format. */ -export interface MessagesSnapshotEvent - extends Pick< - AGUIMessagesSnapshotEvent, - 'messages' | 'timestamp' | 'rawEvent' - > { +export interface MessagesSnapshotEvent extends Pick< + AGUIMessagesSnapshotEvent, + 'messages' | 'timestamp' | 'rawEvent' +> { type: 'MESSAGES_SNAPSHOT' /** Model identifier for multi-model support */ model?: string @@ -1390,11 +1384,10 @@ void _messagesSnapshotDriftCheck * @ag-ui/core provides: `snapshot` (any) * TanStack AI adds: `model?`, `state?` (deprecated alias for snapshot) */ -export interface StateSnapshotEvent - extends Pick< - AGUIStateSnapshotEvent, - 'snapshot' | 'timestamp' | 'rawEvent' - > { +export interface StateSnapshotEvent extends Pick< + AGUIStateSnapshotEvent, + 'snapshot' | 'timestamp' | 'rawEvent' +> { type: 'STATE_SNAPSHOT' /** Model identifier for multi-model support */ model?: string @@ -1417,8 +1410,10 @@ void _stateSnapshotDriftCheck * @ag-ui/core provides: `delta` (any[] - JSON Patch RFC 6902) * TanStack AI adds: `model?` */ -export interface StateDeltaEvent - extends Pick { +export interface StateDeltaEvent extends Pick< + AGUIStateDeltaEvent, + 'delta' | 'timestamp' | 'rawEvent' +> { type: 'STATE_DELTA' /** Model identifier for multi-model support */ model?: string @@ -1436,8 +1431,10 @@ void _stateDeltaDriftCheck * @ag-ui/core provides: `name`, `value` * TanStack AI adds: `model?` */ -export interface CustomEvent - extends Pick { +export interface CustomEvent extends Pick< + AGUICustomEvent, + 'name' | 'value' | 'timestamp' | 'rawEvent' +> { type: 'CUSTOM' /** Model identifier for multi-model support */ model?: string @@ -1571,11 +1568,10 @@ export type StructuredOutputStream = AsyncIterable< * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningStartEvent - extends Pick< - AGUIReasoningStartEvent, - 'messageId' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningStartEvent extends Pick< + AGUIReasoningStartEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_START' /** Model identifier for multi-model support */ model?: string @@ -1593,11 +1589,10 @@ void _reasoningStartDriftCheck * @ag-ui/core provides: `messageId`, `role` ("reasoning") * TanStack AI adds: `model?` */ -export interface ReasoningMessageStartEvent - extends Pick< - AGUIReasoningMessageStartEvent, - 'messageId' | 'role' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningMessageStartEvent extends Pick< + AGUIReasoningMessageStartEvent, + 'messageId' | 'role' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_MESSAGE_START' /** Model identifier for multi-model support */ model?: string @@ -1618,11 +1613,10 @@ void _reasoningMessageStartDriftCheck * @ag-ui/core provides: `messageId`, `delta` * TanStack AI adds: `model?` */ -export interface ReasoningMessageContentEvent - extends Pick< - AGUIReasoningMessageContentEvent, - 'messageId' | 'delta' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningMessageContentEvent extends Pick< + AGUIReasoningMessageContentEvent, + 'messageId' | 'delta' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_MESSAGE_CONTENT' /** Model identifier for multi-model support */ model?: string @@ -1634,8 +1628,7 @@ type _ReasoningMessageContentDriftCheck = AssertSatisfiesAGUI< 'messageId' | 'delta' | 'timestamp' | 'rawEvent' > > -const _reasoningMessageContentDriftCheck: _ReasoningMessageContentDriftCheck = - true +const _reasoningMessageContentDriftCheck: _ReasoningMessageContentDriftCheck = true void _reasoningMessageContentDriftCheck /** @@ -1644,11 +1637,10 @@ void _reasoningMessageContentDriftCheck * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningMessageEndEvent - extends Pick< - AGUIReasoningMessageEndEvent, - 'messageId' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningMessageEndEvent extends Pick< + AGUIReasoningMessageEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_MESSAGE_END' /** Model identifier for multi-model support */ model?: string @@ -1666,11 +1658,10 @@ void _reasoningMessageEndDriftCheck * @ag-ui/core provides: `messageId` * TanStack AI adds: `model?` */ -export interface ReasoningEndEvent - extends Pick< - AGUIReasoningEndEvent, - 'messageId' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningEndEvent extends Pick< + AGUIReasoningEndEvent, + 'messageId' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_END' /** Model identifier for multi-model support */ model?: string @@ -1688,11 +1679,10 @@ void _reasoningEndDriftCheck * @ag-ui/core provides: `subtype`, `entityId`, `encryptedValue` * TanStack AI adds: `model?` */ -export interface ReasoningEncryptedValueEvent - extends Pick< - AGUIReasoningEncryptedValueEvent, - 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' - > { +export interface ReasoningEncryptedValueEvent extends Pick< + AGUIReasoningEncryptedValueEvent, + 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' +> { type: 'REASONING_ENCRYPTED_VALUE' /** Model identifier for multi-model support */ model?: string @@ -1704,8 +1694,7 @@ type _ReasoningEncryptedValueDriftCheck = AssertSatisfiesAGUI< 'subtype' | 'entityId' | 'encryptedValue' | 'timestamp' | 'rawEvent' > > -const _reasoningEncryptedValueDriftCheck: _ReasoningEncryptedValueDriftCheck = - true +const _reasoningEncryptedValueDriftCheck: _ReasoningEncryptedValueDriftCheck = true void _reasoningEncryptedValueDriftCheck // ============================================================================ From cff54ce719ea4775ae819ac32abd5f90cc95c367 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 15:24:47 +0200 Subject: [PATCH 15/20] feat(ai): narrow CustomEvent to TaggedCustomEvent even without typed tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the no-typed-tools fallback branch of \`TypedStreamChunk\` collapsed to plain \`StreamChunk\`, so \`chunk.type === 'CUSTOM'\` left \`chunk.name\` typed as \`string\` and \`chunk.value\` as \`any\` — the engine-emitted tagged variants (structured-output.start/complete, approval-requested, tool-input-available) were not narrowed. The tagged shapes don't depend on the tools array, so they belong in both branches. The fallback now returns \`Exclude | TaggedCustomEvent\` so \`chunk.name === 'structured-output.complete'\` autocompletes and narrows \`chunk.value\` to the typed payload regardless of whether the caller passes typed tools to \`chat()\`. Free-form \`emitCustomEvent\` events still flow at runtime but are intentionally absent from the type — including bare CustomEvent (\`value: any\`) would collapse the tagged variants' \`value\` back to \`any\`. Cast to \`StreamChunk\` if you need to read those. --- packages/ai/src/types.ts | 17 +++++++++++------ packages/ai/tests/chat-result-types.test.ts | 14 +++++++++++--- packages/ai/tests/type-check.test.ts | 20 ++++++++++++++------ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c3699d963..e9c663d7b 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1892,11 +1892,16 @@ export type TaggedCustomEvent = * but are excluded from the type to avoid `any` poisoning the union; cast to * `StreamChunk` if you need to read those. * - * When tools are untyped or absent, `TypedStreamChunk` falls back to plain - * `StreamChunk` — tagged custom events are NOT narrowed in that branch. - * This preserves back-compat with consumers that pass `chat()`'s stream as - * `AsyncIterable` (e.g. `toServerSentEventsResponse`). To get - * tagged narrowing, pass typed tools via `toolDefinition()`. + * When tools are untyped or absent, the tool-call events stay as plain + * `ToolCallStartEvent` / `ToolCallEndEvent` (no per-tool name narrowing), + * but `CUSTOM` events still narrow to `TaggedCustomEvent` — the tagged + * shapes the engine emits (`structured-output.start/complete`, + * `approval-requested`, `tool-input-available`) don't depend on the + * tools array, so they're available in every variant of the union. + * + * Free-form user-emitted custom events (via `emitCustomEvent`) still + * flow at runtime but are excluded from the type to avoid `any` + * poisoning the union; cast to `StreamChunk` if you need to read those. */ export type TypedStreamChunk< TTools extends ReadonlyArray> = ReadonlyArray< @@ -1914,7 +1919,7 @@ export type TypedStreamChunk< | DistributedToolCallStart | DistributedToolCallEnd | TaggedCustomEvent - : StreamChunk + : Exclude | TaggedCustomEvent // Simple streaming format for basic text completions // Converted to StreamChunk format by convertTextCompletionStream() diff --git a/packages/ai/tests/chat-result-types.test.ts b/packages/ai/tests/chat-result-types.test.ts index 2e8ad1aeb..b653a390b 100644 --- a/packages/ai/tests/chat-result-types.test.ts +++ b/packages/ai/tests/chat-result-types.test.ts @@ -14,6 +14,7 @@ import type { InferSchemaType, StreamChunk, StructuredOutputStream, + TypedStreamChunk, } from '../src/types' type Person = { name: string } @@ -88,8 +89,15 @@ describe('chat() return type', () => { }) describe('without outputSchema', () => { - it('stream: true → AsyncIterable', () => { + it('stream: true → AsyncIterable (assignable to AsyncIterable)', () => { + // With untyped tools, `TypedStreamChunk` is StreamChunk with the + // bare CUSTOM variant replaced by the discriminated TaggedCustomEvent + // union — `chunk.type === 'CUSTOM' && chunk.name === '...'` narrows + // even when chat() is called without typed tools. expectTypeOf>().toEqualTypeOf< + AsyncIterable + >() + expectTypeOf>().toMatchTypeOf< AsyncIterable >() }) @@ -100,9 +108,9 @@ describe('chat() return type', () => { >() }) - it('default stream (boolean) → AsyncIterable', () => { + it('default stream (boolean) → AsyncIterable', () => { expectTypeOf>().toEqualTypeOf< - AsyncIterable + AsyncIterable >() }) }) diff --git a/packages/ai/tests/type-check.test.ts b/packages/ai/tests/type-check.test.ts index a89e5c4eb..43270d54e 100644 --- a/packages/ai/tests/type-check.test.ts +++ b/packages/ai/tests/type-check.test.ts @@ -685,14 +685,22 @@ describe('TypedStreamChunk tagged custom event narrowing', () => { }>() }) - it('should keep bare CustomEvent in the no-typed-tools fallback for back-compat', () => { - // Without typed tools, TypedStreamChunk collapses to plain StreamChunk — - // tagged narrowing requires the typed-tools branch to avoid breaking - // existing `AsyncIterable` consumers. + it('should narrow CustomEvent to the TaggedCustomEvent union even without typed tools', () => { + // The tagged shapes the engine emits (`structured-output.start`, + // `structured-output.complete`, `approval-requested`, + // `tool-input-available`) don't depend on the tools array, so they + // narrow in the fallback branch too — `chunk.type === 'CUSTOM' && + // chunk.name === '...'` works whether the caller passed typed tools + // or not. type Chunk = TypedStreamChunk type Custom = Extract - // The bare CustomEvent's `name` stays `string`. - expectTypeOf().toEqualTypeOf() + // `name` is the discriminated union of tagged literals. + expectTypeOf().toEqualTypeOf< + | 'structured-output.start' + | 'structured-output.complete' + | 'approval-requested' + | 'tool-input-available' + >() }) it('should not poison `value` to any across the CUSTOM union', () => { From de5f152c744729b0fdfe58037383e811e2809585 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 15:54:44 +0200 Subject: [PATCH 16/20] docs+feat: update model refs to gpt-5.2 and preserve tool name literals in mergeAgentTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated changes batched: 1. **Docs/JSDoc: gpt-4o → gpt-5.2** Replace plain \`gpt-4o\` mentions across documentation pages and source JSDoc examples with \`gpt-5.2\`. The specialized variants (\`gpt-4o-mini\`, \`gpt-4o-audio-preview\`, \`gpt-4o-realtime-preview\`, \`gpt-4o-transcribe\`, etc.) are real models with no gpt-5.2 counterpart, so they are intentionally left as-is. Updating the JSDoc lets \`pnpm generate-docs\` regenerate consistent reference pages. 2. **\`mergeAgentTools\` preserves typed tool name literals** The signature widened to \`Array\`, which made \`HasTypedTools\` resolve to \`false\` and collapsed \`TypedStreamChunk\`'s \`DistributedToolCall*\` discriminated union — \`chunk.toolCallName\` stayed \`string\` with no per-tool narrowing on the consumer side. Make the function generic over the server tools tuple (\`const TServerTools extends ReadonlyArray>\`) and return that tuple. The runtime array still carries both server and client tools — the return type narrows to just the typed server tuple so that downstream \`chat({ tools })\` can discriminate \`chunk.toolCallName === 'getGuitars'\` against the registered names. Client tool calls still flow at runtime through the existing \`ClientToolRequest\` path; they just appear as the bare \`ToolCallStartEvent\` shape after narrowing rather than as typed literals. In \`examples/ts-react-chat/src/routes/api.tanchat.ts\`, drop the \`Object.values(mergedTools)\` wrapper that was discarding the tuple typing and pass \`mergedTools\` straight through to \`chat()\`. --- docs/adapters/openai.md | 2 +- docs/adapters/openrouter.md | 2 +- docs/advanced/debug-logging.md | 10 +-- docs/advanced/extend-adapter.md | 4 +- docs/advanced/middleware.md | 6 +- docs/advanced/otel.md | 8 +-- docs/api/ai-preact.md | 2 +- docs/api/ai-react.md | 2 +- docs/api/ai-solid.md | 2 +- docs/api/ai-svelte.md | 2 +- docs/api/ai.md | 4 +- docs/chat/streaming.md | 8 +-- docs/code-mode/code-mode-with-skills.md | 4 +- docs/code-mode/code-mode.md | 2 +- docs/community-adapters/cencori.md | 12 ++-- docs/community-adapters/cloudflare.md | 8 +-- docs/comparison/vercel-ai-sdk.md | 4 +- docs/getting-started/quick-start-server.md | 8 +-- docs/getting-started/quick-start-svelte.md | 2 +- docs/getting-started/quick-start-vue.md | 2 +- docs/migration/ag-ui-compliance.md | 14 ++-- docs/migration/migration-from-vercel-ai.md | 72 +++++++++---------- docs/protocol/chunk-definitions.md | 8 +-- docs/reference/functions/chat.md | 8 +-- docs/reference/functions/combineStrategies.md | 2 +- docs/reference/functions/extendAdapter.md | 2 +- docs/reference/functions/maxIterations.md | 2 +- docs/reference/functions/streamToText.md | 2 +- docs/reference/functions/toHttpResponse.md | 2 +- docs/reference/functions/toHttpStream.md | 2 +- .../functions/toServerSentEventsResponse.md | 2 +- docs/reference/functions/untilFinishReason.md | 2 +- .../interfaces/ChatMiddlewareContext.md | 2 +- docs/reference/interfaces/SummarizeAdapter.md | 2 +- docs/reference/interfaces/TextAdapter.md | 2 +- .../type-aliases/TypedStreamChunk.md | 2 +- docs/tools/lazy-tool-discovery.md | 4 +- .../ts-react-chat/src/routes/api.tanchat.ts | 3 +- packages/ai-client/src/connection-adapters.ts | 4 +- .../src/code-mode-with-skills.ts | 2 +- packages/ai-openai/src/adapters/summarize.ts | 4 +- packages/ai-openai/src/adapters/text.ts | 10 +-- .../src/text/text-provider-options.ts | 2 +- packages/ai/src/activities/chat/adapter.ts | 2 +- .../activities/chat/agent-loop-strategies.ts | 6 +- packages/ai/src/activities/chat/index.ts | 16 ++--- .../src/activities/chat/middleware/types.ts | 2 +- .../ai/src/activities/summarize/adapter.ts | 2 +- packages/ai/src/extend-adapter.ts | 2 +- packages/ai/src/stream-to-response.ts | 8 +-- packages/ai/src/utilities/chat-params.ts | 19 +++-- 51 files changed, 158 insertions(+), 148 deletions(-) diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index e780a9a0e..def33f30b 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -6,7 +6,7 @@ description: "Use OpenAI models with TanStack AI — GPT-4o, GPT-5, DALL-E image keywords: - tanstack ai - openai - - gpt-4o + - gpt-5.2 - gpt-5 - dall-e - whisper diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 856ac1065..7a60e8005 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -126,7 +126,7 @@ const stream = chat({ messages, modelOptions: { models: [ - "openai/gpt-4o", + "openai/gpt-5.2", "anthropic/claude-3.5-sonnet", "google/gemini-pro", ], diff --git a/docs/advanced/debug-logging.md b/docs/advanced/debug-logging.md index 3ebec740b..7f4b25ee7 100644 --- a/docs/advanced/debug-logging.md +++ b/docs/advanced/debug-logging.md @@ -27,7 +27,7 @@ import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: [{ role: "user", content: "Hello" }], debug: true, }); @@ -36,7 +36,7 @@ const stream = chat({ Every internal event now prints to the console with a `[tanstack-ai:]` prefix: ``` -[tanstack-ai:request] activity=chat provider=openai model=gpt-4o messages=1 tools=0 stream=true +[tanstack-ai:request] activity=chat provider=openai model=gpt-5.2 messages=1 tools=0 stream=true [tanstack-ai:agentLoop] run started [tanstack-ai:provider] provider=openai type=response.output_text.delta [tanstack-ai:output] type=TEXT_MESSAGE_CONTENT @@ -49,7 +49,7 @@ Pass a `DebugConfig` object instead of `true`. Every unspecified category defaul ```typescript chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { middleware: false }, // everything except middleware }); @@ -59,7 +59,7 @@ If you want to see ONLY a specific set of categories, set the rest to `false` ex ```typescript chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { provider: true, @@ -91,7 +91,7 @@ const logger: Logger = { }; chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, debug: { logger }, // all categories on, piped to pino }); diff --git a/docs/advanced/extend-adapter.md b/docs/advanced/extend-adapter.md index 145432b21..1662762fe 100644 --- a/docs/advanced/extend-adapter.md +++ b/docs/advanced/extend-adapter.md @@ -35,7 +35,7 @@ const myOpenai = extendAdapter(openaiText, [ ]) // Use with original models - full type inference preserved -const gpt4Adapter = myOpenai('gpt-4o') +const gpt4Adapter = myOpenai('gpt-5.2') // Use with custom models - your custom types are applied const customAdapter = myOpenai('my-fine-tuned-gpt4') @@ -109,7 +109,7 @@ import { openaiText } from '@tanstack/ai-openai' const myOpenai = extendAdapter(openaiText, [createModel('custom-model', ['text'])]) // ✅ Original models work with their original types -const a1 = myOpenai('gpt-4o') +const a1 = myOpenai('gpt-5.2') // ✅ Custom models work with your defined types const a2 = myOpenai('custom-model') diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 3d7a7b02f..e5cfdc1d4 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -43,7 +43,7 @@ const logger: ChatMiddleware = { }; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: [{ role: "user", content: "Hello" }], middleware: [logger], }); @@ -444,7 +444,7 @@ Middleware execute in array order. The ordering matters for hooks that pipe or s ```typescript const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, middleware: [authMiddleware, loggingMiddleware, cachingMiddleware], }); @@ -474,7 +474,7 @@ import { chat } from "@tanstack/ai"; import { toolCacheMiddleware } from "@tanstack/ai/middlewares"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [weatherTool, stockTool], middleware: [ diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md index a8c14673b..002d08fb1 100644 --- a/docs/advanced/otel.md +++ b/docs/advanced/otel.md @@ -38,7 +38,7 @@ const otel = otelMiddleware({ }) const result = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'hi' }], middleware: [otel], stream: false, @@ -50,11 +50,11 @@ const result = await chat({ ### Spans ```text -chat gpt-4o (root, kind: INTERNAL) -├── chat gpt-4o #0 (iteration, kind: CLIENT) +chat gpt-5.2 (root, kind: INTERNAL) +├── chat gpt-5.2 #0 (iteration, kind: CLIENT) │ ├── execute_tool get_weather │ └── execute_tool get_time -└── chat gpt-4o #1 (iteration, kind: CLIENT) +└── chat gpt-5.2 #1 (iteration, kind: CLIENT) ``` Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the same chat are easy to pick apart in trace viewers. diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 17dd706eb..afd687a64 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -66,7 +66,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index ac10e1667..ed7e607aa 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -66,7 +66,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 8bf22bf7a..36c162617 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -67,7 +67,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 770e3d90f..f4f866762 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -62,7 +62,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted -- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) +- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.2' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire - `live?` - Enable live subscription mode (subscribes on creation) - `onResponse?` - Callback when response is received diff --git a/docs/api/ai.md b/docs/api/ai.md index 0f50d74cd..9a02359f3 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -205,7 +205,7 @@ import { openaiText } from "@tanstack/ai-openai"; export async function POST(req: Request) { const params = await chatParamsFromRequest(req); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: params.messages, tools: serverTools, }); @@ -246,7 +246,7 @@ import { chat, chatParamsFromRequest, mergeAgentTools } from "@tanstack/ai"; const params = await chatParamsFromRequest(req); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), }); diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index d1233e5aa..018310391 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-4o"), + adapter: openaiText("gpt-5.2"), messages, }); @@ -46,7 +46,7 @@ export async function POST(request: Request) { const { messages } = await request.json(); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, }); @@ -106,7 +106,7 @@ const weatherTool = toolDefinition({ }); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [weatherTool], }); @@ -131,7 +131,7 @@ const searchTool = toolDefinition({ }); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [weatherTool, searchTool], }); diff --git a/docs/code-mode/code-mode-with-skills.md b/docs/code-mode/code-mode-with-skills.md index a77a0b736..1201d8999 100644 --- a/docs/code-mode/code-mode-with-skills.md +++ b/docs/code-mode/code-mode-with-skills.md @@ -101,7 +101,7 @@ const { toolsRegistry, systemPrompt, selectedSkills } = await codeModeWithSkills }) const stream = chat({ - adapter: openaiText('gpt-4o'), // strong model for reasoning + adapter: openaiText('gpt-5.2'), // strong model for reasoning toolRegistry: toolsRegistry, messages, systemPrompts: ['You are a helpful assistant.', systemPrompt], @@ -189,7 +189,7 @@ const skillsPrompt = createSkillsSystemPrompt({ // 5. Assemble and call chat() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), tools: [codeModeTool, ...managementTools, ...skillTools], messages, systemPrompts: [BASE_PROMPT, codeModePrompt, skillsPrompt], diff --git a/docs/code-mode/code-mode.md b/docs/code-mode/code-mode.md index 5cad9fa1d..75b4cbec0 100644 --- a/docs/code-mode/code-mode.md +++ b/docs/code-mode/code-mode.md @@ -100,7 +100,7 @@ import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const result = await chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), systemPrompts: [ "You are a helpful weather assistant.", systemPrompt, diff --git a/docs/community-adapters/cencori.md b/docs/community-adapters/cencori.md index f1de31217..c3a89e740 100644 --- a/docs/community-adapters/cencori.md +++ b/docs/community-adapters/cencori.md @@ -27,7 +27,7 @@ npm install @cencori/ai-sdk import { chat } from "@tanstack/ai"; import { cencori } from "@cencori/ai-sdk/tanstack"; -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); for await (const chunk of chat({ adapter, @@ -49,7 +49,7 @@ const cencori = createCencori({ baseUrl: "https://cencori.com", // Optional }); -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); ``` ## Streaming @@ -79,7 +79,7 @@ for await (const chunk of chat({ import { chat } from "@tanstack/ai"; import { cencori } from "@cencori/ai-sdk/tanstack"; -const adapter = cencori("gpt-4o"); +const adapter = cencori("gpt-5.2"); for await (const chunk of chat({ adapter, @@ -110,7 +110,7 @@ Switch between providers with a single parameter: import { cencori } from "@cencori/ai-sdk/tanstack"; // OpenAI -const openai = cencori("gpt-4o"); +const openai = cencori("gpt-5.2"); // Anthropic const anthropic = cencori("claude-3-5-sonnet"); @@ -131,7 +131,7 @@ All responses use the same unified format regardless of provider. | Provider | Models | |----------|--------| -| OpenAI | `gpt-5`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o1` | +| OpenAI | `gpt-5`, `gpt-5.2`, `gpt-4o-mini`, `o3`, `o1` | | Anthropic | `claude-opus-4`, `claude-sonnet-4`, `claude-3-5-sonnet` | | Google | `gemini-3-pro`, `gemini-2.5-flash`, `gemini-2.0-flash` | | xAI | `grok-4`, `grok-3` | @@ -168,7 +168,7 @@ Creates a Cencori adapter using environment variables. **Parameters:** -- `model` - Model name (e.g., `"gpt-4o"`, `"claude-3-5-sonnet"`, `"gemini-2.5-flash"`) +- `model` - Model name (e.g., `"gpt-5.2"`, `"claude-3-5-sonnet"`, `"gemini-2.5-flash"`) **Returns:** A Cencori TanStack AI adapter instance. diff --git a/docs/community-adapters/cloudflare.md b/docs/community-adapters/cloudflare.md index a34e54860..c7b3a0f99 100644 --- a/docs/community-adapters/cloudflare.md +++ b/docs/community-adapters/cloudflare.md @@ -202,7 +202,7 @@ import { createOpenRouterChat, } from "@cloudflare/tanstack-ai"; -const openai = createOpenAiChat("gpt-4o", { +const openai = createOpenAiChat("gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), }); @@ -214,7 +214,7 @@ const grok = createGrokChat("grok-4", { binding: env.AI.gateway("my-gateway-id"), }); -const openrouter = createOpenRouterChat("openai/gpt-4o", { +const openrouter = createOpenRouterChat("openai/gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), }); ``` @@ -224,7 +224,7 @@ Or use credentials for non-Worker environments: ```typescript import { createOpenAiChat } from "@cloudflare/tanstack-ai"; -const adapter = createOpenAiChat("gpt-4o", { +const adapter = createOpenAiChat("gpt-5.2", { accountId: "your-account-id", gatewayId: "your-gateway-id", cfApiKey: "your-cf-api-key", @@ -237,7 +237,7 @@ const adapter = createOpenAiChat("gpt-4o", { Both binding and credentials modes support cache configuration: ```typescript -const adapter = createOpenAiChat("gpt-4o", { +const adapter = createOpenAiChat("gpt-5.2", { binding: env.AI.gateway("my-gateway-id"), skipCache: false, cacheTtl: 3600, diff --git a/docs/comparison/vercel-ai-sdk.md b/docs/comparison/vercel-ai-sdk.md index 415a4731c..904aa3efd 100644 --- a/docs/comparison/vercel-ai-sdk.md +++ b/docs/comparison/vercel-ai-sdk.md @@ -462,7 +462,7 @@ const getWeatherClient = getWeather.client(async ({ city }) => { import { generateText } from 'ai' const result = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), tools: { getWeather: { description: 'Get current weather for a location', @@ -504,7 +504,7 @@ const stream = chat({ import { generateText } from 'ai' const result = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), tools, maxSteps: 10, prompt: 'Help me plan a trip.', diff --git a/docs/getting-started/quick-start-server.md b/docs/getting-started/quick-start-server.md index 6ef617671..c46d1d6c2 100644 --- a/docs/getting-started/quick-start-server.md +++ b/docs/getting-started/quick-start-server.md @@ -37,7 +37,7 @@ import { chat, streamToText } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }], }) @@ -63,7 +63,7 @@ app.post('/api/chat', async (req, res) => { const { messages } = req.body const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) @@ -112,7 +112,7 @@ const getWeather = toolDefinition({ }) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Weather in Tokyo?' }], tools: [getWeather], }) @@ -134,7 +134,7 @@ import { chat, toHttpResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) const response = toHttpResponse(stream) diff --git a/docs/getting-started/quick-start-svelte.md b/docs/getting-started/quick-start-svelte.md index 39c15189e..57e56ec4a 100644 --- a/docs/getting-started/quick-start-svelte.md +++ b/docs/getting-started/quick-start-svelte.md @@ -52,7 +52,7 @@ export const POST: RequestHandler = async ({ request }) => { // `chat()` uses the AG-UI `threadId` for devtools correlation // when available — no need to plumb `conversationId` manually. const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: body.messages, }) diff --git a/docs/getting-started/quick-start-vue.md b/docs/getting-started/quick-start-vue.md index 38dd22d49..6a4c07e74 100644 --- a/docs/getting-started/quick-start-vue.md +++ b/docs/getting-started/quick-start-vue.md @@ -52,7 +52,7 @@ app.post('/api/chat', async (req, res) => { // `chat()` uses the AG-UI `threadId` for devtools correlation // when available — no need to plumb `conversationId` manually. const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 73e97a445..b237c28c0 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -115,7 +115,7 @@ export async function POST(req: Request) { // const provider = body.forwardedProps?.provider const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: body.messages, // AG-UI mixed shape — works directly tools: serverTools, }) @@ -144,7 +144,7 @@ import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { const params = await chatParamsFromRequest(req) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: serverTools, }) @@ -176,7 +176,7 @@ import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { const params = await chatParamsFromRequest(req) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools }) @@ -195,7 +195,7 @@ Skip this section if you're on Tier 1. `forwardedProps` is only surfaced when yo ```ts // 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), ...params, ...params.forwardedProps, }) @@ -206,7 +206,7 @@ Always destructure the specific fields you intend to forward: ```ts // ✅ SAFE — explicit allowlist chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), temperature: @@ -232,13 +232,13 @@ The `body` option on `useChat` / `ChatClient` is now `@deprecated` in favor of ` // Before — still works, but deprecated useChat({ connection: fetchServerSentEvents('/api/chat'), - body: { provider: 'openai', model: 'gpt-4o' }, + body: { provider: 'openai', model: 'gpt-5.2' }, }) // After — recommended useChat({ connection: fetchServerSentEvents('/api/chat'), - forwardedProps: { provider: 'openai', model: 'gpt-4o' }, + forwardedProps: { provider: 'openai', model: 'gpt-5.2' }, }) ``` diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index f83cd6e0b..cee06f179 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -77,7 +77,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages: convertToModelMessages(messages), }) @@ -96,7 +96,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, }) @@ -111,7 +111,7 @@ export async function POST(request: Request) { | `streamText()` | `chat()` | Main text generation function | | `generateText()` | `chat({ stream: false })` | Returns `Promise` | | `generateObject()` / `streamObject()` / `Output.object()` | `chat({ outputSchema })` | Returns `Promise` — see [Structured Output](#structured-output) | -| `openai('gpt-4o')` | `openaiText('gpt-4o')` | Activity-specific adapters | +| `openai('gpt-5.2')` | `openaiText('gpt-5.2')` | Activity-specific adapters | | `result.toUIMessageStreamResponse()` / `.toTextStreamResponse()` | `toServerSentEventsResponse(stream)` / `toHttpResponse(stream)` | Separate utility functions | | `model` parameter | `adapter` parameter | Model baked into adapter | @@ -121,7 +121,7 @@ Options accepted by `streamText` as of AI SDK v6, and where each lives in TanSta | `streamText` option | `chat()` equivalent | Notes | |--------------------|--------------------|-------| -| `model: openai('gpt-4o')` | `adapter: openaiText('gpt-4o')` | Activity-specific adapters | +| `model: openai('gpt-5.2')` | `adapter: openaiText('gpt-5.2')` | Activity-specific adapters | | `prompt: 'Hello'` | `messages: [{ role: 'user', content: 'Hello' }]` | TanStack is messages-only | | `messages` | `messages` | Same concept; content parts differ (see [Multimodal](#multimodal-content)) | | `system: 'You are…'` | `systemPrompts: ['You are…']` | Root-level `string[]` | @@ -185,7 +185,7 @@ TanStack AI promotes a small, cross-provider set of options to the top level (`t ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, temperature: 0.7, maxOutputTokens: 1000, // (v5+); `maxTokens` on v4 @@ -208,12 +208,12 @@ const result = streamText({ ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, temperature: 0.7, maxTokens: 1000, topP: 0.9, - // Everything else lives under modelOptions — typed for gpt-4o specifically + // Everything else lives under modelOptions — typed for gpt-5.2 specifically modelOptions: { topK: 40, presencePenalty: 0.1, @@ -225,7 +225,7 @@ const stream = chat({ }) ``` -> Autocomplete in `modelOptions` reflects the **exact** adapter and model you passed. Swap `openaiText('gpt-4o')` for `anthropicText('claude-sonnet-4-5')` and the shape changes to match Anthropic's options. +> Autocomplete in `modelOptions` reflects the **exact** adapter and model you passed. Swap `openaiText('gpt-5.2')` for `anthropicText('claude-sonnet-4-5')` and the shape changes to match Anthropic's options. ### System Messages @@ -235,7 +235,7 @@ TanStack AI accepts system prompts at the **root level** via the `systemPrompts` ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), system: 'You are a helpful assistant.', messages, }) @@ -245,7 +245,7 @@ const result = streamText({ ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: ['You are a helpful assistant.'], messages, }) @@ -255,7 +255,7 @@ Multiple system prompts are supported — useful for composing persona, policies ```typescript const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: [ 'You are a helpful assistant.', 'Respond in concise, plain English.', @@ -495,7 +495,7 @@ import { openai } from '@ai-sdk/openai' import { z } from 'zod' const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, tools: { getWeather: tool({ @@ -540,7 +540,7 @@ const getWeather = getWeatherDef.server(async ({ location }) => { // Step 3: Use in chat const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [getWeather], }) @@ -689,7 +689,7 @@ import { openai } from '@ai-sdk/openai' import { z } from 'zod' const { output } = await generateText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), prompt: 'Extract the user profile from this bio…', output: Output.object({ schema: z.object({ @@ -710,7 +710,7 @@ import { openaiText } from '@tanstack/ai-openai' import { z } from 'zod' const profile = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Extract the user profile from this bio…' }], outputSchema: z.object({ name: z.string(), @@ -749,7 +749,7 @@ import { streamText, stepCountIs } from 'ai' import { openai } from '@ai-sdk/openai' const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, tools: { getWeather }, stopWhen: stepCountIs(10), @@ -773,7 +773,7 @@ import { import { openaiText } from '@tanstack/ai-openai' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [getWeather], agentLoopStrategy: combineStrategies([ @@ -802,7 +802,7 @@ const stream = chat({ ```typescript // Stage 1: heavy model for the opening turn const firstPass = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, agentLoopStrategy: maxIterations(1), stream: false, @@ -843,7 +843,7 @@ const loggingMiddleware = { } const wrapped = wrapLanguageModel({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), middleware: [loggingMiddleware], }) @@ -866,7 +866,7 @@ const loggingMiddleware: ChatMiddleware = { } const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, middleware: [loggingMiddleware], context: { userId: 'u_123' }, // passed to every hook as ctx.context @@ -902,7 +902,7 @@ import { chat } from '@tanstack/ai' import { toolCacheMiddleware } from '@tanstack/ai/middlewares' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, tools: [searchDocs, getWeather], middleware: [ @@ -948,7 +948,7 @@ TanStack AI uses activity-specific adapters for optimal tree-shaking. import { openai } from '@ai-sdk/openai' // Chat -streamText({ model: openai('gpt-4o'), ... }) +streamText({ model: openai('gpt-5.2'), ... }) // Embeddings embed({ model: openai.embedding('text-embedding-3-small'), ... }) @@ -963,7 +963,7 @@ generateImage({ model: openai.image('dall-e-3'), ... }) import { openaiText, openaiImage, openaiSpeech } from '@tanstack/ai-openai' // Chat -chat({ adapter: openaiText('gpt-4o'), ... }) +chat({ adapter: openaiText('gpt-5.2'), ... }) // Image generation generateImage({ adapter: openaiImage('dall-e-3'), ... }) @@ -1035,7 +1035,7 @@ import { toHttpStream, } from '@tanstack/ai' -const stream = chat({ adapter: openaiText('gpt-4o'), messages }) +const stream = chat({ adapter: openaiText('gpt-5.2'), messages }) // SSE response (recommended; pairs with fetchServerSentEvents on the client). // Both response helpers accept a ResponseInit with an optional abortController @@ -1091,7 +1091,7 @@ useChat({ ```typescript const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages, abortSignal: controller.signal, }) @@ -1105,7 +1105,7 @@ TanStack AI takes an `AbortController` (not a bare signal) so helpers like `toSe const abortController = new AbortController() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages, abortController, }) @@ -1150,7 +1150,7 @@ TanStack AI also lets you hook into the **server-side** stream lifecycle by subs ```typescript streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), messages: [ { role: 'user', @@ -1167,7 +1167,7 @@ streamText({ ```typescript chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [ { role: 'user', @@ -1190,7 +1190,7 @@ chat({ ```typescript const providers = { - openai: openai('gpt-4o'), + openai: openai('gpt-5.2'), anthropic: anthropic('claude-sonnet-4-5-20250514'), } @@ -1204,7 +1204,7 @@ streamText({ ```typescript const adapters = { - openai: () => openaiText('gpt-4o'), + openai: () => openaiText('gpt-5.2'), anthropic: () => anthropicText('claude-sonnet-4-5-20250514'), } @@ -1242,13 +1242,13 @@ type ChatMessages = InferChatMessages ### Per-Model Type Safety ```typescript -const adapter = openaiText('gpt-4o') +const adapter = openaiText('gpt-5.2') chat({ adapter, messages, modelOptions: { - // TypeScript autocompletes options specific to gpt-4o + // TypeScript autocompletes options specific to gpt-5.2 responseFormat: { type: 'json_object' }, logitBias: { '123': 1.0 }, }, @@ -1264,7 +1264,7 @@ import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' const text = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Summarize TanStack AI in one sentence.' }], stream: false, }) @@ -1276,7 +1276,7 @@ If you already have a stream for another reason, `streamToText(stream)` collects ```typescript import { chat, streamToText } from '@tanstack/ai' -const stream = chat({ adapter: openaiText('gpt-4o'), messages }) +const stream = chat({ adapter: openaiText('gpt-5.2'), messages }) const text = await streamToText(stream) ``` @@ -1324,7 +1324,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const result = streamText({ - model: openai('gpt-4o'), + model: openai('gpt-5.2'), system: 'You are a helpful assistant.', messages: convertToModelMessages(messages), temperature: 0.7, @@ -1400,7 +1400,7 @@ export async function POST(request: Request) { const { messages } = await request.json() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), systemPrompts: ['You are a helpful assistant.'], messages, temperature: 0.7, diff --git a/docs/protocol/chunk-definitions.md b/docs/protocol/chunk-definitions.md index 3b24b9207..23632070a 100644 --- a/docs/protocol/chunk-definitions.md +++ b/docs/protocol/chunk-definitions.md @@ -70,7 +70,7 @@ interface RunStartedEvent extends BaseAGUIEvent { { "type": "RUN_STARTED", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890 } ``` @@ -99,7 +99,7 @@ interface RunFinishedEvent extends BaseAGUIEvent { { "type": "RUN_FINISHED", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567900, "finishReason": "stop", "usage": { @@ -132,7 +132,7 @@ interface RunErrorEvent extends BaseAGUIEvent { { "type": "RUN_ERROR", "runId": "run_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890, "error": { "message": "Rate limit exceeded", @@ -175,7 +175,7 @@ interface TextMessageContentEvent extends BaseAGUIEvent { { "type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_abc123", - "model": "gpt-4o", + "model": "gpt-5.2", "timestamp": 1701234567890, "delta": "Hello", "content": "Hello" diff --git a/docs/reference/functions/chat.md b/docs/reference/functions/chat.md index 2fa5295f1..79a844a36 100644 --- a/docs/reference/functions/chat.md +++ b/docs/reference/functions/chat.md @@ -50,7 +50,7 @@ import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' for await (const chunk of chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'What is the weather?' }], tools: [weatherTool] })) { @@ -62,7 +62,7 @@ for await (const chunk of chat({ ```ts for await (const chunk of chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }] })) { console.log(chunk) @@ -71,7 +71,7 @@ for await (const chunk of chat({ ```ts const text = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Hello!' }], stream: false }) @@ -82,7 +82,7 @@ const text = await chat({ import { z } from 'zod' const result = await chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Research and summarize the topic' }], tools: [researchTool, analyzeTool], outputSchema: z.object({ diff --git a/docs/reference/functions/combineStrategies.md b/docs/reference/functions/combineStrategies.md index 9cf8ab0e7..041958650 100644 --- a/docs/reference/functions/combineStrategies.md +++ b/docs/reference/functions/combineStrategies.md @@ -33,7 +33,7 @@ AgentLoopStrategy that continues only if all strategies return true ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: combineStrategies([ diff --git a/docs/reference/functions/extendAdapter.md b/docs/reference/functions/extendAdapter.md index fe0f38ebb..95c2ffe2f 100644 --- a/docs/reference/functions/extendAdapter.md +++ b/docs/reference/functions/extendAdapter.md @@ -79,7 +79,7 @@ const customModels = [ const myOpenai = extendAdapter(openaiText, customModels) // Use with original models - full type inference preserved -const gpt4 = myOpenai('gpt-4o') +const gpt4 = myOpenai('gpt-5.2') // Use with custom models const custom = myOpenai('my-fine-tuned-gpt4') diff --git a/docs/reference/functions/maxIterations.md b/docs/reference/functions/maxIterations.md index 4348dd42e..6f55ca8c5 100644 --- a/docs/reference/functions/maxIterations.md +++ b/docs/reference/functions/maxIterations.md @@ -32,7 +32,7 @@ AgentLoopStrategy that stops after max iterations ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: maxIterations(3), // Max 3 iterations diff --git a/docs/reference/functions/streamToText.md b/docs/reference/functions/streamToText.md index dab52a6a4..c6f686466 100644 --- a/docs/reference/functions/streamToText.md +++ b/docs/reference/functions/streamToText.md @@ -35,7 +35,7 @@ Promise - The accumulated text content ```typescript const stream = chat({ adapter: openaiText(), - model: 'gpt-4o', + model: 'gpt-5.2', messages: [{ role: 'user', content: 'Hello!' }] }); const text = await streamToText(stream); diff --git a/docs/reference/functions/toHttpResponse.md b/docs/reference/functions/toHttpResponse.md index 44c713caf..4a86ac8a3 100644 --- a/docs/reference/functions/toHttpResponse.md +++ b/docs/reference/functions/toHttpResponse.md @@ -42,6 +42,6 @@ Response in HTTP stream format (newline-delimited JSON) ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); return toHttpResponse(stream, { abortController }); ``` diff --git a/docs/reference/functions/toHttpStream.md b/docs/reference/functions/toHttpStream.md index f2f343c2a..5c1194a90 100644 --- a/docs/reference/functions/toHttpStream.md +++ b/docs/reference/functions/toHttpStream.md @@ -42,7 +42,7 @@ ReadableStream in HTTP stream format (newline-delimited JSON) ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); const readableStream = toHttpStream(stream); // Use with Response for HTTP streaming (not SSE) return new Response(readableStream, { diff --git a/docs/reference/functions/toServerSentEventsResponse.md b/docs/reference/functions/toServerSentEventsResponse.md index 482295fdb..593970462 100644 --- a/docs/reference/functions/toServerSentEventsResponse.md +++ b/docs/reference/functions/toServerSentEventsResponse.md @@ -41,6 +41,6 @@ Response in Server-Sent Events format ## Example ```typescript -const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); +const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); return toServerSentEventsResponse(stream, { abortController }); ``` diff --git a/docs/reference/functions/untilFinishReason.md b/docs/reference/functions/untilFinishReason.md index 05fd875be..f35dd8d05 100644 --- a/docs/reference/functions/untilFinishReason.md +++ b/docs/reference/functions/untilFinishReason.md @@ -32,7 +32,7 @@ AgentLoopStrategy that stops on specific finish reasons ```typescript const stream = chat({ adapter: openaiText(), - model: "gpt-4o", + model: "gpt-5.2", messages: [...], tools: [weatherTool], agentLoopStrategy: untilFinishReason(["stop", "length"]), diff --git a/docs/reference/interfaces/ChatMiddlewareContext.md b/docs/reference/interfaces/ChatMiddlewareContext.md index bc289a593..c25e80628 100644 --- a/docs/reference/interfaces/ChatMiddlewareContext.md +++ b/docs/reference/interfaces/ChatMiddlewareContext.md @@ -203,7 +203,7 @@ model: string; Defined in: [packages/ai/src/activities/chat/middleware/types.ts:78](https://github.com/TanStack/ai/blob/main/packages/ai/src/activities/chat/middleware/types.ts#L78) -Model identifier (e.g., 'gpt-4o') +Model identifier (e.g., 'gpt-5.2') *** diff --git a/docs/reference/interfaces/SummarizeAdapter.md b/docs/reference/interfaces/SummarizeAdapter.md index 1ea03b8ac..d74d0d1ee 100644 --- a/docs/reference/interfaces/SummarizeAdapter.md +++ b/docs/reference/interfaces/SummarizeAdapter.md @@ -13,7 +13,7 @@ An adapter is created by a provider function: `provider('model')` → `adapter` All type resolution happens at the provider call site, not in this interface. Generic parameters: -- TModel: The specific model name (e.g., 'gpt-4o') +- TModel: The specific model name (e.g., 'gpt-5.2') - TProviderOptions: Provider-specific options (already resolved) ## Type Parameters diff --git a/docs/reference/interfaces/TextAdapter.md b/docs/reference/interfaces/TextAdapter.md index 4b507e780..044e95419 100644 --- a/docs/reference/interfaces/TextAdapter.md +++ b/docs/reference/interfaces/TextAdapter.md @@ -13,7 +13,7 @@ An adapter is created by a provider function: `provider('model')` → `adapter` All type resolution happens at the provider call site, not in this interface. Generic parameters: -- TModel: The specific model name (e.g., 'gpt-4o') +- TModel: The specific model name (e.g., 'gpt-5.2') - TProviderOptions: Provider-specific options for this model (already resolved) - TInputModalities: Supported input modalities for this model (already resolved) - TMessageMetadata: Metadata types for content parts (already resolved) diff --git a/docs/reference/type-aliases/TypedStreamChunk.md b/docs/reference/type-aliases/TypedStreamChunk.md index 9455f34dc..bbd43709d 100644 --- a/docs/reference/type-aliases/TypedStreamChunk.md +++ b/docs/reference/type-aliases/TypedStreamChunk.md @@ -55,7 +55,7 @@ const searchTool = toolDefinition({ // Inferred from `chat()` — typed tool call events plus tagged CUSTOM events const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [weatherTool, searchTool], }); diff --git a/docs/tools/lazy-tool-discovery.md b/docs/tools/lazy-tool-discovery.md index 70625dfbc..75375344b 100644 --- a/docs/tools/lazy-tool-discovery.md +++ b/docs/tools/lazy-tool-discovery.md @@ -82,7 +82,7 @@ import { chat, toServerSentEventsResponse } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [ getProducts, // Normal tool — sent to LLM immediately @@ -208,7 +208,7 @@ export async function POST(request: Request) { const { messages } = await request.json(); const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.2"), messages, tools: [getProducts, compareProducts, calculateFinancing], agentLoopStrategy: maxIterations(20), diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 8349399c8..ad158be92 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -230,7 +230,7 @@ export const Route = createFileRoute('/api/tanchat')({ const stream = chat({ ...options, - tools: Object.values(mergedTools), + tools: mergedTools, middleware: [loggingMiddleware], systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), @@ -239,6 +239,7 @@ export const Route = createFileRoute('/api/tanchat')({ runId: params.runId, abortController, }) + return toServerSentEventsResponse(stream, { abortController }) } catch (error: any) { console.error('[API Route] Error in chat request:', { diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 44ad11bff..ef0c10241 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -410,7 +410,7 @@ export interface FetchConnectionOptions { * const connection = fetchServerSentEvents('/api/chat', async () => ({ * body: { * provider: 'openai', - * model: 'gpt-4o', + * model: 'gpt-5.2', * } * })); * ``` @@ -516,7 +516,7 @@ export function fetchServerSentEvents( * const connection = fetchHttpStream('/api/chat', async () => ({ * body: { * provider: 'openai', - * model: 'gpt-4o', + * model: 'gpt-5.2', * } * })); * ``` diff --git a/packages/ai-code-mode-skills/src/code-mode-with-skills.ts b/packages/ai-code-mode-skills/src/code-mode-with-skills.ts index 2e5e3ebe4..7e9f43a21 100644 --- a/packages/ai-code-mode-skills/src/code-mode-with-skills.ts +++ b/packages/ai-code-mode-skills/src/code-mode-with-skills.ts @@ -44,7 +44,7 @@ export type { CodeModeWithSkillsOptions, CodeModeWithSkillsResult } * }); * * const stream = chat({ - * adapter: openaiText('gpt-4o'), // Main model + * adapter: openaiText('gpt-5.2'), // Main model * toolRegistry: toolsRegistry, // Dynamic tool registry * messages, * systemPrompts: [BASE_PROMPT, systemPrompt], diff --git a/packages/ai-openai/src/adapters/summarize.ts b/packages/ai-openai/src/adapters/summarize.ts index e95597d3d..55c647bdd 100644 --- a/packages/ai-openai/src/adapters/summarize.ts +++ b/packages/ai-openai/src/adapters/summarize.ts @@ -14,7 +14,7 @@ export interface OpenAISummarizeConfig extends OpenAIClientConfig {} * Creates an OpenAI summarize adapter with explicit API key. * Type resolution happens here at the call site. * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') + * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-5.2') * @param apiKey - Your OpenAI API key * @param config - Optional additional configuration * @returns Configured OpenAI summarize adapter instance with resolved types @@ -47,7 +47,7 @@ export function createOpenaiSummarize( * - `process.env` (Node.js) * - `window.env` (Browser with injected env) * - * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-4o') + * @param model - The model name (e.g., 'gpt-4o-mini', 'gpt-5.2') * @param config - Optional configuration (excluding apiKey which is auto-detected) * @returns Configured OpenAI summarize adapter instance with resolved types * @throws Error if OPENAI_API_KEY is not found in environment diff --git a/packages/ai-openai/src/adapters/text.ts b/packages/ai-openai/src/adapters/text.ts index efa520385..d6af2e1eb 100644 --- a/packages/ai-openai/src/adapters/text.ts +++ b/packages/ai-openai/src/adapters/text.ts @@ -144,15 +144,15 @@ export class OpenAITextAdapter< * Creates an OpenAI chat adapter with explicit API key. * Type resolution happens here at the call site. * - * @param model - The model name (e.g., 'gpt-4o', 'gpt-4-turbo') + * @param model - The model name (e.g., 'gpt-5.2', 'gpt-4-turbo') * @param apiKey - Your OpenAI API key * @param config - Optional additional configuration * @returns Configured OpenAI chat adapter instance with resolved types * * @example * ```typescript - * const adapter = createOpenaiChat('gpt-4o', "sk-..."); - * // adapter has type-safe modelOptions for gpt-4o + * const adapter = createOpenaiChat('gpt-5.2', "sk-..."); + * // adapter has type-safe modelOptions for gpt-5.2 * ``` */ export function createOpenaiChat< @@ -173,7 +173,7 @@ export function createOpenaiChat< * - `process.env` (Node.js) * - `window.env` (Browser with injected env) * - * @param model - The model name (e.g., 'gpt-4o', 'gpt-4-turbo') + * @param model - The model name (e.g., 'gpt-5.2', 'gpt-4-turbo') * @param config - Optional configuration (excluding apiKey which is auto-detected) * @returns Configured OpenAI text adapter instance with resolved types * @throws Error if OPENAI_API_KEY is not found in environment @@ -181,7 +181,7 @@ export function createOpenaiChat< * @example * ```typescript * // Automatically uses OPENAI_API_KEY from environment - * const adapter = openaiText('gpt-4o'); + * const adapter = openaiText('gpt-5.2'); * * const stream = chat({ * adapter, diff --git a/packages/ai-openai/src/text/text-provider-options.ts b/packages/ai-openai/src/text/text-provider-options.ts index e3e8be740..d0ee5f3b5 100644 --- a/packages/ai-openai/src/text/text-provider-options.ts +++ b/packages/ai-openai/src/text/text-provider-options.ts @@ -263,7 +263,7 @@ https://platform.openai.com/docs/api-reference/responses/create#responses_create max_output_tokens?: number /** - * The model name (e.g. "gpt-4o", "gpt-5", "gpt-4.1-mini", etc). + * The model name (e.g. "gpt-5.2", "gpt-5", "gpt-4.1-mini", etc). * https://platform.openai.com/docs/api-reference/responses/create#responses_create-model */ model: string diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts index 648e7e6bf..7f60d6fe2 100644 --- a/packages/ai/src/activities/chat/adapter.ts +++ b/packages/ai/src/activities/chat/adapter.ts @@ -49,7 +49,7 @@ export interface StructuredOutputResult { * All type resolution happens at the provider call site, not in this interface. * * Generic parameters: - * - TModel: The specific model name (e.g., 'gpt-4o') + * - TModel: The specific model name (e.g., 'gpt-5.2') * - TProviderOptions: Provider-specific options for this model (already resolved) * - TInputModalities: Supported input modalities for this model (already resolved) * - TMessageMetadata: Metadata types for content parts (already resolved) diff --git a/packages/ai/src/activities/chat/agent-loop-strategies.ts b/packages/ai/src/activities/chat/agent-loop-strategies.ts index 25b7b1334..79a07fd82 100644 --- a/packages/ai/src/activities/chat/agent-loop-strategies.ts +++ b/packages/ai/src/activities/chat/agent-loop-strategies.ts @@ -10,7 +10,7 @@ import type { AgentLoopStrategy } from '../../types' * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: maxIterations(3), // Max 3 iterations @@ -31,7 +31,7 @@ export function maxIterations(max: number): AgentLoopStrategy { * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: untilFinishReason(["stop", "length"]), @@ -66,7 +66,7 @@ export function untilFinishReason( * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: "gpt-4o", + * model: "gpt-5.2", * messages: [...], * tools: [weatherTool], * agentLoopStrategy: combineStrategies([ diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 1a5f052c2..25a382d98 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -95,7 +95,7 @@ export interface TextActivityOptions< | ProviderTool >, > { - /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ + /** The text adapter to use (created by a provider function like openaiText('gpt-5.2')) */ adapter: TAdapter /** * Conversation messages. Accepts: @@ -171,7 +171,7 @@ export interface TextActivityOptions< * @example * ```ts * const result = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Generate a person' }], * outputSchema: z.object({ name: z.string(), age: z.number() }) * }) @@ -192,7 +192,7 @@ export interface TextActivityOptions< * @example Non-streaming text * ```ts * const text = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) @@ -207,7 +207,7 @@ export interface TextActivityOptions< * @example * ```ts * const stream = chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [...], * middleware: [loggingMiddleware, redactionMiddleware], * }) @@ -2353,7 +2353,7 @@ class TextEngine< * import { openaiText } from '@tanstack/ai-openai' * * for await (const chunk of chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'What is the weather?' }], * tools: [weatherTool] * })) { @@ -2366,7 +2366,7 @@ class TextEngine< * @example One-shot text (streaming without tools) * ```ts * for await (const chunk of chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }] * })) { * console.log(chunk) @@ -2376,7 +2376,7 @@ class TextEngine< * @example Non-streaming text (stream: false) * ```ts * const text = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Hello!' }], * stream: false * }) @@ -2388,7 +2388,7 @@ class TextEngine< * import { z } from 'zod' * * const result = await chat({ - * adapter: openaiText('gpt-4o'), + * adapter: openaiText('gpt-5.2'), * messages: [{ role: 'user', content: 'Research and summarize the topic' }], * tools: [researchTool, analyzeTool], * outputSchema: z.object({ diff --git a/packages/ai/src/activities/chat/middleware/types.ts b/packages/ai/src/activities/chat/middleware/types.ts index e2a724b97..b94efcd04 100644 --- a/packages/ai/src/activities/chat/middleware/types.ts +++ b/packages/ai/src/activities/chat/middleware/types.ts @@ -74,7 +74,7 @@ export interface ChatMiddlewareContext { /** Provider name (e.g., 'openai', 'anthropic') */ provider: string - /** Model identifier (e.g., 'gpt-4o') */ + /** Model identifier (e.g., 'gpt-5.2') */ model: string /** Source of the chat invocation — always 'server' for server-side chat */ source: 'client' | 'server' diff --git a/packages/ai/src/activities/summarize/adapter.ts b/packages/ai/src/activities/summarize/adapter.ts index 2f7cc34f6..47812e91b 100644 --- a/packages/ai/src/activities/summarize/adapter.ts +++ b/packages/ai/src/activities/summarize/adapter.ts @@ -22,7 +22,7 @@ export interface SummarizeAdapterConfig { * All type resolution happens at the provider call site, not in this interface. * * Generic parameters: - * - TModel: The specific model name (e.g., 'gpt-4o') + * - TModel: The specific model name (e.g., 'gpt-5.2') * - TProviderOptions: Provider-specific options (already resolved) */ export interface SummarizeAdapter< diff --git a/packages/ai/src/extend-adapter.ts b/packages/ai/src/extend-adapter.ts index c689f7ed4..e468457d2 100644 --- a/packages/ai/src/extend-adapter.ts +++ b/packages/ai/src/extend-adapter.ts @@ -148,7 +148,7 @@ type InferAdapterReturn = TFactory extends ( * const myOpenai = extendAdapter(openaiText, customModels) * * // Use with original models - full type inference preserved - * const gpt4 = myOpenai('gpt-4o') + * const gpt4 = myOpenai('gpt-5.2') * * // Use with custom models * const custom = myOpenai('my-fine-tuned-gpt4') diff --git a/packages/ai/src/stream-to-response.ts b/packages/ai/src/stream-to-response.ts index 9850f4d60..c53372c1b 100644 --- a/packages/ai/src/stream-to-response.ts +++ b/packages/ai/src/stream-to-response.ts @@ -14,7 +14,7 @@ import type { StreamChunk } from './types' * ```typescript * const stream = chat({ * adapter: openaiText(), - * model: 'gpt-4o', + * model: 'gpt-5.2', * messages: [{ role: 'user', content: 'Hello!' }] * }); * const text = await streamToText(stream); @@ -113,7 +113,7 @@ export function toServerSentEventsStream( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * return toServerSentEventsResponse(stream, { abortController }); * ``` */ @@ -160,7 +160,7 @@ export function toServerSentEventsResponse( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * const readableStream = toHttpStream(stream); * // Use with Response for HTTP streaming (not SSE) * return new Response(readableStream, { @@ -233,7 +233,7 @@ export function toHttpStream( * * @example * ```typescript - * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] }); + * const stream = chat({ adapter: openaiText(), model: "gpt-5.2", messages: [...] }); * return toHttpResponse(stream, { abortController }); * ``` */ diff --git a/packages/ai/src/utilities/chat-params.ts b/packages/ai/src/utilities/chat-params.ts index 4131a6a01..7a270a008 100644 --- a/packages/ai/src/utilities/chat-params.ts +++ b/packages/ai/src/utilities/chat-params.ts @@ -171,16 +171,18 @@ export async function chatParamsFromRequest( * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`. * @returns A merged array suitable for `chat({ tools })`. */ -export function mergeAgentTools( - serverTools: ReadonlyArray, +export function mergeAgentTools< + const TServerTools extends ReadonlyArray>, +>( + serverTools: TServerTools, clientTools: ReadonlyArray<{ name: string description: string parameters: JSONSchema }>, -): Array { +): TServerTools { const seen = new Set(serverTools.map((t) => t.name)) - const merged: Array = [...serverTools] + const merged: Array> = [...serverTools] for (const ct of clientTools) { if (seen.has(ct.name)) { // Server wins on name collision. @@ -195,5 +197,12 @@ export function mergeAgentTools( // emits ClientToolRequest events. } as Tool) } - return merged + // The runtime array carries both server and client tools, but the + // return type is narrowed to just the typed server tuple so that + // `chat({ tools })` can discriminate `chunk.toolCallName` against the + // server tool names. Client tool calls still flow at runtime through + // the existing `ClientToolRequest` path — TypedStreamChunk just doesn't + // surface their names as typed literals (they appear as the bare + // `ToolCallStartEvent` shape after narrowing). + return merged as unknown as TServerTools } From 821a3d0c9810f89f2bb775efee768ce8926f13bc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 13:56:07 +0000 Subject: [PATCH 17/20] ci: apply automated fixes --- examples/ts-react-chat/src/routes/api.tanchat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index ad158be92..c98a8d866 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -239,7 +239,7 @@ export const Route = createFileRoute('/api/tanchat')({ runId: params.runId, abortController, }) - + return toServerSentEventsResponse(stream, { abortController }) } catch (error: any) { console.error('[API Route] Error in chat request:', { From c0b0feb1c248146a97e7339d939b5fa692b89527 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 16:06:44 +0200 Subject: [PATCH 18/20] feat(ai): expose typed tool output on TOOL_CALL_END events The engine already parses tool results and validates them against the tool's \`outputSchema\` before JSON-stringifying into \`result\` for the wire. Surface the parsed value as a new \`output\` field on \`TOOL_CALL_END\` so consumers can read the typed object directly. Threading: - \`ToolCallEndEvent\` gains a \`TOutput\` generic with default \`unknown\` and an optional \`output?: TOutput\` field. \`result?: string\` stays as the wire-level shape (AG-UI spec compat). - \`SafeToolOutput\` mirrors the existing \`SafeToolInput\` helper, picking \`outputSchema\` instead and guarding against \`any\` leaks. - \`DistributedToolCallEnd\` threads \`SafeToolOutput\` into each per-tool variant, so \`chunk.toolCallName === 'getGuitars'\` narrows \`chunk.output\` to that tool's output type. - The runtime tool-call emitter holds the validated result in a \`toolOutput\` local and spreads it onto the emitted \`TOOL_CALL_END\` event when defined (omitted on execution failure or pure client tools, where no server-side output exists). Consumer pattern: for await (const chunk of stream) { if (chunk.type === 'TOOL_CALL_END') { if (chunk.toolCallName === 'getGuitars') { chunk.output?.forEach((g) => g.id) // typed from outputSchema } } } Tools without an \`outputSchema\` get \`output\` typed as \`unknown\`; pure client tools (no \`.server(...)\`) leave \`output\` undefined and the chat client's tool-approval path remains the way to surface client-executed results. --- .../src/activities/chat/tools/tool-calls.ts | 10 +++++ packages/ai/src/types.ts | 39 ++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 16c1955e7..03061e54a 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -169,6 +169,13 @@ export class ToolCallManager { const tool = this.tools.find((t) => t.name === toolCall.function.name) let toolResultContent: string + // Holds the parsed/validated execution output before JSON-stringify. + // Surfaced on the emitted `TOOL_CALL_END` event as `output` so + // consumers can read it typed (via `TypedStreamChunk` distribution + // over the tools array) without re-parsing `result`. + // Stays `undefined` when the tool has no `.execute` (pure client + // tools) or when execution throws. + let toolOutput: unknown if (tool?.execute) { try { // Parse arguments (normalize null/non-object to {} for empty tool_use blocks) @@ -221,6 +228,7 @@ export class ToolCallManager { } } + toolOutput = result toolResultContent = typeof result === 'string' ? result : JSON.stringify(result) } catch (error: unknown) { @@ -242,6 +250,8 @@ export class ToolCallManager { toolName: toolCall.function.name, model: finishEvent.model, timestamp: Date.now(), + // Typed parsed output (undefined for failed exec / client-only tools). + ...(toolOutput !== undefined ? { output: toolOutput } : {}), result: toolResultContent, } as ToolCallEndEvent diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e9c663d7b..1bb58fe8b 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1218,16 +1218,19 @@ void _toolCallArgsDriftCheck * Emitted when a tool call completes. * * @ag-ui/core provides: `toolCallId` - * TanStack AI adds: `model?`, `toolCallName?`, `toolName?` (deprecated), `input?`, `result?` + * TanStack AI adds: `model?`, `toolCallName?`, `toolName?` (deprecated), `input?`, `output?`, `result?` * * @typeParam TToolName - Constrained tool name type. Defaults to `string` (untyped). * @typeParam TInput - Constrained input arguments type. Defaults to `unknown` (untyped). + * @typeParam TOutput - Constrained output type, inferred from the tool's + * `outputSchema`. 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. + * the union of tool name literals, input types, and output types respectively. */ export interface ToolCallEndEvent< TToolName extends string = string, TInput = unknown, + TOutput = unknown, > extends Pick { type: 'TOOL_CALL_END' /** Model identifier for multi-model support */ @@ -1251,13 +1254,25 @@ export interface ToolCallEndEvent< toolName: TToolName /** Final parsed input arguments (TanStack AI internal) */ input?: TInput - /** Tool execution result (TanStack AI internal) */ + /** + * Tool execution output, validated against the tool's `outputSchema` when + * one is declared. Set by the engine before JSON-stringifying into + * `result`. Undefined for tools without a `.server(...)` implementation + * (pure client tools — their results arrive via the chat client's tool + * approval path instead). + */ + output?: TOutput + /** + * Wire-level JSON-stringified result. The AG-UI spec carries the result + * as text; consumers that need the typed object should read `output` + * instead (it carries the parsed value before serialization). + */ result?: string } type _ToolCallEndDriftCheck = AssertSatisfiesAGUI< Omit< ToolCallEndEvent, - 'type' | 'model' | 'toolCallName' | 'toolName' | 'input' | 'result' + 'type' | 'model' | 'toolCallName' | 'toolName' | 'input' | 'output' | 'result' >, Pick > @@ -1792,6 +1807,20 @@ type SafeToolInput = T extends { : InferSchemaType> : unknown +/** + * Safely infer output type for a single tool. Mirrors `SafeToolInput`, + * picking `outputSchema` instead. Returns `unknown` when the tool has no + * `outputSchema` declared or when `InferSchemaType` produces `any`. + * @internal + */ +type SafeToolOutput = T extends { + outputSchema?: infer TOutput +} + ? IsAny>> extends true + ? unknown + : InferSchemaType> + : unknown + /** * Distribute over each non-provider tool to create a per-tool * `ToolCallStartEvent`. @@ -1841,7 +1870,7 @@ type DistributedToolCallStart< type DistributedToolCallEnd>> = NonProviderTools extends infer T ? T extends { name: infer TName extends string } - ? ToolCallEndEvent> & { + ? ToolCallEndEvent, SafeToolOutput> & { toolCallName: TName toolName: TName } From 550b102dc7dd51ff627f0f5b78646941c6772cfe Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 14:08:27 +0000 Subject: [PATCH 19/20] ci: apply automated fixes --- packages/ai/src/types.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1bb58fe8b..e66fd7776 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1272,7 +1272,13 @@ export interface ToolCallEndEvent< type _ToolCallEndDriftCheck = AssertSatisfiesAGUI< Omit< ToolCallEndEvent, - 'type' | 'model' | 'toolCallName' | 'toolName' | 'input' | 'output' | 'result' + | 'type' + | 'model' + | 'toolCallName' + | 'toolName' + | 'input' + | 'output' + | 'result' >, Pick > From 57c46ff773f5003d13e7414a50f199ddfb218ca1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 26 May 2026 16:21:59 +0200 Subject: [PATCH 20/20] chore(ai): fix lint errors in @tanstack/ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's \`@tanstack/ai:test:eslint\` job failed on this branch. Three categories of fix: - Rename the drift-guard type parameters from \`TanStack\`/\`AGUIShape\` to \`TTanStack\`/\`TAGUIShape\` to match the project's type-parameter naming convention (\`^T(|[A-Z][A-Za-z]+)$\`). - Drop unnecessary type assertions that \`eslint --fix\` flagged after recent type-shape changes made the casts redundant (\`activities/chat/index.ts\`, \`activities/chat/tools/tool-calls.ts\`, \`activities/generateVideo/index.ts\`). - Annotate the three intentional \`as unknown as \` casts with single-line \`eslint-disable-next-line no-restricted-syntax\` directives plus a one-line reason — multi-line comments don't attach the directive to the following statement. --- packages/ai/src/activities/chat/index.ts | 16 ++++++++-------- .../ai/src/activities/chat/tools/tool-calls.ts | 2 +- .../ai/src/activities/generateVideo/index.ts | 12 ++++++------ packages/ai/src/strip-to-spec-middleware.ts | 7 ++++++- packages/ai/src/types.ts | 6 +++--- packages/ai/src/utilities/chat-params.ts | 1 + 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 25a382d98..1ba0a7c83 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -1498,7 +1498,7 @@ class TextEngine< needsApproval: true, }, }, - } as StreamChunk) + }) } return chunks @@ -1521,7 +1521,7 @@ class TextEngine< toolName: clientTool.toolName, input: clientTool.input, }, - } as StreamChunk) + }) } return chunks @@ -1547,7 +1547,7 @@ class TextEngine< toolCallId: result.toolCallId, toolCallName: result.toolName, toolName: result.toolName, - } as StreamChunk) + }) const args = argsMap.get(result.toolCallId) ?? '{}' chunks.push({ @@ -1557,7 +1557,7 @@ class TextEngine< toolCallId: result.toolCallId, delta: args, args, - } as StreamChunk) + }) } chunks.push({ @@ -1568,7 +1568,7 @@ class TextEngine< toolCallName: result.toolName, toolName: result.toolName, result: content, - } as StreamChunk) + }) // AG-UI spec TOOL_CALL_RESULT event chunks.push({ @@ -1579,7 +1579,7 @@ class TextEngine< toolCallId: result.toolCallId, content, role: 'tool', - } as StreamChunk) + }) // If a placeholder tool message exists for this toolCallId (created by // uiMessageToModelMessages for an approval-responded part with no @@ -1669,7 +1669,7 @@ class TextEngine< model: this.params.model, timestamp: Date.now(), finishReason: 'tool_calls', - } as RunFinishedEvent + } } private shouldContinue(): boolean { @@ -2326,7 +2326,7 @@ class TextEngine< model: this.params.model, name: eventName, value, - } as CustomEvent + } } private createId(prefix: string): string { diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 03061e54a..44e57d474 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -253,7 +253,7 @@ export class ToolCallManager { // Typed parsed output (undefined for failed exec / client-only tools). ...(toolOutput !== undefined ? { output: toolOutput } : {}), result: toolResultContent, - } as ToolCallEndEvent + } // Add tool result message toolResults.push({ diff --git a/packages/ai/src/activities/generateVideo/index.ts b/packages/ai/src/activities/generateVideo/index.ts index cee2339f7..37e0f0893 100644 --- a/packages/ai/src/activities/generateVideo/index.ts +++ b/packages/ai/src/activities/generateVideo/index.ts @@ -314,7 +314,7 @@ async function* runStreamingVideoGeneration< runId, threadId, timestamp: Date.now(), - } as StreamChunk + } logger.request( `activity=generateVideo provider=${providerName} stream=true`, @@ -340,7 +340,7 @@ async function* runStreamingVideoGeneration< name: 'video:job:created', value: { jobId: jobResult.jobId }, timestamp: Date.now(), - } as StreamChunk + } // Poll for completion const startTime = Date.now() @@ -359,7 +359,7 @@ async function* runStreamingVideoGeneration< error: statusResult.error, }, timestamp: Date.now(), - } as StreamChunk + } if (statusResult.status === 'completed') { const urlResult = await adapter.getVideoUrl(jobResult.jobId) @@ -382,7 +382,7 @@ async function* runStreamingVideoGeneration< expiresAt: urlResult.expiresAt, }, timestamp: Date.now(), - } as StreamChunk + } yield { type: 'RUN_FINISHED', @@ -390,7 +390,7 @@ async function* runStreamingVideoGeneration< threadId, finishReason: 'stop', timestamp: Date.now(), - } as StreamChunk + } return } @@ -415,7 +415,7 @@ async function* runStreamingVideoGeneration< code: payload.code, error: payload, timestamp: Date.now(), - } as StreamChunk + } } } diff --git a/packages/ai/src/strip-to-spec-middleware.ts b/packages/ai/src/strip-to-spec-middleware.ts index 2953899ff..98ad80fa1 100644 --- a/packages/ai/src/strip-to-spec-middleware.ts +++ b/packages/ai/src/strip-to-spec-middleware.ts @@ -11,12 +11,17 @@ import type { StreamChunk } from './types' * spec validation or verifyEvents. */ export function stripToSpec(chunk: StreamChunk): StreamChunk { - // Only strip the deprecated nested error object from RUN_ERROR + // Only strip the deprecated nested error object from RUN_ERROR. + // StreamChunk is a closed discriminated union with no index signature, + // so we need to bypass the overlap check to destructure into a record + // and drop the legacy field. if (chunk.type === 'RUN_ERROR' && 'error' in chunk) { + // eslint-disable-next-line no-restricted-syntax -- structural narrowing into a record (see comment above) const { error: _deprecated, ...rest } = chunk as unknown as Record< string, unknown > + // eslint-disable-next-line no-restricted-syntax -- inverse cast; `rest` is structurally a subset of RunErrorEvent return rest as unknown as StreamChunk } return chunk diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e66fd7776..44d3f6160 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -952,12 +952,12 @@ export interface BaseAGUIEvent { * @ag-ui/core releases a new version. * @internal */ -type AssertSatisfiesAGUI = TanStack extends AGUIShape +type AssertSatisfiesAGUI = TTanStack extends TAGUIShape ? true : [ 'DRIFT: TanStack event no longer satisfies AGUI shape', - TanStack, - AGUIShape, + TTanStack, + TAGUIShape, ] // ============================================================================ diff --git a/packages/ai/src/utilities/chat-params.ts b/packages/ai/src/utilities/chat-params.ts index 7a270a008..b59c51f73 100644 --- a/packages/ai/src/utilities/chat-params.ts +++ b/packages/ai/src/utilities/chat-params.ts @@ -204,5 +204,6 @@ export function mergeAgentTools< // the existing `ClientToolRequest` path — TypedStreamChunk just doesn't // surface their names as typed literals (they appear as the bare // `ToolCallStartEvent` shape after narrowing). + // eslint-disable-next-line no-restricted-syntax -- intentional narrowing (see above) return merged as unknown as TServerTools }