From 8b1c3961114e1da2c8e9df1bbfc028a0ce800c1f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 15:10:42 +0200 Subject: [PATCH 01/29] chore(ai): bump @ag-ui/core to ^0.0.52 --- packages/typescript/ai/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 2ad62f817..ac20beae3 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -61,7 +61,7 @@ "tanstack-intent" ], "dependencies": { - "@ag-ui/core": "0.0.49", + "@ag-ui/core": "^0.0.52", "@tanstack/ai-event-client": "workspace:*", "partial-json": "^0.1.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb41b0817..2615b6fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,8 +935,8 @@ importers: packages/typescript/ai: dependencies: '@ag-ui/core': - specifier: 0.0.49 - version: 0.0.49 + specifier: ^0.0.52 + version: 0.0.52 '@tanstack/ai-event-client': specifier: workspace:* version: link:../ai-event-client @@ -1891,8 +1891,8 @@ packages: '@acemir/cssom@0.9.29': resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} - '@ag-ui/core@0.0.49': - resolution: {integrity: sha512-9ywypwjUGtIvTxJ2eKQjhPZgLnSFAfNK7vZUcT7Bz4ur4yAIB+lAFtzvu7VDYe6jsUx/6N/71Dh4R0zX5woNVw==} + '@ag-ui/core@0.0.52': + resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} '@anthropic-ai/sdk@0.71.2': resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} @@ -12009,7 +12009,7 @@ snapshots: '@acemir/cssom@0.9.29': {} - '@ag-ui/core@0.0.49': + '@ag-ui/core@0.0.52': dependencies: zod: 3.25.76 From c2f7405b302234565d27ed43cb3cec88a88deff8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 15:17:30 +0200 Subject: [PATCH 02/29] feat(ai): accept parentRunId in TextOptions for AG-UI run correlation --- packages/typescript/ai-anthropic/src/adapters/text.ts | 1 + packages/typescript/ai-gemini/src/adapters/text.ts | 1 + packages/typescript/ai-grok/src/adapters/text.ts | 2 ++ packages/typescript/ai-groq/src/adapters/text.ts | 2 ++ packages/typescript/ai-ollama/src/adapters/text.ts | 1 + packages/typescript/ai-openai/src/adapters/text.ts | 1 + packages/typescript/ai-openrouter/src/adapters/text.ts | 2 ++ packages/typescript/ai/src/activities/chat/index.ts | 5 +++++ packages/typescript/ai/src/types.ts | 5 +++++ 9 files changed, 20 insertions(+) diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index 359f2377a..165c00b58 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -602,6 +602,7 @@ export class AnthropicTextAdapter< threadId, model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index f4efcc466..93cb10c93 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -273,6 +273,7 @@ export class GeminiTextAdapter< threadId, model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index e185c5ecf..e5cd97127 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -126,6 +126,7 @@ export class GrokTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -266,6 +267,7 @@ export class GrokTextAdapter< threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 34f44ba81..61c51bf6f 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -129,6 +129,7 @@ export class GroqTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -265,6 +266,7 @@ export class GroqTextAdapter< threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 209951569..f60220ba6 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -253,6 +253,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< threadId, model: chunk.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 139629869..fed485cd2 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -321,6 +321,7 @@ export class OpenAITextAdapter< threadId, model: model || options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 29427171c..d0881bb05 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -162,6 +162,7 @@ export class OpenRouterTextAdapter< threadId: aguiState.threadId, model: currentModel || options.model, timestamp, + parentRunId: options.parentRunId, }) } @@ -229,6 +230,7 @@ export class OpenRouterTextAdapter< threadId: aguiState.threadId, model: options.model, timestamp, + parentRunId: options.parentRunId, }) } diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index e1327fdb5..51a03e80b 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -125,6 +125,8 @@ export interface TextActivityOptions< threadId?: TextOptions['threadId'] /** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */ runId?: TextOptions['runId'] + /** Parent run ID for AG-UI protocol nested run correlation. */ + parentRunId?: TextOptions['parentRunId'] /** * Optional Standard Schema for structured output. * When provided, the activity will: @@ -294,6 +296,7 @@ class TextEngine< // AG-UI protocol IDs private threadId: string private runIdOverride?: string + private parentRunIdOverride?: string // Middleware support private readonly middlewareRunner: MiddlewareRunner @@ -347,6 +350,7 @@ class TextEngine< this.effectiveSignal = config.params.abortController?.signal this.threadId = config.params.threadId || this.createId('thread') this.runIdOverride = config.params.runId + this.parentRunIdOverride = config.params.parentRunId // Initialize middleware — devtools first, strip-to-spec always last. // handleStreamChunk processes raw chunks BEFORE middleware, so internal @@ -612,6 +616,7 @@ class TextEngine< logger: this.logger, threadId: this.threadId, runId: this.runIdOverride, + parentRunId: this.parentRunIdOverride, })) { if (this.isCancelled()) { break diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index e11e7176f..7abb1161c 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -758,6 +758,11 @@ export interface TextOptions< * If not provided, a unique ID will be generated. */ runId?: string + /** + * Parent run ID for AG-UI protocol nested run correlation. + * Surfaced for observability/middleware; not consumed by the LLM call. + */ + parentRunId?: string } // ============================================================================ From 6fde945c8d475973295d99ecec20a3471bcc2f94 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 15:35:01 +0200 Subject: [PATCH 03/29] feat(ai): dedup AG-UI fan-out + handle reasoning/activity/developer roles Add a pre-pass to convertMessagesToModelMessages that drops tool fan-out duplicates when a UIMessage anchor already covers the tool result, filters out AG-UI reasoning/activity messages with no ModelMessage equivalent, and collapses developer messages to the system role. --- .../ai/src/activities/chat/messages.ts | 48 +++++++++- packages/typescript/ai/tests/messages.test.ts | 95 +++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 packages/typescript/ai/tests/messages.test.ts diff --git a/packages/typescript/ai/src/activities/chat/messages.ts b/packages/typescript/ai/src/activities/chat/messages.ts index b7f97b880..735887bb4 100644 --- a/packages/typescript/ai/src/activities/chat/messages.ts +++ b/packages/typescript/ai/src/activities/chat/messages.ts @@ -63,15 +63,55 @@ function getTextContent(content: string | null | Array): string { export function convertMessagesToModelMessages( messages: Array, ): Array { + // Pre-pass: collect toolCallIds already represented in anchor UIMessage parts. + // Fan-out tool messages whose toolCallId matches an anchored ToolResultPart + // are AG-UI duplicates and must be dropped to avoid double-feeding the LLM. + const anchoredToolCallIds = new Set() + for (const msg of messages) { + if ('parts' in msg) { + for (const part of msg.parts) { + if (part.type === 'tool-result') { + anchoredToolCallIds.add(part.toolCallId) + } + } + } + } + const modelMessages: Array = [] for (const msg of messages) { if ('parts' in msg) { - // UIMessage - convert to ModelMessages + // UIMessage anchor — existing fan-out path modelMessages.push(...uiMessageToModelMessages(msg)) - } else { - // Already ModelMessage - modelMessages.push(msg) + continue + } + + const role = (msg as { role: string }).role + + // AG-UI tool fan-out duplicate — drop if anchor already covers it + if ( + role === 'tool' && + msg.toolCallId && + anchoredToolCallIds.has(msg.toolCallId) + ) { + continue } + + // AG-UI reasoning and activity — no ModelMessage equivalent today + if (role === 'reasoning' || role === 'activity') { + continue + } + + // AG-UI developer — collapse to system + if (role === 'developer') { + modelMessages.push({ + role: 'system' as ModelMessage['role'], + content: (msg as { content: string }).content, + } as ModelMessage) + continue + } + + // Already a ModelMessage (user, assistant, system, tool with no anchor) — pass through + modelMessages.push(msg) } return modelMessages } diff --git a/packages/typescript/ai/tests/messages.test.ts b/packages/typescript/ai/tests/messages.test.ts new file mode 100644 index 000000000..2a6216901 --- /dev/null +++ b/packages/typescript/ai/tests/messages.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { convertMessagesToModelMessages } from '../src/activities/chat/messages' +import type { ModelMessage, UIMessage } from '../src/types' + +describe('convertMessagesToModelMessages — AG-UI dedup pre-pass', () => { + it('drops fan-out tool message when an anchor UIMessage already represents the tool result', () => { + const messages = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'text', content: 'calling' }, + { + type: 'tool-call', + id: 'tc1', + name: 'getTodos', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'tc1', + content: '[]', + state: 'complete', + }, + ], + } as UIMessage, + // AG-UI fan-out duplicate — should be dropped + { + role: 'tool', + toolCallId: 'tc1', + content: '[]', + } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + const toolMessages = result.filter((m) => m.role === 'tool') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]?.toolCallId).toBe('tc1') + }) + + it('keeps tool messages from a foreign AG-UI client (no anchor parts)', () => { + const messages = [ + // No UIMessage with parts; this is what a foreign AG-UI client sends. + { + role: 'assistant', + content: 'calling', + toolCalls: [ + { id: 'tc1', type: 'function', function: { name: 'getTodos', arguments: '{}' } }, + ], + } as ModelMessage, + { role: 'tool', toolCallId: 'tc1', content: '[]' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + const toolMessages = result.filter((m) => m.role === 'tool') + expect(toolMessages).toHaveLength(1) + expect(toolMessages[0]?.toolCallId).toBe('tc1') + }) + + it('drops AG-UI reasoning messages (no ModelMessage equivalent today)', () => { + const messages = [ + { role: 'reasoning', content: 'thinking...' } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result.find((m) => (m as any).role === 'reasoning')).toBeUndefined() + expect(result).toHaveLength(1) + expect(result[0]?.role).toBe('user') + }) + + it('drops AG-UI activity messages', () => { + const messages = [ + { role: 'activity', content: 'event' } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result).toHaveLength(1) + expect(result[0]?.role).toBe('user') + }) + + it('collapses AG-UI developer messages to system role', () => { + const messages = [ + { role: 'developer', content: 'You are helpful' } as unknown as ModelMessage, + { role: 'user', content: 'hi' } as ModelMessage, + ] + + const result = convertMessagesToModelMessages(messages) + expect(result).toHaveLength(2) + expect(result[0]?.role).toBe('system') + expect(result[0]?.content).toBe('You are helpful') + }) +}) From dc8c172ba0c4017063b537b7acccd2652c4e45b3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:07:16 +0200 Subject: [PATCH 04/29] feat(ai): add chatParamsFromRequestBody for AG-UI server endpoints Implements a server-side helper that validates an incoming request body against RunAgentInputSchema from @ag-ui/core and returns a spread-friendly params object. Re-attaches stripped `parts` fields from raw messages to preserve UIMessage compatibility after AG-UI Zod strip-mode parsing. --- .../ai/src/utilities/chat-params.ts | 66 +++++++++++++++++++ .../typescript/ai/tests/chat-params.test.ts | 62 +++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/typescript/ai/src/utilities/chat-params.ts create mode 100644 packages/typescript/ai/tests/chat-params.test.ts diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts new file mode 100644 index 000000000..7b36f1741 --- /dev/null +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -0,0 +1,66 @@ +import { AGUIError, RunAgentInputSchema } from '@ag-ui/core' +import type { JSONSchema, ModelMessage, UIMessage } from '../types' + +/** + * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`. + * + * Returns a spread-friendly object whose `messages` field is suitable for + * passing directly to `chat({ messages })`. The existing + * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and + * reasoning/activity/developer-role normalization internally. + * + * @throws An error with a migration-pointing message when the body does + * not conform to AG-UI 0.0.52 `RunAgentInputSchema`. Surface this as a + * 400 Bad Request to the client. + */ +export function chatParamsFromRequestBody(body: unknown): Promise<{ + messages: Array + threadId: string + runId: string + parentRunId?: string + tools: Array<{ name: string; description: string; parameters: JSONSchema }> + forwardedProps: Record + state: unknown + context: Array<{ description: string; value: string }> +}> { + const parseResult = RunAgentInputSchema.safeParse(body) + if (!parseResult.success) { + return Promise.reject( + new AGUIError( + `Request body is not a valid AG-UI RunAgentInput. ` + + `If you're upgrading from a previous @tanstack/ai-client release, ` + + `see docs/migration/ag-ui-compliance.md. ` + + `Validation errors: ${parseResult.error.message}`, + ), + ) + } + + const parsed = parseResult.data + + // AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are + // dropped during parse. We re-attach them from the original body so the + // existing UIMessage path inside `chat()` can use them directly. + const rawMessages = (body as { messages?: Array> }).messages ?? [] + const messages = parsed.messages.map((m, i) => { + const raw = rawMessages[i] + if (raw && typeof raw === 'object' && 'parts' in raw) { + return { ...m, parts: raw.parts } as UIMessage | ModelMessage + } + return m as ModelMessage + }) + + return Promise.resolve({ + messages, + threadId: parsed.threadId, + runId: parsed.runId, + parentRunId: parsed.parentRunId, + tools: parsed.tools as Array<{ + name: string + description: string + parameters: JSONSchema + }>, + forwardedProps: (parsed.forwardedProps ?? {}) as Record, + state: parsed.state, + context: parsed.context, + }) +} diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts new file mode 100644 index 000000000..2529c61a9 --- /dev/null +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { chatParamsFromRequestBody } from '../src/utilities/chat-params' + +describe('chatParamsFromRequestBody', () => { + const validBody = { + threadId: 'thread-1', + runId: 'run-1', + state: {}, + messages: [ + { + id: 'm1', + role: 'user', + content: 'hello', + // TanStack canonical (extra) — should pass through untouched + parts: [{ type: 'text', content: 'hello' }], + }, + ], + tools: [], + context: [], + forwardedProps: { temperature: 0.7 }, + } + + it('returns parsed fields verbatim on a valid body', async () => { + const result = await chatParamsFromRequestBody(validBody) + expect(result.threadId).toBe('thread-1') + expect(result.runId).toBe('run-1') + expect(result.messages).toHaveLength(1) + expect(result.tools).toEqual([]) + expect(result.forwardedProps).toEqual({ temperature: 0.7 }) + }) + + it('preserves the `parts` field on messages (AG-UI strip mode tolerates extras in raw JSON)', async () => { + const result = await chatParamsFromRequestBody(validBody) + const m = result.messages[0] as { parts?: unknown } + expect(m.parts).toEqual([{ type: 'text', content: 'hello' }]) + }) + + it('throws on missing threadId', async () => { + const { threadId, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('throws on missing runId', async () => { + const { runId, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('throws on missing messages', async () => { + const { messages, ...rest } = validBody + await expect(chatParamsFromRequestBody(rest)).rejects.toThrow() + }) + + it('rejects the legacy {messages, data} shape with a migration-pointing error', async () => { + const oldBody = { + messages: [{ id: 'm1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }], + data: {}, + } + await expect(chatParamsFromRequestBody(oldBody)).rejects.toThrow( + /AG-UI|RunAgentInput|migration/i, + ) + }) +}) From d9f7408d045b3265c8fe2bf099e730e3ab233283 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:10:25 +0200 Subject: [PATCH 05/29] refactor(ai): use AGUIContext type for context field in chatParamsFromRequestBody Replaces inline anonymous type with the Context type from @ag-ui/core to reuse the canonical shape instead of re-declaring it. --- packages/typescript/ai/src/utilities/chat-params.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index 7b36f1741..fe754eddc 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -1,4 +1,5 @@ import { AGUIError, RunAgentInputSchema } from '@ag-ui/core' +import type { Context as AGUIContext } from '@ag-ui/core' import type { JSONSchema, ModelMessage, UIMessage } from '../types' /** @@ -21,7 +22,7 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{ tools: Array<{ name: string; description: string; parameters: JSONSchema }> forwardedProps: Record state: unknown - context: Array<{ description: string; value: string }> + context: Array }> { const parseResult = RunAgentInputSchema.safeParse(body) if (!parseResult.success) { From 83c168dc963d2d2e0712db474a0c668a00b0ad92 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:24:41 +0200 Subject: [PATCH 06/29] feat(ai): add mergeAgentTools helper (server wins on collision) --- .../ai/src/utilities/chat-params.ts | 48 ++++++++++++++++- .../typescript/ai/tests/chat-params.test.ts | 52 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index fe754eddc..2c29822d7 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -1,6 +1,6 @@ import { AGUIError, RunAgentInputSchema } from '@ag-ui/core' import type { Context as AGUIContext } from '@ag-ui/core' -import type { JSONSchema, ModelMessage, UIMessage } from '../types' +import type { JSONSchema, ModelMessage, Tool, UIMessage } from '../types' /** * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`. @@ -65,3 +65,49 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{ context: parsed.context, }) } + +/** + * Merge a server-side tool registry with the AG-UI client-declared tools + * received in the request body. + * + * Rules: + * - Server tools win on name collision. The client's declaration is + * ignored if the server already has a tool with that name. The client's + * UI-side handler still fires when the streamed tool-result event comes + * through (see `chat-client.ts` `onToolCall`), giving the + * "after server execution the client also handles" semantic for free. + * - Client-only tools (name not in `serverTools`) become no-execute + * entries: the runtime's existing `ClientToolRequest` path handles + * them — server emits a tool-call request, client executes via its + * registered handler, client posts back the result. + * + * @param serverTools - The server's `toolDefinition().server(...)` registry, + * keyed by tool name. + * @param clientTools - The `tools` array received from + * `chatParamsFromRequestBody(...)`. + * @returns A merged record suitable for `chat({ tools })`. + */ +export function mergeAgentTools( + serverTools: Record, + clientTools: Array<{ + name: string + description: string + parameters: JSONSchema + }>, +): Record { + const merged: Record = { ...serverTools } + for (const ct of clientTools) { + if (ct.name in merged) { + // Server wins + continue + } + merged[ct.name] = { + name: ct.name, + description: ct.description, + inputSchema: ct.parameters, + // No `execute` — runtime treats this as a client-side tool and + // emits ClientToolRequest events. + } as Tool + } + return merged +} diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts index 2529c61a9..0414ad027 100644 --- a/packages/typescript/ai/tests/chat-params.test.ts +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { chatParamsFromRequestBody } from '../src/utilities/chat-params' +import { chatParamsFromRequestBody, mergeAgentTools } from '../src/utilities/chat-params' describe('chatParamsFromRequestBody', () => { const validBody = { @@ -60,3 +60,53 @@ describe('chatParamsFromRequestBody', () => { ) }) }) + +describe('mergeAgentTools', () => { + const fakeServerTool = (name: string) => ({ + name, + description: `server ${name}`, + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ ok: true }), + }) + + it('returns server tools unchanged when client list is empty', () => { + const server = { greet: fakeServerTool('greet') } + const result = mergeAgentTools(server, []) + expect(Object.keys(result)).toEqual(['greet']) + expect(result['greet']!.execute).toBeDefined() + }) + + it('adds client-only tools as no-execute stubs', () => { + const server = {} + const client = [ + { + name: 'showToast', + description: 'render a toast', + parameters: { type: 'object', properties: {} }, + }, + ] + const result = mergeAgentTools(server, client) + expect(Object.keys(result)).toEqual(['showToast']) + expect(result['showToast']!.execute).toBeUndefined() + expect(result['showToast']!.inputSchema).toEqual({ type: 'object', properties: {} }) + expect(result['showToast']!.description).toBe('render a toast') + }) + + it('server wins on name collision (client declaration ignored)', () => { + const server = { greet: fakeServerTool('greet') } + const client = [ + { + name: 'greet', + description: 'overridden', + parameters: { type: 'object', properties: { foo: { type: 'string' } } }, + }, + ] + const result = mergeAgentTools(server, client) + expect(result['greet']!.description).toBe('server greet') + expect(result['greet']!.execute).toBeDefined() + }) + + it('handles empty server and empty client', () => { + expect(mergeAgentTools({}, [])).toEqual({}) + }) +}) From 2a58cdbf1951c421ef217dc403e4df7d11ea1fd2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:31:40 +0200 Subject: [PATCH 07/29] feat(ai): export chatParamsFromRequestBody and mergeAgentTools --- packages/typescript/ai/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index ef45543be..2bb62f1af 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -168,6 +168,12 @@ export type { JSONParser, } from './activities/chat/stream/index' +// Chat utilities +export { + chatParamsFromRequestBody, + mergeAgentTools, +} from './utilities/chat-params' + // Adapter extension utilities export { createModel, extendAdapter } from './extend-adapter' export type { ExtendedModelDef } from './extend-adapter' From 7f865d0ef41284769752247362b9b492db015de7 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:38:34 +0200 Subject: [PATCH 08/29] feat(ai): add uiMessagesToWire serializer for AG-UI request body --- .../typescript/ai/src/utilities/ag-ui-wire.ts | 173 ++++++++++++++++++ .../typescript/ai/tests/ag-ui-wire.test.ts | 128 +++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 packages/typescript/ai/src/utilities/ag-ui-wire.ts create mode 100644 packages/typescript/ai/tests/ag-ui-wire.test.ts diff --git a/packages/typescript/ai/src/utilities/ag-ui-wire.ts b/packages/typescript/ai/src/utilities/ag-ui-wire.ts new file mode 100644 index 000000000..c016f177c --- /dev/null +++ b/packages/typescript/ai/src/utilities/ag-ui-wire.ts @@ -0,0 +1,173 @@ +import type { + ContentPart, + MessagePart, + TextPart, + UIMessage, +} from '../types' + +type AGUITextInputContent = { type: 'text'; text: string } +type AGUIInputContent = + | AGUITextInputContent + | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' }) + +type AGUIToolCallMirror = { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +type AGUIToolMessage = { + role: 'tool' + id: string + toolCallId: string + content: string + error?: string +} + +type AGUIReasoningMessage = { + role: 'reasoning' + id: string + content: string +} + +type WireAnchorMessage = UIMessage & { + content?: string | Array + toolCalls?: Array +} + +export type WireMessage = WireAnchorMessage | AGUIToolMessage | AGUIReasoningMessage + +/** + * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages` + * wire shape. Each anchor (system/user/assistant) carries the canonical + * `parts` array verbatim plus AG-UI mirror fields (`content`, `toolCalls`) + * so AG-UI Zod parsing succeeds. Tool results and thinking parts on + * assistant messages are additionally emitted as fan-out + * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict + * AG-UI server consumers. + */ +export function uiMessagesToWire( + messages: Array, +): Array { + const wire: Array = [] + + for (const msg of messages) { + if (msg.role === 'system') { + wire.push({ + ...msg, + content: collectText(msg.parts), + }) + continue + } + + if (msg.role === 'user') { + wire.push({ + ...msg, + content: collectUserContent(msg.parts), + }) + continue + } + + // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs + for (const part of msg.parts) { + if (part.type === 'thinking') { + wire.push({ + role: 'reasoning', + id: deriveReasoningId(msg.id, part), + content: part.content, + }) + } + } + + const text = collectText(msg.parts) + const toolCalls = collectToolCalls(msg.parts) + wire.push({ + ...msg, + ...(text !== '' && { content: text }), + ...(toolCalls && { toolCalls }), + }) + + for (const part of msg.parts) { + if (part.type === 'tool-result') { + wire.push({ + role: 'tool', + id: deriveToolMessageId(part.toolCallId), + toolCallId: part.toolCallId, + content: part.content, + ...(part.error !== undefined && { error: part.error }), + }) + } + } + } + + return wire +} + +function collectText(parts: ReadonlyArray): string { + return parts + .filter((p): p is TextPart => p.type === 'text') + .map((p) => p.content) + .join('') +} + +function collectUserContent( + parts: ReadonlyArray, +): string | Array { + const hasMultimodal = parts.some( + (p) => + p.type === 'image' || + p.type === 'audio' || + p.type === 'video' || + p.type === 'document', + ) + if (!hasMultimodal) { + return collectText(parts) + } + const out: Array = [] + for (const p of parts) { + if (p.type === 'text') { + out.push({ type: 'text', text: p.content }) + } else if ( + p.type === 'image' || + p.type === 'audio' || + p.type === 'video' || + p.type === 'document' + ) { + out.push(p as AGUIInputContent) + } + } + return out +} + +function collectToolCalls( + parts: ReadonlyArray, +): Array | undefined { + const calls: Array = [] + for (const p of parts) { + if (p.type === 'tool-call') { + calls.push({ + id: p.id, + type: 'function', + function: { name: p.name, arguments: p.arguments }, + }) + } + } + return calls.length > 0 ? calls : undefined +} + +function deriveReasoningId(messageId: string, part: MessagePart): string { + return `${messageId}-reasoning-${(part as { id?: string }).id ?? hashContent((part as { content: string }).content)}` +} + +function deriveToolMessageId(toolCallId: string): string { + return `tool-${toolCallId}` +} + +function hashContent(s: string): string { + // Cheap deterministic id suffix; collisions are tolerable since + // reasoning ids only matter for AG-UI server consumers, not for our + // own server's dedup logic (which keys on toolCallId, not reasoning id). + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h).toString(36) +} diff --git a/packages/typescript/ai/tests/ag-ui-wire.test.ts b/packages/typescript/ai/tests/ag-ui-wire.test.ts new file mode 100644 index 000000000..25ee128a2 --- /dev/null +++ b/packages/typescript/ai/tests/ag-ui-wire.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { uiMessagesToWire } from '../src/utilities/ag-ui-wire' +import type { UIMessage } from '../src/types' + +describe('uiMessagesToWire', () => { + it('mirrors a system UIMessage to a string content field', () => { + const messages: Array = [ + { id: 's1', role: 'system', parts: [{ type: 'text', content: 'You are helpful' }] }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(wire[0]!).toMatchObject({ + id: 's1', + role: 'system', + content: 'You are helpful', + }) + expect((wire[0]! as any).parts).toBeDefined() + }) + + it('mirrors a user UIMessage with a text-only parts list to a string content', () => { + const messages: Array = [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(wire[0]!).toMatchObject({ id: 'u1', role: 'user', content: 'hi' }) + }) + + it('mirrors a user UIMessage with mixed multimodal parts to an InputContent[] content', () => { + const messages: Array = [ + { + id: 'u1', + role: 'user', + parts: [ + { type: 'text', content: 'look at this' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/cat.png', mimeType: 'image/png' }, + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(1) + expect(Array.isArray((wire[0]! as any).content)).toBe(true) + expect((wire[0]! as any).content).toHaveLength(2) + expect((wire[0]! as any).content[0]).toEqual({ type: 'text', text: 'look at this' }) + expect((wire[0]! as any).content[1]).toMatchObject({ + type: 'image', + source: { type: 'url', value: 'https://example.com/cat.png', mimeType: 'image/png' }, + }) + }) + + it('emits assistant anchor with toolCalls mirror and a separate tool fan-out per ToolResultPart', () => { + const messages: Array = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'text', content: 'ok' }, + { type: 'tool-call', id: 'tc1', name: 'getTodos', arguments: '{}', state: 'input-complete' }, + { type: 'tool-result', toolCallId: 'tc1', content: '[]', state: 'complete' }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(2) + // Anchor + expect(wire[0]!).toMatchObject({ + id: 'a1', + role: 'assistant', + content: 'ok', + toolCalls: [ + { id: 'tc1', type: 'function', function: { name: 'getTodos', arguments: '{}' } }, + ], + }) + // Fan-out tool message + expect(wire[1]!).toMatchObject({ + role: 'tool', + toolCallId: 'tc1', + content: '[]', + }) + }) + + it('emits a separate reasoning fan-out before the assistant anchor for each ThinkingPart', () => { + const messages: Array = [ + { + id: 'a1', + role: 'assistant', + parts: [ + { type: 'thinking', content: 'pondering' }, + { type: 'text', content: 'answer' }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + expect(wire).toHaveLength(2) + expect(wire[0]!).toMatchObject({ role: 'reasoning', content: 'pondering' }) + expect(wire[1]!).toMatchObject({ id: 'a1', role: 'assistant', content: 'answer' }) + }) + + it('preserves the original `parts` array on every anchor message', () => { + const messages: Array = [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ] + const wire = uiMessagesToWire(messages) + expect((wire[0]! as any).parts).toEqual([{ type: 'text', content: 'hi' }]) + }) + + it('preserves per-part metadata on multimodal parts (round-trip via parts field)', () => { + const messages: Array = [ + { + id: 'u1', + role: 'user', + parts: [ + { + type: 'image', + source: { type: 'data', value: 'base64...', mimeType: 'image/png' }, + metadata: { detail: 'high' }, + }, + ], + }, + ] + const wire = uiMessagesToWire(messages) + const partOnAnchor = ((wire[0]! as any).parts)[0] + expect(partOnAnchor.metadata).toEqual({ detail: 'high' }) + }) +}) From 622bb80fb5e7361ba9a917f7eb19deb1cab32cda Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 16:51:39 +0200 Subject: [PATCH 09/29] feat(ai-client): connection adapters POST AG-UI RunAgentInput payload - Export uiMessagesToWire and WireMessage from @tanstack/ai - Add RunAgentInputContext interface to connection-adapters.ts - Extend ConnectConnectionAdapter.connect and SubscribeConnectionAdapter.send with optional runContext param - fetchServerSentEvents and fetchHttpStream now POST AG-UI RunAgentInput shape (threadId, runId, state, messages, tools, context, forwardedProps) instead of bare {messages, data} - stream() and rpcStream() accept _runContext for type consistency but pass through unchanged - normalizeConnectionAdapter wrapper forwards runContext to connect() - Add generateRunId() helper for fallback threadId/runId generation - Make uiMessagesToWire defensive against ModelMessage-shaped inputs (no parts field) - Update test assertions: body.data -> body.forwardedProps, body.model/provider -> body.forwardedProps.* --- .../ai-client/src/connection-adapters.ts | 80 +++++++++++++++---- .../tests/connection-adapters.test.ts | 18 +++-- packages/typescript/ai/src/index.ts | 4 + .../typescript/ai/src/utilities/ag-ui-wire.ts | 16 ++-- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 91d63a146..9014c7265 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -1,5 +1,10 @@ +import { uiMessagesToWire } from '@tanstack/ai' import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai' +function generateRunId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + /** * Merge custom headers into request headers */ @@ -62,6 +67,25 @@ async function* readStreamLines( } } +/** + * Per-send context provided by the chat client to the connection adapter. + * The adapter combines this with serialized messages to build a full + * AG-UI `RunAgentInput` payload. + */ +export interface RunAgentInputContext { + threadId: string + runId: string + parentRunId?: string + /** Client-declared tools to advertise in the request payload. */ + clientTools?: Array<{ + name: string + description: string + parameters: unknown + }> + /** Arbitrary user-controlled passthrough data. */ + forwardedProps?: Record +} + export interface ConnectConnectionAdapter { /** * Connect and return an async iterable of StreamChunks. @@ -70,6 +94,7 @@ export interface ConnectConnectionAdapter { messages: Array | Array, data?: Record, abortSignal?: AbortSignal, + runContext?: RunAgentInputContext, ) => AsyncIterable } @@ -85,6 +110,7 @@ export interface SubscribeConnectionAdapter { messages: Array | Array, data?: Record, abortSignal?: AbortSignal, + runContext?: RunAgentInputContext, ) => Promise } @@ -173,10 +199,10 @@ export function normalizeConnectionAdapter( } })() }, - async send(messages, data, abortSignal) { + async send(messages, data, abortSignal, runContext) { let hasTerminalEvent = false try { - const stream = connection.connect(messages, data, abortSignal) + const stream = connection.connect(messages, data, abortSignal, runContext) for await (const chunk of stream) { if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { hasTerminalEvent = true @@ -268,7 +294,7 @@ export function fetchServerSentEvents( | (() => FetchConnectionOptions | Promise) = {}, ): ConnectConnectionAdapter { return { - async *connect(messages, data, abortSignal) { + async *connect(messages, data, abortSignal, runContext) { // Resolve URL and options if they are functions const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = @@ -279,12 +305,23 @@ export function fetchServerSentEvents( ...mergeHeaders(resolvedOptions.headers), } - // Send messages as-is (UIMessages with parts preserved) - // Server-side TextEngine handles conversion to ModelMessages + // Build AG-UI RunAgentInput payload + const wireMessages = uiMessagesToWire(messages as Array) const requestBody = { - messages, - data, - ...resolvedOptions.body, + threadId: runContext?.threadId ?? generateRunId('thread'), + runId: runContext?.runId ?? generateRunId('run'), + ...(runContext?.parentRunId !== undefined && { + parentRunId: runContext.parentRunId, + }), + state: {}, + messages: wireMessages, + tools: runContext?.clientTools ?? [], + context: [], + forwardedProps: { + ...(runContext?.forwardedProps ?? {}), + ...data, + ...resolvedOptions.body, + }, } const fetchClient = resolvedOptions.fetchClient ?? fetch @@ -372,7 +409,7 @@ export function fetchHttpStream( | (() => FetchConnectionOptions | Promise) = {}, ): ConnectConnectionAdapter { return { - async *connect(messages, data, abortSignal) { + async *connect(messages, data, abortSignal, runContext) { // Resolve URL and options if they are functions const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = @@ -383,12 +420,23 @@ export function fetchHttpStream( ...mergeHeaders(resolvedOptions.headers), } - // Send messages as-is (UIMessages with parts preserved) - // Server-side TextEngine handles conversion to ModelMessages + // Build AG-UI RunAgentInput payload + const wireMessages = uiMessagesToWire(messages as Array) const requestBody = { - messages, - data, - ...resolvedOptions.body, + threadId: runContext?.threadId ?? generateRunId('thread'), + runId: runContext?.runId ?? generateRunId('run'), + ...(runContext?.parentRunId !== undefined && { + parentRunId: runContext.parentRunId, + }), + state: {}, + messages: wireMessages, + tools: runContext?.clientTools ?? [], + context: [], + forwardedProps: { + ...(runContext?.forwardedProps ?? {}), + ...data, + ...resolvedOptions.body, + }, } const fetchClient = resolvedOptions.fetchClient ?? fetch @@ -445,7 +493,7 @@ export function stream( ) => AsyncIterable, ): ConnectConnectionAdapter { return { - async *connect(messages, data) { + async *connect(messages, data, _abortSignal, _runContext) { // Pass messages as-is (UIMessages with parts preserved) // Server-side chat() handles conversion to ModelMessages yield* streamFactory(messages, data) @@ -476,7 +524,7 @@ export function rpcStream( ) => AsyncIterable, ): ConnectConnectionAdapter { return { - async *connect(messages, data) { + async *connect(messages, data, _abortSignal, _runContext) { // Pass messages as-is (UIMessages with parts preserved) // Server-side chat() handles conversion to ModelMessages yield* rpcCall(messages, data) diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 60c36763a..b720b230a 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -302,7 +302,7 @@ describe('connection-adapters', () => { expect(authValue).toBe('Bearer token') }) - it('should pass data to request body', async () => { + it('should pass data to request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -329,7 +329,7 @@ describe('connection-adapters', () => { expect(fetchMock).toHaveBeenCalled() const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ key: 'value' }) }) it('should use custom fetchClient when provided', async () => { @@ -436,7 +436,7 @@ describe('connection-adapters', () => { expect(call?.[1]?.headers).toMatchObject({ 'X-Async': 'token' }) }) - it('should merge options.body into request body', async () => { + it('should merge options.body into request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -462,9 +462,11 @@ describe('connection-adapters', () => { const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.model).toBe('gpt-4o') - expect(body.provider).toBe('openai') - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ + model: 'gpt-4o', + provider: 'openai', + key: 'value', + }) }) it('should handle multiple chunks across multiple reads', async () => { @@ -688,7 +690,7 @@ describe('connection-adapters', () => { }) }) - it('should pass data to request body', async () => { + it('should pass data to request body forwardedProps', async () => { const mockReader = { read: vi.fn().mockResolvedValue({ done: true, value: undefined }), releaseLock: vi.fn(), @@ -712,7 +714,7 @@ describe('connection-adapters', () => { const call = fetchMock.mock.calls[0] const body = JSON.parse(call?.[1]?.body as string) - expect(body.data).toEqual({ key: 'value' }) + expect(body.forwardedProps).toMatchObject({ key: 'value' }) }) it('should resolve dynamic URL from function', async () => { diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index 2bb62f1af..e32f183e6 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -174,6 +174,10 @@ export { mergeAgentTools, } from './utilities/chat-params' +// AG-UI wire serialization (used internally by @tanstack/ai-client) +export { uiMessagesToWire } from './utilities/ag-ui-wire' +export type { WireMessage } from './utilities/ag-ui-wire' + // Adapter extension utilities export { createModel, extendAdapter } from './extend-adapter' export type { ExtendedModelDef } from './extend-adapter' diff --git a/packages/typescript/ai/src/utilities/ag-ui-wire.ts b/packages/typescript/ai/src/utilities/ag-ui-wire.ts index c016f177c..631492555 100644 --- a/packages/typescript/ai/src/utilities/ag-ui-wire.ts +++ b/packages/typescript/ai/src/utilities/ag-ui-wire.ts @@ -52,10 +52,14 @@ export function uiMessagesToWire( const wire: Array = [] for (const msg of messages) { + // Defensive: if parts is missing (ModelMessage-shaped input), pass through as-is. + // UIMessage always has parts; ModelMessage uses content directly. + const parts: ReadonlyArray = (msg.parts as ReadonlyArray | undefined) ?? [] + if (msg.role === 'system') { wire.push({ ...msg, - content: collectText(msg.parts), + content: parts.length > 0 ? collectText(parts) : (msg as unknown as { content?: string }).content ?? '', }) continue } @@ -63,13 +67,13 @@ export function uiMessagesToWire( if (msg.role === 'user') { wire.push({ ...msg, - content: collectUserContent(msg.parts), + content: parts.length > 0 ? collectUserContent(parts) : (msg as unknown as { content?: string }).content ?? '', }) continue } // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs - for (const part of msg.parts) { + for (const part of parts) { if (part.type === 'thinking') { wire.push({ role: 'reasoning', @@ -79,15 +83,15 @@ export function uiMessagesToWire( } } - const text = collectText(msg.parts) - const toolCalls = collectToolCalls(msg.parts) + const text = collectText(parts) + const toolCalls = collectToolCalls(parts) wire.push({ ...msg, ...(text !== '' && { content: text }), ...(toolCalls && { toolCalls }), }) - for (const part of msg.parts) { + for (const part of parts) { if (part.type === 'tool-result') { wire.push({ role: 'tool', From 6680b40f450746c40d78aafc333f0fee5fcf85d5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 17:02:32 +0200 Subject: [PATCH 10/29] feat(ai-client): generate threadId per session, runId per send, advertise client tools Adds threadId field to ChatClient (persisted across sends, configurable via ChatClientOptions.threadId) and builds a per-send runContext with a fresh runId and serialized clientTools advertised to the connection adapter. --- .../typescript/ai-client/src/chat-client.ts | 19 +++++++++++++++++++ packages/typescript/ai-client/src/types.ts | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index ab9d07dff..b0e5c0d49 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -30,6 +30,7 @@ export class ChatClient { private processor: StreamProcessor private connection: SubscribeConnectionAdapter private uniqueId: string + private threadId: string private body: Record = {} private pendingMessageBody: Record | undefined = undefined private isLoading = false @@ -81,6 +82,7 @@ export class ChatClient { constructor(options: ChatClientOptions) { this.uniqueId = options.id || this.generateUniqueId('chat') + this.threadId = options.threadId || this.generateUniqueId('thread') this.body = options.body || {} this.connection = normalizeConnectionAdapter(options.connection) this.events = new DefaultChatClientEventEmitter(this.uniqueId) @@ -605,11 +607,28 @@ export class ChatClient { // Set up promise that resolves when onStreamEnd fires const processingComplete = this.waitForProcessing() + // Build per-send run context for AG-UI compliance + // Note: mergedBody already contains the merged this.body + pendingMessageBody + // (pendingMessageBody was cleared above, so we use mergedBody as forwardedProps) + const runContext = { + threadId: this.threadId, + runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + clientTools: Array.from(this.clientToolsRef.current.values()).map( + (t) => ({ + name: t.name, + description: t.description, + parameters: t.inputSchema || { type: 'object' }, + }), + ), + forwardedProps: { ...mergedBody }, + } + // Send through normalized connection (pushes chunks to subscription queue) await this.connection.send( messages, mergedBody, this.abortController.signal, + runContext, ) // Wait for subscription loop to finish processing all chunks diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index b705ebbdf..9d5bf632e 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -204,6 +204,12 @@ export interface ChatClientOptions< */ id?: string + /** + * Thread ID to use for this chat session. Persists across sends within + * the session. If omitted, a unique thread ID is generated. + */ + threadId?: string + /** * Additional body parameters to send */ From a2febe25446854fc6fcdd67a35228a9790c288e7 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 17:15:16 +0200 Subject: [PATCH 11/29] feat(ai): accept UIMessage/ModelMessage in chat() messages; migrate ts-react-chat to RunAgentInput endpoint - Widen TextActivityOptions.messages to accept Array (function body already handled both at runtime) - Migrate examples/ts-react-chat to chatParamsFromRequestBody + mergeAgentTools --- .../ts-react-chat/src/routes/api.tanchat.ts | 67 ++++++++++++------- .../ai/src/activities/chat/index.ts | 20 ++++-- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index f571fd9c7..2e048e202 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -1,8 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, + chatParamsFromRequestBody, createChatOptions, maxIterations, + mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' @@ -12,7 +14,7 @@ import { geminiText } from '@tanstack/ai-gemini' import { openRouterText } from '@tanstack/ai-openrouter' import { grokText } from '@tanstack/ai-grok' import { groqText } from '@tanstack/ai-groq' -import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' +import type { AnyTextAdapter, ChatMiddleware, Tool } from '@tanstack/ai' import { addToCartToolDef, addToWishListToolDef, @@ -75,6 +77,22 @@ const addToCartToolServer = addToCartToolDef.server((args, context) => { } }) +const serverToolsList = [ + getGuitars, // Server tool + recommendGuitarToolDef, // No server execute - client will handle + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, + // Lazy tools - discovered on demand + compareGuitars, + calculateFinancing, + searchGuitars, +] + +const serverTools: Record = Object.fromEntries( + serverToolsList.map((t) => [t.name, t]), +) + const loggingMiddleware: ChatMiddleware = { name: 'logging', onConfig(ctx, config) { @@ -122,13 +140,26 @@ export const Route = createFileRoute('/api/tanchat')({ const abortController = new AbortController() - const body = await request.json() - const { messages, data } = body + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } - // Extract provider and model from data - const provider: Provider = data?.provider || 'openai' - const model: string = data?.model || 'gpt-4o' - const conversationId: string | undefined = data?.conversationId + // Extract provider and model from forwardedProps (sent by the client) + const provider: Provider = + typeof params.forwardedProps.provider === 'string' && + (params.forwardedProps.provider as Provider) + ? (params.forwardedProps.provider as Provider) + : 'openai' + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-4o' // Pre-define typed adapter configurations with full type inference // Model is passed to the adapter factory function for type-safe autocomplete @@ -191,28 +222,18 @@ export const Route = createFileRoute('/api/tanchat')({ // Get typed adapter options using createChatOptions pattern const options = adapterConfig[provider]() - // Note: We cast to AsyncIterable because all chat adapters - // return streams, but TypeScript sees a union of all possible return types + const mergedTools = mergeAgentTools(serverTools, params.tools) + const stream = chat({ ...options, - - tools: [ - getGuitars, // Server tool - recommendGuitarToolDef, // No server execute - client will handle - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - // Lazy tools - discovered on demand - compareGuitars, - calculateFinancing, - searchGuitars, - ], + tools: Object.values(mergedTools), middleware: [loggingMiddleware], systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, - conversationId, }) return toServerSentEventsResponse(stream, { abortController }) } catch (error: any) { diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 51a03e80b..e9eef21ad 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -45,6 +45,7 @@ import type { ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, + UIMessage, } from '../../types' import type { ChatMiddleware, @@ -82,12 +83,21 @@ export interface TextActivityOptions< > { /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */ adapter: TAdapter - /** Conversation messages - content types are constrained by the adapter's input modalities and metadata */ + /** + * Conversation messages. Accepts: + * - `ConstrainedModelMessage` — content types constrained by the adapter's input modalities. + * - `ModelMessage` — unconstrained model message (e.g., forwarded from an AG-UI wire payload). + * - `UIMessage` — parts-based UI representation; converted internally via `convertMessagesToModelMessages`. + * + * The three shapes can be mixed in a single array (e.g., when forwarding a wire payload that includes both anchor UIMessages and AG-UI fan-out ModelMessages). + */ messages?: Array< - ConstrainedModelMessage<{ - inputModalities: TAdapter['~types']['inputModalities'] - messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] - }> + | UIMessage + | ModelMessage + | ConstrainedModelMessage<{ + inputModalities: TAdapter['~types']['inputModalities'] + messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] + }> > /** System prompts to prepend to the conversation */ systemPrompts?: TextOptions['systemPrompts'] From 16ae25569f7da6cb86ae1fe68e22843c22c66377 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 17:33:13 +0200 Subject: [PATCH 12/29] chore(examples): migrate chat endpoints to AG-UI RunAgentInput Migrate ts-solid-chat and ts-svelte-chat server endpoints to use chatParamsFromRequestBody and mergeAgentTools, accepting RunAgentInput wire format. Switches provider selection from data.provider to params.forwardedProps.provider. Passes threadId and runId through to chat(). ts-group-chat uses Cap'n Web RPC WebSockets (no HTTP body parsing) and is intentionally skipped. --- examples/ts-solid-chat/src/routes/api.chat.ts | 31 ++++++++++-- .../src/routes/api/chat/+server.ts | 50 ++++++++++++++----- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/examples/ts-solid-chat/src/routes/api.chat.ts b/examples/ts-solid-chat/src/routes/api.chat.ts index 0b73e29be..fabdfb74a 100644 --- a/examples/ts-solid-chat/src/routes/api.chat.ts +++ b/examples/ts-solid-chat/src/routes/api.chat.ts @@ -1,8 +1,19 @@ import { createFileRoute } from '@tanstack/solid-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import type { Tool } from '@tanstack/ai' import { anthropicText } from '@tanstack/ai-anthropic' import { serverTools } from '@/lib/guitar-tools' +const serverToolsRecord: Record = Object.fromEntries( + serverTools.map((t) => [t.name, t]), +) + const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. CRITICAL INSTRUCTIONS - YOU MUST FOLLOW THIS EXACT WORKFLOW: @@ -53,15 +64,27 @@ export const Route = createFileRoute('/api/chat')({ const abortController = new AbortController() - const { messages } = await request.json() + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + try { + const mergedTools = mergeAgentTools(serverToolsRecord, params.tools) // Use the stream abort signal for proper cancellation handling const stream = chat({ adapter: anthropicText('claude-sonnet-4-5'), - tools: serverTools, + tools: Object.values(mergedTools), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, modelOptions: { thinking: { type: 'enabled', diff --git a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts index 6308af357..21a0de167 100644 --- a/examples/ts-svelte-chat/src/routes/api/chat/+server.ts +++ b/examples/ts-svelte-chat/src/routes/api/chat/+server.ts @@ -1,9 +1,12 @@ import { chat, + chatParamsFromRequestBody, createChatOptions, maxIterations, + mergeAgentTools, toServerSentEventsResponse, } from '@tanstack/ai' +import type { Tool } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' import { anthropicText } from '@tanstack/ai-anthropic' @@ -68,7 +71,7 @@ IMPORTANT: Example workflow: User: "I want an acoustic guitar" Step 1: Call getGuitars() -Step 2: Call recommendGuitar(id: "6") +Step 2: Call recommendGuitar(id: "6") Step 3: Done - do NOT add any text after calling recommendGuitar ` @@ -80,6 +83,18 @@ const addToCartToolServer = addToCartToolDef.server((args) => ({ totalItems: args.quantity, })) +const serverToolsList = [ + getGuitars, // Server tool + recommendGuitarToolDef, // No server execute - client will handle + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, +] + +const serverTools: Record = Object.fromEntries( + serverToolsList.map((t) => [t.name, t]), +) + export const POST: RequestHandler = async ({ request }) => { // Capture request signal before reading body (it may be aborted after body is consumed) const requestSignal = request.signal @@ -91,28 +106,37 @@ export const POST: RequestHandler = async ({ request }) => { const abortController = new AbortController() + let params try { - const body = await request.json() - const { messages, data } = body + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } - // Extract provider from data - const provider: Provider = data?.provider || 'openai' + // Extract provider from forwardedProps (sent by the client) + const provider: Provider = + typeof params.forwardedProps?.provider === 'string' && + params.forwardedProps.provider in adapterConfig + ? (params.forwardedProps.provider as Provider) + : 'openai' + try { // Get typed adapter options using createOptions pattern const options = adapterConfig[provider]() + const mergedTools = mergeAgentTools(serverTools, params.tools) + const stream = chat({ ...options, - tools: [ - getGuitars, // Server tool - recommendGuitarToolDef, // No server execute - client will handle - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - ], + tools: Object.values(mergedTools), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) From 7631d00e0af0438fec0fba0a6edab17cbbc6f360 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 17:53:44 +0200 Subject: [PATCH 13/29] test(e2e): migrate test endpoints to AG-UI RunAgentInput + add compliance spec Switch api.chat.ts, api.tools-test.ts, and api.middleware-test.ts from destructuring body.data.* (legacy shape) to chatParamsFromRequestBody(), extracting provider/feature/scenario/middlewareMode from params.forwardedProps. Pass params.threadId and params.runId through to chat() calls. Add ag-ui-compliance.spec.ts to assert the wire body has all RunAgentInput fields (threadId, runId, state, messages, tools, context, forwardedProps), that threadId persists across sends within a session, runId is fresh per send, and that user/assistant messages carry their parts arrays. --- testing/e2e/src/routes/api.chat.ts | 41 +++++++++--- testing/e2e/src/routes/api.middleware-test.ts | 34 ++++++---- testing/e2e/src/routes/api.tools-test.ts | 37 +++++++---- testing/e2e/tests/ag-ui-compliance.spec.ts | 62 +++++++++++++++++++ 4 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 testing/e2e/tests/ag-ui-compliance.spec.ts diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index 30a00f8cc..f65429b25 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -1,5 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' import type { Feature, Provider } from '@/lib/types' import { createTextAdapter } from '@/lib/providers' import { featureConfigs } from '@/lib/features' @@ -14,14 +19,28 @@ export const Route = createFileRoute('/api/chat')({ } const abortController = new AbortController() - const body = await request.json() - const { messages, data } = body - const provider: Provider = data?.provider || 'openai' - const feature: Feature = data?.feature || 'chat' - const testId: string | undefined = - typeof data?.testId === 'string' ? data.testId : undefined - const aimockPort: number | undefined = - data?.aimockPort != null ? Number(data.aimockPort) : undefined + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const fp = params.forwardedProps as Record + const provider: Provider = ( + typeof fp.provider === 'string' ? fp.provider : 'openai' + ) as Provider + const feature: Feature = ( + typeof fp.feature === 'string' ? fp.feature : 'chat' + ) as Feature + const testId = + typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined const config = featureConfigs[feature] const modelOverride = config.modelOverrides?.[provider] @@ -39,7 +58,9 @@ export const Route = createFileRoute('/api/chat')({ modelOptions: config.modelOptions, systemPrompts: ['You are a helpful assistant for a guitar store.'], agentLoopStrategy: maxIterations(5), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index a8c0def9b..ef75c316e 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, + chatParamsFromRequestBody, maxIterations, toServerSentEventsResponse, toolDefinition, @@ -51,18 +52,27 @@ export const Route = createFileRoute('/api/middleware-test')({ if (request.signal?.aborted) return new Response(null, { status: 499 }) const abortController = new AbortController() + let params try { - const body = await request.json() - const messages = body.messages - const scenario = body.data?.scenario || 'basic-text' - const middlewareMode = body.data?.middlewareMode || 'none' - const testId: string | undefined = - typeof body.data?.testId === 'string' ? body.data.testId : undefined - const aimockPort: number | undefined = - body.data?.aimockPort != null - ? Number(body.data.aimockPort) - : undefined + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + const fp = params.forwardedProps as Record + const scenario = + typeof fp.scenario === 'string' ? fp.scenario : 'basic-text' + const middlewareMode = + typeof fp.middlewareMode === 'string' ? fp.middlewareMode : 'none' + const testId: string | undefined = + typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort: number | undefined = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + try { const adapterOptions = createTextAdapter( 'openai', undefined, @@ -81,9 +91,11 @@ export const Route = createFileRoute('/api/middleware-test')({ const stream = chat({ ...adapterOptions, - messages, + messages: params.messages, tools, middleware, + threadId: params.threadId, + runId: params.runId, agentLoopStrategy: maxIterations(10), abortController, }) diff --git a/testing/e2e/src/routes/api.tools-test.ts b/testing/e2e/src/routes/api.tools-test.ts index 5dbd8da8a..403ad12c7 100644 --- a/testing/e2e/src/routes/api.tools-test.ts +++ b/testing/e2e/src/routes/api.tools-test.ts @@ -1,5 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' import { createTextAdapter } from '@/lib/providers' import { getToolsForScenario } from '@/lib/tools-test-tools' @@ -15,17 +20,25 @@ export const Route = createFileRoute('/api/tools-test')({ const abortController = new AbortController() + let params try { - const body = await request.json() - const messages = body.messages - const scenario = body.data?.scenario || body.scenario || 'text-only' - const testId: string | undefined = - typeof body.data?.testId === 'string' ? body.data.testId : undefined - const aimockPort: number | undefined = - body.data?.aimockPort != null - ? Number(body.data.aimockPort) - : undefined + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + const fp = params.forwardedProps as Record + const scenario = + typeof fp.scenario === 'string' ? fp.scenario : 'text-only' + const testId: string | undefined = + typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort: number | undefined = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + + try { // Special error scenario: return a stream that immediately errors if (scenario === 'error') { const errorStream = (async function* () { @@ -57,8 +70,10 @@ export const Route = createFileRoute('/api/tools-test')({ const stream = chat({ ...adapterOptions, - messages, + messages: params.messages, tools, + threadId: params.threadId, + runId: params.runId, agentLoopStrategy: maxIterations(20), abortController, }) diff --git a/testing/e2e/tests/ag-ui-compliance.spec.ts b/testing/e2e/tests/ag-ui-compliance.spec.ts new file mode 100644 index 000000000..53c9250b4 --- /dev/null +++ b/testing/e2e/tests/ag-ui-compliance.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from './fixtures' +import { sendMessage, waitForResponse, featureUrl } from './helpers' + +test.describe('AG-UI client-to-server compliance', () => { + test('POST body has RunAgentInput shape and persists threadId across sends', async ({ + page, + testId, + aimockPort, + }) => { + const requestBodies: Array = [] + page.on('request', (request) => { + if (request.url().includes('/api/chat') && request.method() === 'POST') { + const body = request.postDataJSON() + if (body) requestBodies.push(body) + } + }) + + await page.goto(featureUrl('openai', 'chat', testId, aimockPort)) + + // Send first message + await sendMessage(page, '[chat] hello') + await waitForResponse(page) + + // Send second message in the same session + await sendMessage(page, '[chat] another message') + await waitForResponse(page) + + expect(requestBodies.length).toBeGreaterThanOrEqual(2) + + const first = requestBodies[0]! + const second = requestBodies[1]! + + // Wire shape: every field required by RunAgentInput must be present + for (const body of [first, second]) { + expect(body).toHaveProperty('threadId') + expect(body).toHaveProperty('runId') + expect(body).toHaveProperty('state') + expect(body).toHaveProperty('messages') + expect(body).toHaveProperty('tools') + expect(body).toHaveProperty('context') + expect(body).toHaveProperty('forwardedProps') + expect(Array.isArray(body.messages)).toBe(true) + expect(Array.isArray(body.tools)).toBe(true) + } + + // threadId continuity: same session → same threadId + expect(first.threadId).toBe(second.threadId) + + // runId freshness: each send generates a new runId + expect(first.runId).not.toBe(second.runId) + + // Anchor messages carry `parts` (re-attached by chatParamsFromRequestBody) + const anchors = second.messages.filter( + (m: any) => m.role === 'user' || m.role === 'system' || m.role === 'assistant', + ) + expect(anchors.length).toBeGreaterThan(0) + for (const a of anchors) { + expect(a).toHaveProperty('parts') + expect(Array.isArray(a.parts)).toBe(true) + } + }) +}) From 49490382c5c7527c8058d4a1cde73f14f59ee01c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 18:08:42 +0200 Subject: [PATCH 14/29] test(e2e): add foreign AG-UI client and legacy wire shape rejection specs --- .../e2e/tests/ag-ui-foreign-client.spec.ts | 65 +++++++++++++++++++ .../tests/ag-ui-old-client-rejection.spec.ts | 19 ++++++ 2 files changed, 84 insertions(+) create mode 100644 testing/e2e/tests/ag-ui-foreign-client.spec.ts create mode 100644 testing/e2e/tests/ag-ui-old-client-rejection.spec.ts diff --git a/testing/e2e/tests/ag-ui-foreign-client.spec.ts b/testing/e2e/tests/ag-ui-foreign-client.spec.ts new file mode 100644 index 000000000..b7a19b252 --- /dev/null +++ b/testing/e2e/tests/ag-ui-foreign-client.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from './fixtures' + +test.describe('AG-UI foreign client compatibility', () => { + test('TanStack server accepts pure RunAgentInput with fan-out tool messages', async ({ + request, + testId, + aimockPort, + }) => { + const body = { + threadId: 'thread-foreign-1', + runId: 'run-foreign-1', + state: {}, + messages: [ + { id: 'u1', role: 'user', content: '[chat] recommend a guitar' }, + ], + tools: [], + context: [], + forwardedProps: { + provider: 'openai', + feature: 'chat', + testId, + aimockPort, + }, + } + const response = await request.post('/api/chat', { + data: body, + headers: { 'Content-Type': 'application/json' }, + }) + expect( + response.ok(), + `expected 200, got ${response.status()}: ${await response.text()}`, + ).toBe(true) + const text = await response.text() + expect(text).toContain('RUN_FINISHED') + }) + + test('developer role is collapsed to system without breaking the run', async ({ + request, + testId, + aimockPort, + }) => { + const body = { + threadId: 'thread-foreign-2', + runId: 'run-foreign-2', + state: {}, + messages: [ + { id: 'd1', role: 'developer', content: 'You only speak in haiku.' }, + { id: 'u1', role: 'user', content: '[chat] recommend a guitar' }, + ], + tools: [], + context: [], + forwardedProps: { + provider: 'openai', + feature: 'chat', + testId, + aimockPort, + }, + } + const response = await request.post('/api/chat', { + data: body, + headers: { 'Content-Type': 'application/json' }, + }) + expect(response.ok()).toBe(true) + }) +}) diff --git a/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts b/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts new file mode 100644 index 000000000..694a373fc --- /dev/null +++ b/testing/e2e/tests/ag-ui-old-client-rejection.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from './fixtures' + +test('legacy {messages, data} wire shape is rejected with a migration-pointing error', async ({ + request, +}) => { + const oldBody = { + messages: [ + { id: 'u1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ], + data: {}, + } + const response = await request.post('/api/chat', { + data: oldBody, + headers: { 'Content-Type': 'application/json' }, + }) + expect(response.status()).toBe(400) + const body = await response.text() + expect(body).toMatch(/AG-UI|RunAgentInput|migration/i) +}) From 21a0f9026830a9d7e241a40d81a1b969880cd906 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 18:14:31 +0200 Subject: [PATCH 15/29] docs: add AG-UI client compliance migration guide --- docs/config.json | 4 + docs/migration/ag-ui-compliance.md | 180 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 docs/migration/ag-ui-compliance.md diff --git a/docs/config.json b/docs/config.json index f24a5fa0a..db2ce2413 100644 --- a/docs/config.json +++ b/docs/config.json @@ -211,6 +211,10 @@ { "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai" + }, + { + "label": "AG-UI Client Compliance", + "to": "migration/ag-ui-compliance" } ] }, diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md new file mode 100644 index 000000000..67ec4fb6b --- /dev/null +++ b/docs/migration/ag-ui-compliance.md @@ -0,0 +1,180 @@ +--- +title: Migrating to AG-UI Client-to-Server Compliance +--- + +# Migrating to AG-UI Client-to-Server Compliance + +> **TL;DR:** Upgrade `@tanstack/ai` and `@tanstack/ai-client` together. The HTTP wire format changed to AG-UI `RunAgentInput`. Server endpoints must use `chatParamsFromRequestBody` and `mergeAgentTools`. Clients have nothing to do — `useChat` and the connection adapters handle the new format internally. + +## What changed + +`@tanstack/ai-client` now POSTs an AG-UI 0.0.52 `RunAgentInput` request body. The previous shape (`{ messages, data, ...optionsBody }`) is no longer accepted by `@tanstack/ai`'s server helpers. + +### Old wire shape + +```json +{ + "messages": [...], + "data": {...} +} +``` + +### New wire shape + +```json +{ + "threadId": "thread-...", + "runId": "run-...", + "state": {}, + "messages": [...], + "tools": [...], + "context": [], + "forwardedProps": {...} +} +``` + +The `messages` array carries TanStack `UIMessage` anchors with `parts` intact, plus AG-UI mirror fields (`content`, `toolCalls`) so strict AG-UI servers can parse it. Tool results and thinking parts are additionally emitted as separate `{role:'tool',...}` and `{role:'reasoning',...}` fan-out messages alongside the anchors. + +## Server endpoint upgrade + +### Before + +```ts +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +export async function POST(req: Request) { + const { messages } = await req.json() + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + tools: serverTools, + }) + return toServerSentEventsResponse(stream) +} +``` + +### After + +```ts +import { + chat, + chatParamsFromRequestBody, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' + +export async function POST(req: Request) { + let params + try { + params = await chatParamsFromRequestBody(await req.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), + threadId: params.threadId, + runId: params.runId, + + // Explicitly allowlist forwardedProps — see security note below + temperature: + typeof params.forwardedProps.temperature === 'number' + ? params.forwardedProps.temperature + : undefined, + maxTokens: + typeof params.forwardedProps.maxTokens === 'number' + ? params.forwardedProps.maxTokens + : undefined, + }) + + return toServerSentEventsResponse(stream) +} +``` + +`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core` and throws an `AGUIError` on a malformed shape. Surface this as HTTP 400. + +## `forwardedProps` security + +`forwardedProps` is arbitrary client-controlled JSON. **Do not** spread it directly into `chat({...})`: + +```ts +// 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything +chat({ + adapter: openaiText('gpt-4o'), + ...params, + ...params.forwardedProps, +}) +``` + +Always destructure the specific fields you intend to forward: + +```ts +// ✅ SAFE — explicit allowlist +chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), + threadId: params.threadId, + runId: params.runId, + temperature: typeof params.forwardedProps.temperature === 'number' + ? params.forwardedProps.temperature + : undefined, +}) +``` + +## Client-side: nothing to do + +`useChat` and the connection adapters (`fetchServerSentEvents`, `fetchHttpStream`) handle the new wire format internally. Existing `UIMessage` state is unchanged. `clientTools(...)` declarations are now automatically advertised to the server in the request payload. + +If you instantiated a `ChatClient` directly and want to control the thread identifier, pass `threadId` via the constructor options: + +```ts +const client = new ChatClient({ + threadId: 'persistent-thread-from-storage', + connection: fetchServerSentEvents('/api/chat'), + tools: [/* clientTools */], +}) +``` + +If you don't pass `threadId`, one is generated automatically and persists for the lifetime of the `ChatClient` instance. A fresh `runId` is generated for every send. + +## Tool-merge semantics + +- **Server tools win on name collision.** A tool registered server-side via `toolDefinition().server(...)` always executes server-side. +- **Client-only tools become no-execute stubs** in `chat()`. The runtime emits a `ClientToolRequest` event back to the client; the client's registered handler (via `clientTools(...)`) executes locally and posts the result. +- **Dual-handler (both have it):** server executes, then `chat-client.ts`'s `onToolCall` fires the client's handler as a UI side-effect when the streamed tool result event arrives. The server's result is authoritative for the conversation. + +## Talking to a foreign AG-UI server + +A `@tanstack/ai-client` request hitting a foreign AG-UI server: + +- ✅ Single-turn user messages work — content is mirrored to AG-UI's `content` field. +- ✅ Server-emitted events stream and render correctly. +- ✅ Multi-turn history that includes tool results from prior turns: the foreign server reads them via the AG-UI fan-out duplicates we send (separate `{role:'tool',...}` messages). +- ⚠️ Client-only tools are sent in the AG-UI `tools` field; whether the foreign server actually invokes them depends on its tool-calling logic. + +## Talking to a TanStack server from a foreign AG-UI client + +Pure AG-UI `RunAgentInput` payloads (no TanStack `parts` field) work end-to-end: + +- Tool messages pass through as `ModelMessage` entries with `role: 'tool'`. +- `reasoning` messages are dropped (no LLM-replay equivalent today). +- `activity` messages are dropped (no TanStack equivalent). +- `developer` messages are collapsed to `system` role. + +## `@ag-ui/core` bump + +`@tanstack/ai` now depends on `@ag-ui/core@^0.0.52`. If your code imports types from `@tanstack/ai` that re-export AG-UI types, you may need minor type adjustments — see the changeset for specifics. + +## Out of scope (existing behavior preserved) + +- **Reasoning replay to LLM providers.** TanStack still drops `ThinkingPart` at the `UIMessage`→`ModelMessage` boundary (pre-existing behavior). Providers like Anthropic that require thinking blocks to be replayed for extended thinking continuation remain a separate concern, tracked outside this migration. +- **AG-UI `state` and `context` fields.** Surfaced on `chatParamsFromRequestBody`'s return value but not yet wired into `chat()`. They're available for your endpoint to inspect/forward, but the runtime ignores them. +- **PHP and Python server packages.** No `chatParamsFromRequestBody` parity yet. Their examples temporarily lag on the old shape until the matching helpers ship. From e8e865a10bba99112005c7be6ec7f215fc9bdfc5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 18:25:27 +0200 Subject: [PATCH 16/29] docs(ai): document client-to-server AG-UI compliance and wire format --- .../typescript/ai/docs/chat-architecture.md | 52 ++++++++++++++----- .../ai/skills/ai-core/ag-ui-protocol/SKILL.md | 43 +++++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/typescript/ai/docs/chat-architecture.md b/packages/typescript/ai/docs/chat-architecture.md index 5625a6ab5..c0693bb45 100644 --- a/packages/typescript/ai/docs/chat-architecture.md +++ b/packages/typescript/ai/docs/chat-architecture.md @@ -9,18 +9,19 @@ ## Table of Contents 1. [System Overview](#system-overview) -2. [Single-Shot Text Response](#single-shot-text-response) -3. [Single-Shot Tool Call Response](#single-shot-tool-call-response) -4. [Parallel Tool Calls (Single Shot)](#parallel-tool-calls-single-shot) -5. [Text-Then-Tool Interleaving (Single Shot)](#text-then-tool-interleaving-single-shot) -6. [Thinking/Reasoning Content](#thinkingreasoning-content) -7. [Tool Results and the TOOL_CALL_END Dual Role](#tool-results-and-the-tool_call_end-dual-role) -8. [Client Tools and Approval Flows](#client-tools-and-approval-flows) -9. [Multi-Iteration Agent Loop](#multi-iteration-agent-loop) -10. [Adapter Contract](#adapter-contract) -11. [StreamProcessor Internal State](#streamprocessor-internal-state) -12. [UIMessage Part Ordering Invariants](#uimessage-part-ordering-invariants) -13. [Testing Strategy](#testing-strategy) +2. [Wire format (HTTP body)](#wire-format-http-body) +3. [Single-Shot Text Response](#single-shot-text-response) +4. [Single-Shot Tool Call Response](#single-shot-tool-call-response) +5. [Parallel Tool Calls (Single Shot)](#parallel-tool-calls-single-shot) +6. [Text-Then-Tool Interleaving (Single Shot)](#text-then-tool-interleaving-single-shot) +7. [Thinking/Reasoning Content](#thinkingreasoning-content) +8. [Tool Results and the TOOL_CALL_END Dual Role](#tool-results-and-the-tool_call_end-dual-role) +9. [Client Tools and Approval Flows](#client-tools-and-approval-flows) +10. [Multi-Iteration Agent Loop](#multi-iteration-agent-loop) +11. [Adapter Contract](#adapter-contract) +12. [StreamProcessor Internal State](#streamprocessor-internal-state) +13. [UIMessage Part Ordering Invariants](#uimessage-part-ordering-invariants) +14. [Testing Strategy](#testing-strategy) --- @@ -59,6 +60,33 @@ Both trust the adapter to emit events in the correct order. The processor does * --- +## Wire format (HTTP body) + +The HTTP body posted by `@tanstack/ai-client` to a `chat()` endpoint is the AG-UI `RunAgentInput` shape from `@ag-ui/core`: + +```json +{ + "threadId": "thread-...", + "runId": "run-...", + "state": {}, + "messages": [...], + "tools": [...], + "context": [], + "forwardedProps": {} +} +``` + +Each entry in `messages` is either: + +- A **TanStack anchor** — a `UIMessage` with its canonical `parts` array, augmented with AG-UI mirror fields (`content` for system/user/assistant; `toolCalls` for assistant) so AG-UI Zod parsing succeeds. +- An **AG-UI fan-out duplicate** — a `{role:'tool',...}` or `{role:'reasoning',...}` entry generated from each `ToolResultPart`/`ThinkingPart` on the assistant anchor. Strict AG-UI server consumers walk these role-based messages directly. + +On the server, `chatParamsFromRequestBody` validates the body and returns the parsed fields. `convertMessagesToModelMessages` (called inside `chat()`) handles dedup: when an anchor's `parts` already contain a `tool-result`, the matching fan-out tool message is dropped from the `ModelMessage[]` fed to the LLM. `reasoning` and `activity` messages are dropped (no `ModelMessage` equivalent today); `developer` messages collapse to `system`. + +For the migration story when upgrading, see `docs/migration/ag-ui-compliance.md`. + +--- + ## Single-Shot Text Response The simplest possible flow. The model returns text with no tool calls. diff --git a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 561843174..688534c12 100644 --- a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -39,6 +39,49 @@ export async function POST(request: Request) { typed AG-UI event (discriminated union on `type`). The `toServerSentEventsResponse()` helper encodes that iterable into an SSE-formatted `Response` with correct headers. +## Setup — Receiving AG-UI RunAgentInput on the Server + +```typescript +import { + chat, + chatParamsFromRequestBody, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai/adapters' +import { serverTools } from './tools' + +export async function POST(req: Request) { + let params + try { + params = await chatParamsFromRequestBody(await req.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), + threadId: params.threadId, + runId: params.runId, + }) + + return toServerSentEventsResponse(stream) +} +``` + +`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core`. `mergeAgentTools` merges the server's tool registry with client-declared tools (server wins on collision; client-only tools become no-execute stubs that flow through the runtime's `ClientToolRequest` path). + +`params.messages` is a mixed array of TanStack `UIMessage` anchors (with `parts`) and AG-UI fan-out duplicates (`{role:'tool',...}`, `{role:'reasoning',...}`). The existing `convertMessagesToModelMessages` (called inside `chat()`) handles dedup automatically. + +**Wire shape (POST body):** AG-UI `RunAgentInput` — `{threadId, runId, parentRunId?, state, messages, tools, context, forwardedProps}`. The `messages` array carries TanStack `UIMessage` anchors with their canonical `parts` plus AG-UI mirror fields (`content`, `toolCalls`) inline; tool results and thinking parts are additionally emitted as fan-out `{role:'tool',...}` and `{role:'reasoning',...}` entries. + +**`forwardedProps` security:** Don't spread it directly into `chat()` — clients could override `adapter`, `model`, `tools`, etc. Always allowlist specific fields. + ## Core Patterns ### 1. SSE Format — toServerSentEventsStream / toServerSentEventsResponse From 2c3cf395077bd0c5c3ca672afaaedcf4569f3743 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 18:34:44 +0200 Subject: [PATCH 17/29] changeset: AG-UI client-to-server compliance (breaking) --- .changeset/ag-ui-client-compliance.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/ag-ui-client-compliance.md diff --git a/.changeset/ag-ui-client-compliance.md b/.changeset/ag-ui-client-compliance.md new file mode 100644 index 000000000..539d96d7e --- /dev/null +++ b/.changeset/ag-ui-client-compliance.md @@ -0,0 +1,23 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-react-ui': minor +--- + +**Breaking:** AG-UI client-to-server compliance. + +`@tanstack/ai-client` now POSTs an AG-UI `RunAgentInput` request body and `@tanstack/ai` server endpoints must use the new `chatParamsFromRequestBody` + `mergeAgentTools` helpers. Upgrade both packages together. + +Highlights: +- **Wire format**: `{threadId, runId, state, messages, tools, context, forwardedProps}` (per AG-UI 0.0.52 `RunAgentInputSchema`) instead of `{messages, data}`. +- **New server helpers** exported from `@tanstack/ai`: `chatParamsFromRequestBody`, `mergeAgentTools`. +- **`chat()` accepts `threadId`, `runId`, `parentRunId`** as optional fields for AG-UI run correlation. +- **`ChatClient` accepts `threadId`** option; auto-generates and persists per session if omitted; fresh `runId` per send. +- **Client tools auto-advertised** to the server via `RunAgentInput.tools`. +- **Foreign AG-UI clients** can hit a TanStack server: `developer` collapses to `system`, `reasoning`/`activity` drop. + +See `docs/migration/ag-ui-compliance.md` for full migration steps. From 154d97c5c4aa2a6d22ced874b6eaf7aae64650d7 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 27 Apr 2026 19:31:10 +0200 Subject: [PATCH 18/29] fix(ai): migrate vue example, validate parts shape, refresh skill tension --- examples/ts-vue-chat/vite.config.ts | 43 +++++++++++++------ .../ai/skills/ai-core/ag-ui-protocol/SKILL.md | 8 ++-- .../ai/src/utilities/chat-params.ts | 23 +++++++++- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/examples/ts-vue-chat/vite.config.ts b/examples/ts-vue-chat/vite.config.ts index 74c3563f1..42140d0a2 100644 --- a/examples/ts-vue-chat/vite.config.ts +++ b/examples/ts-vue-chat/vite.config.ts @@ -2,7 +2,13 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' -import { chat, maxIterations, toServerSentEventsStream } from '@tanstack/ai' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + mergeAgentTools, + toServerSentEventsStream, +} from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' @@ -196,10 +202,23 @@ export default defineConfig({ body += chunk } + let params try { - const { messages, data } = JSON.parse(body) - const provider: Provider = data?.provider || 'openai' - const model: string | undefined = data?.model + params = await chatParamsFromRequestBody(JSON.parse(body)) + } catch (error) { + res.statusCode = 400 + res.end(error instanceof Error ? error.message : 'Bad request') + return + } + + try { + const fp = params.forwardedProps as Record + const provider: Provider = + typeof fp.provider === 'string' + ? (fp.provider as Provider) + : 'openai' + const model: string | undefined = + typeof fp.model === 'string' ? fp.model : undefined let adapter @@ -231,18 +250,18 @@ export default defineConfig({ const abortController = new AbortController() + const serverTools = Object.fromEntries( + [getGuitars, addToCartToolServer].map((t) => [t.name, t]), + ) + const stream = chat({ adapter, - tools: [ - getGuitars, - recommendGuitarToolDef, - addToCartToolServer, - addToWishListToolDef, - getPersonalGuitarPreferenceToolDef, - ], + tools: Object.values(mergeAgentTools(serverTools, params.tools)), systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), - messages, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, abortController, }) diff --git a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 688534c12..236ff887d 100644 --- a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -266,9 +266,11 @@ Source: docs/protocol/chunk-definitions.md ## Tension -HIGH Tension: AG-UI protocol compliance vs. internal message format -- TanStack -AI's `UIMessage` format (parts-based) diverges from AG-UI spec (content-based). -Full compliance would require a different message structure. +RESOLVED: TanStack AI is fully AG-UI compliant on both axes (server→client events +AND client→server `RunAgentInput`). The wire format carries TanStack `UIMessage` +anchors with their parts intact alongside AG-UI fan-out messages, so strict AG-UI +servers see role-based messages while TanStack-aware servers read parts directly +without transformation. See `docs/migration/ag-ui-compliance.md` for details. ## Cross-References diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index 2c29822d7..15620e82a 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -2,6 +2,27 @@ import { AGUIError, RunAgentInputSchema } from '@ag-ui/core' import type { Context as AGUIContext } from '@ag-ui/core' import type { JSONSchema, ModelMessage, Tool, UIMessage } from '../types' +const KNOWN_PART_TYPES = new Set([ + 'text', + 'image', + 'audio', + 'video', + 'document', + 'tool-call', + 'tool-result', + 'thinking', +]) + +function isValidParts(value: unknown): value is Array<{ type: string }> { + if (!Array.isArray(value)) return false + for (const p of value) { + if (!p || typeof p !== 'object') return false + const type = (p as { type?: unknown }).type + if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false + } + return true +} + /** * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`. * @@ -44,7 +65,7 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{ const rawMessages = (body as { messages?: Array> }).messages ?? [] const messages = parsed.messages.map((m, i) => { const raw = rawMessages[i] - if (raw && typeof raw === 'object' && 'parts' in raw) { + if (raw && typeof raw === 'object' && 'parts' in raw && isValidParts(raw.parts)) { return { ...m, parts: raw.parts } as UIMessage | ModelMessage } return m as ModelMessage From 8b1cdb6d434f47100649e5ccc06ba505ee6aa327 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:43:49 +0000 Subject: [PATCH 19/29] ci: apply automated fixes --- .changeset/ag-ui-client-compliance.md | 1 + .../ai-client/src/connection-adapters.ts | 7 ++- .../typescript/ai/src/utilities/ag-ui-wire.ts | 25 +++++---- .../ai/src/utilities/chat-params.ts | 10 +++- .../typescript/ai/tests/ag-ui-wire.test.ts | 52 +++++++++++++++---- .../typescript/ai/tests/chat-params.test.ts | 14 +++-- packages/typescript/ai/tests/messages.test.ts | 11 +++- testing/e2e/src/routes/api.chat.ts | 3 +- testing/e2e/tests/ag-ui-compliance.spec.ts | 3 +- 9 files changed, 96 insertions(+), 30 deletions(-) diff --git a/.changeset/ag-ui-client-compliance.md b/.changeset/ag-ui-client-compliance.md index 539d96d7e..679fac06c 100644 --- a/.changeset/ag-ui-client-compliance.md +++ b/.changeset/ag-ui-client-compliance.md @@ -13,6 +13,7 @@ `@tanstack/ai-client` now POSTs an AG-UI `RunAgentInput` request body and `@tanstack/ai` server endpoints must use the new `chatParamsFromRequestBody` + `mergeAgentTools` helpers. Upgrade both packages together. Highlights: + - **Wire format**: `{threadId, runId, state, messages, tools, context, forwardedProps}` (per AG-UI 0.0.52 `RunAgentInputSchema`) instead of `{messages, data}`. - **New server helpers** exported from `@tanstack/ai`: `chatParamsFromRequestBody`, `mergeAgentTools`. - **`chat()` accepts `threadId`, `runId`, `parentRunId`** as optional fields for AG-UI run correlation. diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 9014c7265..3eb7be432 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -202,7 +202,12 @@ export function normalizeConnectionAdapter( async send(messages, data, abortSignal, runContext) { let hasTerminalEvent = false try { - const stream = connection.connect(messages, data, abortSignal, runContext) + const stream = connection.connect( + messages, + data, + abortSignal, + runContext, + ) for await (const chunk of stream) { if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { hasTerminalEvent = true diff --git a/packages/typescript/ai/src/utilities/ag-ui-wire.ts b/packages/typescript/ai/src/utilities/ag-ui-wire.ts index 631492555..f7abd3606 100644 --- a/packages/typescript/ai/src/utilities/ag-ui-wire.ts +++ b/packages/typescript/ai/src/utilities/ag-ui-wire.ts @@ -1,9 +1,4 @@ -import type { - ContentPart, - MessagePart, - TextPart, - UIMessage, -} from '../types' +import type { ContentPart, MessagePart, TextPart, UIMessage } from '../types' type AGUITextInputContent = { type: 'text'; text: string } type AGUIInputContent = @@ -35,7 +30,10 @@ type WireAnchorMessage = UIMessage & { toolCalls?: Array } -export type WireMessage = WireAnchorMessage | AGUIToolMessage | AGUIReasoningMessage +export type WireMessage = + | WireAnchorMessage + | AGUIToolMessage + | AGUIReasoningMessage /** * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages` @@ -54,12 +52,16 @@ export function uiMessagesToWire( for (const msg of messages) { // Defensive: if parts is missing (ModelMessage-shaped input), pass through as-is. // UIMessage always has parts; ModelMessage uses content directly. - const parts: ReadonlyArray = (msg.parts as ReadonlyArray | undefined) ?? [] + const parts: ReadonlyArray = + (msg.parts as ReadonlyArray | undefined) ?? [] if (msg.role === 'system') { wire.push({ ...msg, - content: parts.length > 0 ? collectText(parts) : (msg as unknown as { content?: string }).content ?? '', + content: + parts.length > 0 + ? collectText(parts) + : ((msg as unknown as { content?: string }).content ?? ''), }) continue } @@ -67,7 +69,10 @@ export function uiMessagesToWire( if (msg.role === 'user') { wire.push({ ...msg, - content: parts.length > 0 ? collectUserContent(parts) : (msg as unknown as { content?: string }).content ?? '', + content: + parts.length > 0 + ? collectUserContent(parts) + : ((msg as unknown as { content?: string }).content ?? ''), }) continue } diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index 15620e82a..d5afc08c2 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -62,10 +62,16 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{ // AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are // dropped during parse. We re-attach them from the original body so the // existing UIMessage path inside `chat()` can use them directly. - const rawMessages = (body as { messages?: Array> }).messages ?? [] + const rawMessages = + (body as { messages?: Array> }).messages ?? [] const messages = parsed.messages.map((m, i) => { const raw = rawMessages[i] - if (raw && typeof raw === 'object' && 'parts' in raw && isValidParts(raw.parts)) { + if ( + raw && + typeof raw === 'object' && + 'parts' in raw && + isValidParts(raw.parts) + ) { return { ...m, parts: raw.parts } as UIMessage | ModelMessage } return m as ModelMessage diff --git a/packages/typescript/ai/tests/ag-ui-wire.test.ts b/packages/typescript/ai/tests/ag-ui-wire.test.ts index 25ee128a2..7de0fc0a0 100644 --- a/packages/typescript/ai/tests/ag-ui-wire.test.ts +++ b/packages/typescript/ai/tests/ag-ui-wire.test.ts @@ -5,7 +5,11 @@ import type { UIMessage } from '../src/types' describe('uiMessagesToWire', () => { it('mirrors a system UIMessage to a string content field', () => { const messages: Array = [ - { id: 's1', role: 'system', parts: [{ type: 'text', content: 'You are helpful' }] }, + { + id: 's1', + role: 'system', + parts: [{ type: 'text', content: 'You are helpful' }], + }, ] const wire = uiMessagesToWire(messages) expect(wire).toHaveLength(1) @@ -35,7 +39,11 @@ describe('uiMessagesToWire', () => { { type: 'text', content: 'look at this' }, { type: 'image', - source: { type: 'url', value: 'https://example.com/cat.png', mimeType: 'image/png' }, + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, }, ], }, @@ -44,10 +52,17 @@ describe('uiMessagesToWire', () => { expect(wire).toHaveLength(1) expect(Array.isArray((wire[0]! as any).content)).toBe(true) expect((wire[0]! as any).content).toHaveLength(2) - expect((wire[0]! as any).content[0]).toEqual({ type: 'text', text: 'look at this' }) + expect((wire[0]! as any).content[0]).toEqual({ + type: 'text', + text: 'look at this', + }) expect((wire[0]! as any).content[1]).toMatchObject({ type: 'image', - source: { type: 'url', value: 'https://example.com/cat.png', mimeType: 'image/png' }, + source: { + type: 'url', + value: 'https://example.com/cat.png', + mimeType: 'image/png', + }, }) }) @@ -58,8 +73,19 @@ describe('uiMessagesToWire', () => { role: 'assistant', parts: [ { type: 'text', content: 'ok' }, - { type: 'tool-call', id: 'tc1', name: 'getTodos', arguments: '{}', state: 'input-complete' }, - { type: 'tool-result', toolCallId: 'tc1', content: '[]', state: 'complete' }, + { + type: 'tool-call', + id: 'tc1', + name: 'getTodos', + arguments: '{}', + state: 'input-complete', + }, + { + type: 'tool-result', + toolCallId: 'tc1', + content: '[]', + state: 'complete', + }, ], }, ] @@ -71,7 +97,11 @@ describe('uiMessagesToWire', () => { role: 'assistant', content: 'ok', toolCalls: [ - { id: 'tc1', type: 'function', function: { name: 'getTodos', arguments: '{}' } }, + { + id: 'tc1', + type: 'function', + function: { name: 'getTodos', arguments: '{}' }, + }, ], }) // Fan-out tool message @@ -96,7 +126,11 @@ describe('uiMessagesToWire', () => { const wire = uiMessagesToWire(messages) expect(wire).toHaveLength(2) expect(wire[0]!).toMatchObject({ role: 'reasoning', content: 'pondering' }) - expect(wire[1]!).toMatchObject({ id: 'a1', role: 'assistant', content: 'answer' }) + expect(wire[1]!).toMatchObject({ + id: 'a1', + role: 'assistant', + content: 'answer', + }) }) it('preserves the original `parts` array on every anchor message', () => { @@ -122,7 +156,7 @@ describe('uiMessagesToWire', () => { }, ] const wire = uiMessagesToWire(messages) - const partOnAnchor = ((wire[0]! as any).parts)[0] + const partOnAnchor = (wire[0]! as any).parts[0] expect(partOnAnchor.metadata).toEqual({ detail: 'high' }) }) }) diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts index 0414ad027..0c984288b 100644 --- a/packages/typescript/ai/tests/chat-params.test.ts +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest' -import { chatParamsFromRequestBody, mergeAgentTools } from '../src/utilities/chat-params' +import { + chatParamsFromRequestBody, + mergeAgentTools, +} from '../src/utilities/chat-params' describe('chatParamsFromRequestBody', () => { const validBody = { @@ -52,7 +55,9 @@ describe('chatParamsFromRequestBody', () => { it('rejects the legacy {messages, data} shape with a migration-pointing error', async () => { const oldBody = { - messages: [{ id: 'm1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }], + messages: [ + { id: 'm1', role: 'user', parts: [{ type: 'text', content: 'hi' }] }, + ], data: {}, } await expect(chatParamsFromRequestBody(oldBody)).rejects.toThrow( @@ -88,7 +93,10 @@ describe('mergeAgentTools', () => { const result = mergeAgentTools(server, client) expect(Object.keys(result)).toEqual(['showToast']) expect(result['showToast']!.execute).toBeUndefined() - expect(result['showToast']!.inputSchema).toEqual({ type: 'object', properties: {} }) + expect(result['showToast']!.inputSchema).toEqual({ + type: 'object', + properties: {}, + }) expect(result['showToast']!.description).toBe('render a toast') }) diff --git a/packages/typescript/ai/tests/messages.test.ts b/packages/typescript/ai/tests/messages.test.ts index 2a6216901..025e33b8a 100644 --- a/packages/typescript/ai/tests/messages.test.ts +++ b/packages/typescript/ai/tests/messages.test.ts @@ -46,7 +46,11 @@ describe('convertMessagesToModelMessages — AG-UI dedup pre-pass', () => { role: 'assistant', content: 'calling', toolCalls: [ - { id: 'tc1', type: 'function', function: { name: 'getTodos', arguments: '{}' } }, + { + id: 'tc1', + type: 'function', + function: { name: 'getTodos', arguments: '{}' }, + }, ], } as ModelMessage, { role: 'tool', toolCallId: 'tc1', content: '[]' } as ModelMessage, @@ -83,7 +87,10 @@ describe('convertMessagesToModelMessages — AG-UI dedup pre-pass', () => { it('collapses AG-UI developer messages to system role', () => { const messages = [ - { role: 'developer', content: 'You are helpful' } as unknown as ModelMessage, + { + role: 'developer', + content: 'You are helpful', + } as unknown as ModelMessage, { role: 'user', content: 'hi' } as ModelMessage, ] diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index f65429b25..8d42a21fd 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -37,8 +37,7 @@ export const Route = createFileRoute('/api/chat')({ const feature: Feature = ( typeof fp.feature === 'string' ? fp.feature : 'chat' ) as Feature - const testId = - typeof fp.testId === 'string' ? fp.testId : undefined + const testId = typeof fp.testId === 'string' ? fp.testId : undefined const aimockPort = fp.aimockPort != null ? Number(fp.aimockPort) : undefined diff --git a/testing/e2e/tests/ag-ui-compliance.spec.ts b/testing/e2e/tests/ag-ui-compliance.spec.ts index 53c9250b4..31ded5dae 100644 --- a/testing/e2e/tests/ag-ui-compliance.spec.ts +++ b/testing/e2e/tests/ag-ui-compliance.spec.ts @@ -51,7 +51,8 @@ test.describe('AG-UI client-to-server compliance', () => { // Anchor messages carry `parts` (re-attached by chatParamsFromRequestBody) const anchors = second.messages.filter( - (m: any) => m.role === 'user' || m.role === 'system' || m.role === 'assistant', + (m: any) => + m.role === 'user' || m.role === 'system' || m.role === 'assistant', ) expect(anchors.length).toBeGreaterThan(0) for (const a of anchors) { From a8ee901d22517dd19fcbe1d8aa2a2758e7e8a348 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 5 May 2026 19:04:38 +0200 Subject: [PATCH 20/29] fix(e2e): unwrap forwardedProps in non-chat media route handlers Connection adapters now POST AG-UI RunAgentInput envelopes with input data nested under forwardedProps. Update the image, tts, transcription, and video route handlers (sse + http-stream) to read from body.forwardedProps ?? body.data ?? body so they work for both the AG-UI envelope (connection adapter path) and the existing server-function fetcher path. --- testing/e2e/src/routes/api.image.stream.ts | 2 +- testing/e2e/src/routes/api.image.ts | 2 +- testing/e2e/src/routes/api.transcription.stream.ts | 2 +- testing/e2e/src/routes/api.transcription.ts | 2 +- testing/e2e/src/routes/api.tts.stream.ts | 2 +- testing/e2e/src/routes/api.tts.ts | 2 +- testing/e2e/src/routes/api.video.stream.ts | 2 +- testing/e2e/src/routes/api.video.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/testing/e2e/src/routes/api.image.stream.ts b/testing/e2e/src/routes/api.image.stream.ts index 3dc986d9c..abcf2c280 100644 --- a/testing/e2e/src/routes/api.image.stream.ts +++ b/testing/e2e/src/routes/api.image.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/image/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, numberOfImages, testId, aimockPort } = data as { prompt: string diff --git a/testing/e2e/src/routes/api.image.ts b/testing/e2e/src/routes/api.image.ts index 7356a9260..8fb9829ac 100644 --- a/testing/e2e/src/routes/api.image.ts +++ b/testing/e2e/src/routes/api.image.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/image')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, numberOfImages, testId, aimockPort } = data as { prompt: string diff --git a/testing/e2e/src/routes/api.transcription.stream.ts b/testing/e2e/src/routes/api.transcription.stream.ts index e43f154a1..34979ea17 100644 --- a/testing/e2e/src/routes/api.transcription.stream.ts +++ b/testing/e2e/src/routes/api.transcription.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/transcription/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { audio, language, provider, testId, aimockPort } = data as { audio: string language?: string diff --git a/testing/e2e/src/routes/api.transcription.ts b/testing/e2e/src/routes/api.transcription.ts index 904776a4b..070b29db7 100644 --- a/testing/e2e/src/routes/api.transcription.ts +++ b/testing/e2e/src/routes/api.transcription.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/transcription')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { audio, language, provider, testId, aimockPort } = data as { audio: string language?: string diff --git a/testing/e2e/src/routes/api.tts.stream.ts b/testing/e2e/src/routes/api.tts.stream.ts index 69144cb31..1917ed5ad 100644 --- a/testing/e2e/src/routes/api.tts.stream.ts +++ b/testing/e2e/src/routes/api.tts.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/tts/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { text, voice, provider, testId, aimockPort } = data as { text: string voice?: string diff --git a/testing/e2e/src/routes/api.tts.ts b/testing/e2e/src/routes/api.tts.ts index 7ae80f7f7..b981baab1 100644 --- a/testing/e2e/src/routes/api.tts.ts +++ b/testing/e2e/src/routes/api.tts.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/tts')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { text, voice, provider, testId, aimockPort } = data as { text: string voice?: string diff --git a/testing/e2e/src/routes/api.video.stream.ts b/testing/e2e/src/routes/api.video.stream.ts index 2d3760058..33643bd02 100644 --- a/testing/e2e/src/routes/api.video.stream.ts +++ b/testing/e2e/src/routes/api.video.stream.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/video/stream')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, testId, aimockPort } = data as { prompt: string provider: Provider diff --git a/testing/e2e/src/routes/api.video.ts b/testing/e2e/src/routes/api.video.ts index d9269c035..e50d9cb87 100644 --- a/testing/e2e/src/routes/api.video.ts +++ b/testing/e2e/src/routes/api.video.ts @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/video')({ await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) const abortController = new AbortController() const body = await request.json() - const data = body.data ?? body + const data = body.forwardedProps ?? body.data ?? body const { prompt, provider, testId, aimockPort } = data as { prompt: string provider: Provider From e055a3d76af10f819d74c35223e50bf5670fd909 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 5 May 2026 20:10:05 +0200 Subject: [PATCH 21/29] fix(ai-client,examples): correct AG-UI run tracking and forwardedProps precedence Fixes surfaced by full-PR review: - chat-client: extract runId from RUN_ERROR via the AG-UI passthrough field, not just RUN_FINISHED. A per-run RUN_ERROR carrying a runId was falling into the no-runId branch and clearing every active run in the session, breaking concurrent multi-run state. - connection-adapters: synthesized RUN_FINISHED and RUN_ERROR in the legacy connect-wrapper now carry the caller's threadId and runId from runContext instead of bogus 'connect-wrapper' / Date.now() ids, so client-side activeRunIds tracking matches. - connection-adapters: reverse forwardedProps merge order so per-call data wins over the static fetchServerSentEvents/fetchHttpStream options.body, matching the documented "per-message body takes priority" contract. - ts-react-chat example: validate provider against adapterConfig keys (was accepting any string and crashing inside the try block); use the client-supplied model for openrouter (was hardcoded to openai/gpt-5.1). --- .../ts-react-chat/src/routes/api.tanchat.ts | 20 +++++++++++++------ .../typescript/ai-client/src/chat-client.ts | 9 ++++++++- .../ai-client/src/connection-adapters.ts | 10 +++++++--- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 2e048e202..7000efe22 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -150,11 +150,12 @@ export const Route = createFileRoute('/api/tanchat')({ ) } - // Extract provider and model from forwardedProps (sent by the client) - const provider: Provider = - typeof params.forwardedProps.provider === 'string' && - (params.forwardedProps.provider as Provider) - ? (params.forwardedProps.provider as Provider) + // Extract provider and model from forwardedProps (sent by the client). + // Provider must be allowlisted against adapterConfig (validated below) + // to avoid SSRF/runtime crashes from arbitrary client-supplied strings. + const requestedProvider = + typeof params.forwardedProps.provider === 'string' + ? params.forwardedProps.provider : 'openai' const model: string = typeof params.forwardedProps.model === 'string' @@ -175,7 +176,9 @@ export const Route = createFileRoute('/api/tanchat')({ }), openrouter: () => createChatOptions({ - adapter: openRouterText('openai/gpt-5.1'), + adapter: openRouterText( + (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', + ), modelOptions: { reasoning: { effort: 'medium', @@ -219,6 +222,11 @@ export const Route = createFileRoute('/api/tanchat')({ } try { + // Allowlist provider against adapterConfig keys; fall back to openai. + const provider: Provider = + requestedProvider in adapterConfig + ? (requestedProvider as Provider) + : 'openai' // Get typed adapter options using createChatOptions pattern const options = adapterConfig[provider]() diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index b0e5c0d49..850a541cb 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -399,7 +399,14 @@ export class ChatClient { // RUN_FINISHED / RUN_ERROR signal run completion — resolve processing // (redundant if onStreamEnd already resolved it, harmless) if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - const runId = chunk.type === 'RUN_FINISHED' ? chunk.runId : undefined + // RUN_FINISHED has runId in its schema; RUN_ERROR carries it via the + // AG-UI passthrough so adapters can correlate per-run errors. Extract + // both so a RUN_ERROR with a runId only clears that run, not every + // active run in the session. + const runId = + chunk.type === 'RUN_FINISHED' + ? chunk.runId + : (chunk as { runId?: string }).runId if (runId) { this.activeRunIds.delete(runId) } else if (chunk.type === 'RUN_ERROR') { diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 3eb7be432..22f89d6df 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -217,10 +217,12 @@ export function normalizeConnectionAdapter( // If the connect stream ended cleanly without a terminal event, // synthesize RUN_FINISHED so request-scoped consumers can complete. + // Reuse the caller's threadId/runId so client-side activeRunIds tracking matches. if (!abortSignal?.aborted && !hasTerminalEvent) { push({ type: 'RUN_FINISHED', - runId: `run-${Date.now()}`, + threadId: runContext?.threadId, + runId: runContext?.runId ?? `run-${Date.now()}`, model: 'connect-wrapper', timestamp: Date.now(), finishReason: 'stop', @@ -230,6 +232,8 @@ export function normalizeConnectionAdapter( if (!abortSignal?.aborted && !hasTerminalEvent) { push({ type: 'RUN_ERROR', + threadId: runContext?.threadId, + runId: runContext?.runId, timestamp: Date.now(), message: err instanceof Error ? err.message : 'Unknown error in connect()', @@ -323,9 +327,9 @@ export function fetchServerSentEvents( tools: runContext?.clientTools ?? [], context: [], forwardedProps: { + ...resolvedOptions.body, ...(runContext?.forwardedProps ?? {}), ...data, - ...resolvedOptions.body, }, } @@ -438,9 +442,9 @@ export function fetchHttpStream( tools: runContext?.clientTools ?? [], context: [], forwardedProps: { + ...resolvedOptions.body, ...(runContext?.forwardedProps ?? {}), ...data, - ...resolvedOptions.body, }, } From 926653e4b847b4d318d8f5bfe5112083cbad8dea Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 15:42:27 +0200 Subject: [PATCH 22/29] feat(ai): backward-compatible AG-UI client/server bridges + codemod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the AG-UI compliance migration fully non-breaking. Existing client and server code keeps working; new canonical names are introduced alongside deprecated aliases that route to the same internal slot. Client (@tanstack/ai-client + framework wrappers): - New `forwardedProps` option on `useChat` / `ChatClient` / `updateOptions`, preferred over the legacy `body` (now `@deprecated`). Both populate the same wire field. - Connection adapters emit a `data` mirror of `forwardedProps` on the wire so server endpoints reading `body.data.X` keep working. - Drop the auto-emitted `conversationId: this.uniqueId`. Threads are now identified by the AG-UI top-level `threadId` field. - Svelte: new `updateForwardedProps` method; `updateBody` retained as deprecated alias. - Framework `useChat` types automatically pick up `forwardedProps` via `Omit`. Server (@tanstack/ai): - New `chatParamsFromRequest(req)` helper: reads `req.json()`, validates against AG-UI `RunAgentInputSchema`, throws a 400 `Response` on bad input (auto-handled by Remix-style frameworks). - `chat()`: `conversationId` option is now a true deprecated alias of `threadId`. Both names route to `this.threadId` resolution; explicit `threadId` wins. - `ChatMiddlewareContext`: canonical `threadId: string` plus deprecated `conversationId?` alias (always equals `threadId`). Internal callers switched to `ctx.threadId`. Codemod: - New `codemods/` workspace with a jscodeshift transform for the safe client-side renames: useChat/ChatClient body→forwardedProps, updateOptions body→forwardedProps, Svelte updateBody→updateForwardedProps, chat() conversationId→threadId. Each rename is import-source gated; conflicts (both legacy + canonical present) are left alone. - Top-level `pnpm codemod:ag-ui-compliance` script. - 6 vitest cases covering positive, negative (no-imports), and conflict-handling paths. Docs: - Migration guide rewritten with three deprecation bridges, tier-based server upgrade path, conversationId→threadId section, and codemod invocation. - API references for ai, ai-client, ai-react, ai-vue, ai-solid, ai-svelte, ai-preact updated with `forwardedProps`/`threadId` options and deprecation notes. - Quick-starts (React, Vue, Svelte) drop the now-redundant `conversationId` plumbing. - Middleware doc updated to document `ctx.threadId` and the alias. - runtime-adapter-switching and multimodal-content code examples switched from `body.data.X` / `body: {...}` to `forwardedProps`. --- codemods/README.md | 41 ++ codemods/ag-ui-compliance/README.md | 57 +++ .../chat-client-body.input.ts | 10 + .../chat-client-body.output.ts | 10 + .../chat-conversation-id.input.ts | 12 + .../chat-conversation-id.output.ts | 12 + .../conflict-leave-alone.input.ts | 14 + .../conflict-leave-alone.output.ts | 14 + .../__testfixtures__/no-imports.input.ts | 13 + .../__testfixtures__/no-imports.output.ts | 13 + .../svelte-update-body.input.ts | 7 + .../svelte-update-body.output.ts | 7 + .../__testfixtures__/use-chat-body.input.tsx | 12 + .../__testfixtures__/use-chat-body.output.tsx | 12 + codemods/ag-ui-compliance/transform.test.ts | 69 +++ codemods/ag-ui-compliance/transform.ts | 260 ++++++++++ codemods/package.json | 18 + codemods/tsconfig.json | 17 + docs/advanced/middleware.md | 3 +- docs/advanced/multimodal-content.md | 10 +- docs/advanced/runtime-adapter-switching.md | 14 +- docs/api/ai-client.md | 10 +- docs/api/ai-preact.md | 4 +- docs/api/ai-react.md | 4 +- docs/api/ai-solid.md | 4 +- docs/api/ai-svelte.md | 8 +- docs/api/ai-vue.md | 4 +- docs/api/ai.md | 72 ++- docs/getting-started/quick-start-svelte.md | 7 +- docs/getting-started/quick-start-vue.md | 5 +- docs/getting-started/quick-start.md | 16 +- docs/migration/ag-ui-compliance.md | 199 ++++++-- package.json | 1 + .../typescript/ai-client/src/chat-client.ts | 26 +- .../ai-client/src/connection-adapters.ts | 58 ++- packages/typescript/ai-client/src/types.ts | 17 +- .../ai-client/tests/chat-client.test.ts | 77 ++- .../tests/connection-adapters.test.ts | 29 ++ packages/typescript/ai-preact/src/use-chat.ts | 13 +- packages/typescript/ai-react/src/use-chat.ts | 15 +- packages/typescript/ai-solid/README.md | 7 +- packages/typescript/ai-solid/src/use-chat.ts | 12 +- .../ai-svelte/src/create-chat.svelte.ts | 10 + packages/typescript/ai-svelte/src/types.ts | 8 +- packages/typescript/ai-vue/src/use-chat.ts | 15 +- .../ai/skills/ai-core/ag-ui-protocol/SKILL.md | 2 - .../ai/src/activities/chat/index.ts | 17 +- .../src/activities/chat/middleware/compose.ts | 2 +- .../src/activities/chat/middleware/types.ts | 13 +- packages/typescript/ai/src/index.ts | 1 + packages/typescript/ai/src/types.ts | 10 +- .../ai/src/utilities/chat-params.ts | 44 ++ .../typescript/ai/tests/chat-params.test.ts | 59 +++ .../typescript/ai/tests/middleware.test.ts | 57 ++- pnpm-lock.yaml | 453 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 56 files changed, 1765 insertions(+), 140 deletions(-) create mode 100644 codemods/README.md create mode 100644 codemods/ag-ui-compliance/README.md create mode 100644 codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts create mode 100644 codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx create mode 100644 codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx create mode 100644 codemods/ag-ui-compliance/transform.test.ts create mode 100644 codemods/ag-ui-compliance/transform.ts create mode 100644 codemods/package.json create mode 100644 codemods/tsconfig.json diff --git a/codemods/README.md b/codemods/README.md new file mode 100644 index 000000000..4299515c7 --- /dev/null +++ b/codemods/README.md @@ -0,0 +1,41 @@ +# TanStack AI codemods + +[jscodeshift](https://github.com/facebook/jscodeshift) transforms for migrating between API versions of `@tanstack/ai` and friends. + +Each codemod lives in its own subdirectory and is named after the migration it covers. Codemods are **opt-in modernizations** — the deprecated APIs they replace continue to work, so you can run them at your own pace. + +## Available codemods + +| Codemod | Migrates | +|---|---| +| [`ag-ui-compliance`](./ag-ui-compliance) | Client-side renames introduced by the AG-UI client/server compliance release: `body` → `forwardedProps` on `useChat` / `ChatClient` / `updateOptions`, Svelte's `updateBody` → `updateForwardedProps`, and `chat({ conversationId })` → `chat({ threadId })`. | + +## Running a codemod + +You can run any codemod via `npx jscodeshift` directly against a remote URL — no clone or install needed in your project: + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + src/**/*.{ts,tsx} +``` + +Add `--dry --print` to preview the rewrite without modifying files. + +## Running locally (this repo) + +If you've cloned this repo (e.g., to apply a codemod to the bundled examples), use the top-level pnpm script: + +```bash +pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" +``` + +## Authoring a new codemod + +1. Add a sibling directory `codemods//`. +2. Implement the transform in `transform.ts` — follow `ag-ui-compliance/transform.ts` for structure (import-source gating, conflict-safe property renames). +3. Add fixtures under `__testfixtures__/.input.{ts,tsx}` and matching `.output.{ts,tsx}`. Cover at least one positive case per transformation, one negative case (file should be left alone), and one conflict case (legacy + canonical names both present). +4. Add tests in `transform.test.ts` using the `expectFixture` helper. +5. Run `pnpm --filter @tanstack/ai-codemods test`. +6. Document the codemod in its own `README.md` and add a row to the table above. diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md new file mode 100644 index 000000000..2e18aab63 --- /dev/null +++ b/codemods/ag-ui-compliance/README.md @@ -0,0 +1,57 @@ +# `ag-ui-compliance` codemod + +Migrates client-side TanStack AI code from the legacy field names to the AG-UI–compliant ones introduced in the AG-UI client/server compliance release. + +> **Heads up — nothing breaks if you skip this.** The legacy names continue to work via deprecation bridges. Run this codemod when you want to clean up your codebase and remove deprecation warnings. See [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the full migration story. + +## What it does + +| Before | After | +|---|---| +| `useChat({ body: {...} })` | `useChat({ forwardedProps: {...} })` | +| `new ChatClient({ body: {...} })` | `new ChatClient({ forwardedProps: {...} })` | +| `client.updateOptions({ body: {...} })` | `client.updateOptions({ forwardedProps: {...} })` | +| `chat.updateBody(x)` *(Svelte)* | `chat.updateForwardedProps(x)` | +| `chat({ conversationId: x })` | `chat({ threadId: x })` | + +Each rename is gated by an **import-source check** — the codemod only rewrites a call site if the relevant identifier (`useChat`, `ChatClient`, etc.) is imported from a known TanStack AI package in the same file. Files that just happen to use a `body` key on unrelated object literals are left untouched. + +## What it doesn't do + +- **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md). +- **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too. + +## Running it + +### Against your app + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +Preview changes without writing them: + +```bash +npx jscodeshift --dry --print \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +### Against this repo's examples + +```bash +pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" +``` + +## Conflict handling + +If an object literal already declares **both** the legacy and the canonical key — for example, `useChat({ body: {...}, forwardedProps: {...} })` — the codemod leaves it alone. Renaming would produce a duplicate-key object literal and silently drop one of the two values; resolving the merge is a judgment call only the author can make. After the codemod runs, look for any places where you intentionally set both and merge them by hand. + +## Limits and verification + +- Test fixtures cover the supported call-site shapes; out-of-shape uses (e.g., `useChat(...spread)`) are skipped. +- Run your test suite after the codemod completes — the rewrite is mechanical, not semantic, so unusual patterns may need manual review. diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts new file mode 100644 index 000000000..dbe298fa3 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.input.ts @@ -0,0 +1,10 @@ +import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client' + +const client = new ChatClient({ + connection: fetchServerSentEvents('/api/chat'), + body: { userId: '123' }, +}) + +client.updateOptions({ + body: { sessionId: 'abc' }, +}) diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts new file mode 100644 index 000000000..933ca2e55 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-client-body.output.ts @@ -0,0 +1,10 @@ +import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client' + +const client = new ChatClient({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { userId: '123' }, +}) + +client.updateOptions({ + forwardedProps: { sessionId: 'abc' }, +}) diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts new file mode 100644 index 000000000..11ada7b89 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts @@ -0,0 +1,12 @@ +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(req: Request) { + const body = await req.json() + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: body.messages, + conversationId: body.forwardedProps?.conversationId, + }) + return toServerSentEventsResponse(stream) +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts new file mode 100644 index 000000000..85bd72610 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts @@ -0,0 +1,12 @@ +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +export async function POST(req: Request) { + const body = await req.json() + const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: body.messages, + threadId: body.forwardedProps?.conversationId, + }) + return toServerSentEventsResponse(stream) +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts new file mode 100644 index 000000000..b3621afbb --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.input.ts @@ -0,0 +1,14 @@ +// Conflict case: both `body` and `forwardedProps` are already set. +// The codemod must leave this alone — renaming would produce a +// duplicate-key object literal and silently drop one of the values. + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { legacy: true }, + forwardedProps: { newer: true }, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts new file mode 100644 index 000000000..b3621afbb --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/conflict-leave-alone.output.ts @@ -0,0 +1,14 @@ +// Conflict case: both `body` and `forwardedProps` are already set. +// The codemod must leave this alone — renaming would produce a +// duplicate-key object literal and silently drop one of the values. + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { legacy: true }, + forwardedProps: { newer: true }, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts b/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts new file mode 100644 index 000000000..b7affc522 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/no-imports.input.ts @@ -0,0 +1,13 @@ +// Negative case: a file that has nothing to do with TanStack AI but +// happens to use a `body` key on object literals. Must remain +// untouched even though it pattern-matches. + +function buildRequest(opts: { body: Record }) { + return fetch('/api/something', { + method: 'POST', + body: JSON.stringify(opts.body), + }) +} + +const obj = { useChat: true, body: { hello: 'world' } } +const x = obj.useChat ? 1 : 0 diff --git a/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts b/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts new file mode 100644 index 000000000..b7affc522 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/no-imports.output.ts @@ -0,0 +1,13 @@ +// Negative case: a file that has nothing to do with TanStack AI but +// happens to use a `body` key on object literals. Must remain +// untouched even though it pattern-matches. + +function buildRequest(opts: { body: Record }) { + return fetch('/api/something', { + method: 'POST', + body: JSON.stringify(opts.body), + }) +} + +const obj = { useChat: true, body: { hello: 'world' } } +const x = obj.useChat ? 1 : 0 diff --git a/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts new file mode 100644 index 000000000..3d00c0476 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.input.ts @@ -0,0 +1,7 @@ +import { createChat, fetchServerSentEvents } from '@tanstack/ai-svelte' + +const chat = createChat({ + connection: fetchServerSentEvents('/api/chat'), +}) + +chat.updateBody({ provider: 'openai' }) diff --git a/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts new file mode 100644 index 000000000..dd5d88597 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/svelte-update-body.output.ts @@ -0,0 +1,7 @@ +import { createChat, fetchServerSentEvents } from '@tanstack/ai-svelte' + +const chat = createChat({ + connection: fetchServerSentEvents('/api/chat'), +}) + +chat.updateForwardedProps({ provider: 'openai' }) diff --git a/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx new file mode 100644 index 000000000..4481728a1 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.input.tsx @@ -0,0 +1,12 @@ +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { + provider: 'openai', + model: 'gpt-4o', + }, + }) + return null +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx new file mode 100644 index 000000000..ed6cd2421 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/use-chat-body.output.tsx @@ -0,0 +1,12 @@ +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { + provider: 'openai', + model: 'gpt-4o', + }, + }) + return null +} diff --git a/codemods/ag-ui-compliance/transform.test.ts b/codemods/ag-ui-compliance/transform.test.ts new file mode 100644 index 000000000..8db3d9ca9 --- /dev/null +++ b/codemods/ag-ui-compliance/transform.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import jscodeshift from 'jscodeshift' +import transform from './transform' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const FIXTURES = resolve(__dirname, '__testfixtures__') + +function read(name: string): string { + return readFileSync(resolve(FIXTURES, name), 'utf-8') +} + +function runTransform(fixtureBaseName: string, ext: 'ts' | 'tsx'): string { + const source = read(`${fixtureBaseName}.input.${ext}`) + const j = jscodeshift.withParser('tsx') + const result = transform( + { path: `${fixtureBaseName}.input.${ext}`, source }, + { + jscodeshift: j, + j, + stats: () => {}, + report: () => {}, + }, + {}, + ) + return typeof result === 'string' ? result : source +} + +// Normalize line endings — fixtures may be saved as CRLF on Windows +// while jscodeshift emits LF, which would make a string compare +// fail despite identical content. +function normalize(s: string): string { + return s.replace(/\r\n/g, '\n').trim() +} + +function expectFixture(name: string, ext: 'ts' | 'tsx' = 'ts'): void { + const expected = read(`${name}.output.${ext}`) + const actual = runTransform(name, ext) + expect(normalize(actual)).toBe(normalize(expected)) +} + +describe('ag-ui-compliance codemod', () => { + it('renames useChat({ body }) to useChat({ forwardedProps })', () => { + expectFixture('use-chat-body', 'tsx') + }) + + it('renames body on ChatClient constructor and updateOptions calls', () => { + expectFixture('chat-client-body') + }) + + it('renames Svelte chat.updateBody() to chat.updateForwardedProps()', () => { + expectFixture('svelte-update-body') + }) + + it('renames chat({ conversationId }) to chat({ threadId })', () => { + expectFixture('chat-conversation-id') + }) + + it('leaves files without TanStack AI imports untouched', () => { + expectFixture('no-imports') + }) + + it('leaves objects that already declare both keys untouched', () => { + expectFixture('conflict-leave-alone') + }) +}) diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts new file mode 100644 index 000000000..48da6799b --- /dev/null +++ b/codemods/ag-ui-compliance/transform.ts @@ -0,0 +1,260 @@ +/** + * jscodeshift transform: AG-UI client compliance migration + * + * Renames the deprecated client-side fields introduced by the AG-UI + * compliance release of `@tanstack/ai`. Each rename is gated by an + * import-source check so we don't touch unrelated code that happens + * to share a property name. + * + * Transforms (all opt-in — the deprecated names keep working): + * + * 1. `useChat({ body })` → `useChat({ forwardedProps })` + * 2. `new ChatClient({ body })` → `new ChatClient({ forwardedProps })` + * 3. `client.updateOptions({ body })` → `{ forwardedProps }` + * (when `ChatClient` is imported in the file) + * 4. `chat.updateBody(x)` → `chat.updateForwardedProps(x)` + * (when imported from `@tanstack/ai-svelte`) + * 5. `chat({ conversationId })` → `chat({ threadId })` + * (when `chat` is imported from `@tanstack/ai`) + * + * Conflict handling: if both the legacy and canonical key are already + * present in the same object literal we leave the property alone — the + * user has authored a deliberate mix and a blind rename would create + * a duplicate key. + */ + +import type { + API, + ASTPath, + Collection, + FileInfo, + ImportDeclaration, + JSCodeshift, + ObjectExpression, + Property, +} from 'jscodeshift' + +const FRAMEWORK_USE_CHAT_PACKAGES = new Set([ + '@tanstack/ai-react', + '@tanstack/ai-react-ui', + '@tanstack/ai-vue', + '@tanstack/ai-vue-ui', + '@tanstack/ai-solid', + '@tanstack/ai-solid-ui', + '@tanstack/ai-preact', +]) + +const SVELTE_PACKAGE = '@tanstack/ai-svelte' +const CLIENT_PACKAGE = '@tanstack/ai-client' +const CORE_PACKAGE = '@tanstack/ai' + +interface ImportFacts { + /** Whether `useChat` is imported from a framework package. */ + hasUseChat: boolean + /** Whether `ChatClient` is imported from `@tanstack/ai-client`. */ + hasChatClient: boolean + /** Whether anything is imported from `@tanstack/ai-svelte`. */ + hasSvelte: boolean + /** Whether `chat` is imported from `@tanstack/ai`. */ + hasChat: boolean +} + +function collectImportFacts( + j: JSCodeshift, + root: Collection, +): ImportFacts { + const facts: ImportFacts = { + hasUseChat: false, + hasChatClient: false, + hasSvelte: false, + hasChat: false, + } + + root.find(j.ImportDeclaration).forEach((path: ASTPath) => { + const source = path.node.source.value + if (typeof source !== 'string') return + + const specifiers = path.node.specifiers ?? [] + const importedNames = new Set() + for (const spec of specifiers) { + if (spec.type === 'ImportSpecifier') { + importedNames.add(spec.imported.name) + } + } + + if (FRAMEWORK_USE_CHAT_PACKAGES.has(source) && importedNames.has('useChat')) { + facts.hasUseChat = true + } + if (source === CLIENT_PACKAGE && importedNames.has('ChatClient')) { + facts.hasChatClient = true + } + if (source === SVELTE_PACKAGE) { + facts.hasSvelte = true + } + if (source === CORE_PACKAGE && importedNames.has('chat')) { + facts.hasChat = true + } + }) + + return facts +} + +/** + * Find a Property by its (Identifier) key name on an ObjectExpression. + * Skips spread elements, computed keys, and non-Identifier shorthand keys. + */ +function findKey( + obj: ObjectExpression, + name: string, +): Property | undefined { + for (const prop of obj.properties) { + if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') continue + if (prop.computed) continue + const key = prop.key + if (key.type === 'Identifier' && key.name === name) { + return prop as Property + } + } + return undefined +} + +/** + * Rename a property `oldName` → `newName` on the given object expression + * iff `oldName` is present and `newName` is not. Returns true if a + * rename happened. + */ +function renameProperty( + obj: ObjectExpression, + oldName: string, + newName: string, +): boolean { + const oldProp = findKey(obj, oldName) + if (!oldProp) return false + if (findKey(obj, newName)) { + // Both keys present — author has set them deliberately. Leaving + // alone is safer than producing a duplicate-key object literal. + return false + } + if (oldProp.key.type === 'Identifier') { + oldProp.key.name = newName + return true + } + return false +} + +/** + * Rename the `body` key to `forwardedProps` on the first object-literal + * argument of every call site whose callee matches `predicate`. + */ +function renameBodyOnCalls( + j: JSCodeshift, + root: Collection, + predicate: (path: ASTPath) => boolean, +): number { + let count = 0 + root + .find(j.CallExpression) + .filter(predicate) + .forEach((path) => { + const args = path.node.arguments + const objArg = args.find( + (a): a is ObjectExpression => a.type === 'ObjectExpression', + ) + if (objArg && renameProperty(objArg, 'body', 'forwardedProps')) { + count++ + } + }) + return count +} + +export default function transform( + file: FileInfo, + api: API, +): string | null | undefined { + const j = api.jscodeshift + const root = j(file.source) + const facts = collectImportFacts(j, root) + + // Bail out early if no relevant imports — keeps the codemod a no-op + // on files that just happen to use a `body` key in unrelated code. + if ( + !facts.hasUseChat && + !facts.hasChatClient && + !facts.hasSvelte && + !facts.hasChat + ) { + return file.source + } + + let changed = 0 + + // 1. useChat({ body }) → useChat({ forwardedProps }) + if (facts.hasUseChat) { + changed += renameBodyOnCalls(j, root, (path) => { + const callee = path.node.callee + return callee.type === 'Identifier' && callee.name === 'useChat' + }) + } + + // 2. new ChatClient({ body }) → new ChatClient({ forwardedProps }) + if (facts.hasChatClient) { + root.find(j.NewExpression).forEach((path) => { + const callee = path.node.callee + if (callee.type !== 'Identifier' || callee.name !== 'ChatClient') return + const objArg = path.node.arguments.find( + (a): a is ObjectExpression => a.type === 'ObjectExpression', + ) + if (objArg && renameProperty(objArg, 'body', 'forwardedProps')) { + changed++ + } + }) + + // 3. .updateOptions({ body }) → { forwardedProps } + // + // We can't always tell statically that `` is a ChatClient + // instance, so we gate the whole transform on the file importing + // ChatClient (already checked) and pattern-match on the method + // name. `updateOptions` is distinctive enough that false matches + // are unlikely in a TanStack AI codebase. + changed += renameBodyOnCalls(j, root, (path) => { + const callee = path.node.callee + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'updateOptions' + ) + }) + } + + // 4. chat.updateBody(x) → chat.updateForwardedProps(x) + if (facts.hasSvelte) { + root.find(j.MemberExpression).forEach((path) => { + if (path.node.computed) return + if (path.node.property.type !== 'Identifier') return + if (path.node.property.name !== 'updateBody') return + path.node.property.name = 'updateForwardedProps' + changed++ + }) + } + + // 5. chat({ conversationId }) → chat({ threadId }) + if (facts.hasChat) { + root.find(j.CallExpression).forEach((path) => { + const callee = path.node.callee + if (callee.type !== 'Identifier' || callee.name !== 'chat') return + const objArg = path.node.arguments.find( + (a): a is ObjectExpression => a.type === 'ObjectExpression', + ) + if (objArg && renameProperty(objArg, 'conversationId', 'threadId')) { + changed++ + } + }) + } + + return changed > 0 ? root.toSource() : file.source +} + +// jscodeshift inspects `.parser` on the default export to choose its +// AST flavor. We support both .ts and .tsx out of the box. +;(transform as unknown as { parser: string }).parser = 'tsx' diff --git a/codemods/package.json b/codemods/package.json new file mode 100644 index 000000000..73654abf2 --- /dev/null +++ b/codemods/package.json @@ -0,0 +1,18 @@ +{ + "name": "@tanstack/ai-codemods", + "version": "0.0.0", + "private": true, + "description": "jscodeshift codemods for migrating TanStack AI client APIs", + "type": "module", + "scripts": { + "test": "vitest run", + "test:dev": "vitest" + }, + "devDependencies": { + "@types/jscodeshift": "^0.12.0", + "@types/node": "^22.10.0", + "jscodeshift": "^17.1.1", + "typescript": "5.9.3", + "vitest": "^4.1.4" + } +} diff --git a/codemods/tsconfig.json b/codemods/tsconfig.json new file mode 100644 index 000000000..17d20f217 --- /dev/null +++ b/codemods/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowJs": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": false + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "**/__testfixtures__/**"] +} diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 91de53d95..620a97ec4 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -319,7 +319,8 @@ Every hook receives a `ChatMiddlewareContext` as its first argument. It provides |-------|------|-------------| | `requestId` | `string` | Unique ID for this chat request | | `streamId` | `string` | Unique ID for this stream | -| `conversationId` | `string \| undefined` | User-provided conversation ID | +| `threadId` | `string` | AG-UI thread identifier. Resolves to caller-provided `threadId` (or legacy `conversationId`), or an auto-generated value if neither is supplied. Use this for event correlation. | +| `conversationId` | `string \| undefined` | **Deprecated** alias of `threadId`. Always equals `ctx.threadId`; retained so middleware written before the AG-UI rename keeps working. New middleware should read `ctx.threadId`. | | `phase` | `ChatMiddlewarePhase` | Current lifecycle phase | | `iteration` | `number` | Agent loop iteration (0-indexed) | | `chunkIndex` | `number` | Running count of chunks yielded | diff --git a/docs/advanced/multimodal-content.md b/docs/advanced/multimodal-content.md index f30301e1b..25683d233 100644 --- a/docs/advanced/multimodal-content.md +++ b/docs/advanced/multimodal-content.md @@ -379,14 +379,14 @@ await client.sendMessage({ }) ``` -### Per-Message Body Parameters +### Per-Message Forwarded Props -The second parameter allows you to pass additional body parameters for that specific request. These are shallow-merged with the client's base body configuration, with per-message parameters taking priority: +The second parameter allows you to pass additional `forwardedProps` for that specific request. These are shallow-merged with the client's base `forwardedProps` configuration, with per-message values taking priority: ```typescript const client = new ChatClient({ connection: fetchServerSentEvents('/api/chat'), - body: { model: 'gpt-5' }, // Base body params + forwardedProps: { model: 'gpt-5' }, // Base forwarded props }) // Override model for this specific message @@ -394,10 +394,10 @@ await client.sendMessage('Analyze this complex problem', { model: 'gpt-5', temperature: 0.2, }) - - ``` +> **Note:** The legacy `body` constructor option is still supported but deprecated. New code should use `forwardedProps`. Both populate the same wire field. + ### React Example Here's how to use multimodal messages in a React component: diff --git a/docs/advanced/runtime-adapter-switching.md b/docs/advanced/runtime-adapter-switching.md index ba13debb3..96f4cafcf 100644 --- a/docs/advanced/runtime-adapter-switching.md +++ b/docs/advanced/runtime-adapter-switching.md @@ -34,11 +34,12 @@ const adapters = { } // In your request handler: -const provider: Provider = request.body.provider || 'openai' +const body = await request.json() +const provider: Provider = body.forwardedProps?.provider || 'openai' const stream = chat({ adapter: adapters[provider](), - messages, + messages: body.messages, }) ``` @@ -89,15 +90,16 @@ export const Route = createFileRoute('/api/chat')({ POST: async ({ request }) => { const abortController = new AbortController() const body = await request.json() - const { messages, data } = body - - const provider: Provider = data?.provider || 'openai' + // `forwardedProps` is the AG-UI field set by `useChat({ forwardedProps })`. + // The legacy `body.data.provider` access still works (mirrored on the + // wire for backward compatibility) but `forwardedProps` is preferred. + const provider: Provider = body.forwardedProps?.provider || 'openai' const stream = chat({ adapter: adapters[provider](), tools: [...], systemPrompts: [...], - messages, + messages: body.messages, abortController, }) diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 379e58589..bf9f6eda6 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -46,7 +46,9 @@ const client = new ChatClient({ - `connection` - Connection adapter for streaming - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `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 +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works — values are merged into `forwardedProps` on the wire and mirrored under the legacy `data` field for backward compatibility - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -174,10 +176,12 @@ Creates a custom connection adapter. import { stream } from "@tanstack/ai-client"; const adapter = stream(async (messages, data, signal) => { - // Custom implementation + // `data` here carries the merged forwardedProps. The fetch-based + // adapters serialize it as the AG-UI `RunAgentInput.forwardedProps` + // field on the wire (with a backward-compat `data` mirror). const response = await fetch("/api/chat", { method: "POST", - body: JSON.stringify({ messages, ...data }), + body: JSON.stringify({ messages, forwardedProps: data }), signal, }); return processStream(response); diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 91ae5b7f5..17dd706eb 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -65,7 +65,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `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' }`) +- `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 - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index c736e207a..ac10e1667 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -65,7 +65,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `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' }`) +- `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 - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index daaade764..8bf22bf7a 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -66,7 +66,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `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' }`) +- `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 - `onFinish?` - Callback when response finishes diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 09e434032..770e3d90f 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -61,7 +61,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send +- `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' }`) +- `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 - `onChunk?` - Callback when stream chunk is received @@ -100,7 +102,9 @@ interface CreateChatReturn { readonly sessionGenerating: boolean; setMessages: (messages: UIMessage[]) => void; clear: () => void; + /** @deprecated Use `updateForwardedProps` instead. */ updateBody: (body: Record) => void; + updateForwardedProps: (forwardedProps: Record) => void; } ``` @@ -109,7 +113,7 @@ interface CreateChatReturn { - **`create*` naming** -- factory functions, not hooks. Call outside of any lifecycle. - **Reactive getters** -- state properties (`messages`, `isLoading`, `error`, `status`, `isSubscribed`, `connectionStatus`, `sessionGenerating`) are Svelte 5 `$state` via getters. Access directly (e.g., `chat.messages`, not `chat.messages.value`). - **No automatic cleanup** -- unlike React/Vue/Solid, `createChat` does not auto-dispose. Call `chat.stop()` manually when the component unmounts (e.g., in `onDestroy` or an `$effect` return). -- **`updateBody()`** -- update request body parameters dynamically (e.g., for model selection). In Vue, body changes are synced via `watch`; in Svelte, call this method explicitly. +- **`updateForwardedProps()`** -- update AG-UI `forwardedProps` dynamically (e.g., for model selection). In Vue, changes to the `forwardedProps` option are synced via `watch`; in Svelte, call this method explicitly. The legacy `updateBody()` is still available but deprecated. - **`.svelte.ts` files** -- source files use the `.svelte.ts` extension for Svelte 5 rune support. ## Connection Adapters diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index be3d6f681..d1831b6c1 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -61,7 +61,9 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `tools?` - Array of client tool implementations (with `.client()` method) - `initialMessages?` - Initial messages array - `id?` - Unique identifier for this chat instance -- `body?` - Additional body parameters to send (reactive -- changes are synced automatically via `watch`) +- `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 (reactive -- changes are synced automatically via `watch`) +- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire (reactive) - `live?` - Enable live subscription mode (auto-subscribes/unsubscribes) - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received diff --git a/docs/api/ai.md b/docs/api/ai.md index da0970d14..0f50d74cd 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -41,12 +41,15 @@ const stream = chat({ ### Parameters - `adapter` - An AI adapter instance with model (e.g., `openaiText('gpt-5.2')`, `anthropicText('claude-sonnet-4-5')`) -- `messages` - Array of chat messages +- `messages` - Array of chat messages. Accepts mixed `UIMessage | ModelMessage` arrays — internal conversion handles AG-UI fan-out dedup, drops `reasoning`/`activity`, and collapses `developer` → `system` - `tools?` - Array of tools for function calling - `systemPrompts?` - System prompts to prepend to messages - `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`) - `abortController?` - AbortController for cancellation - `modelOptions?` - Model-specific options (renamed from `providerOptions`) +- `threadId?` - AG-UI thread identifier propagated into `RUN_STARTED` events for run correlation +- `runId?` - AG-UI run identifier (auto-generated if omitted) +- `parentRunId?` - AG-UI parent run identifier for nested runs ### Returns @@ -191,6 +194,73 @@ return toServerSentEventsResponse(stream); A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`). +## `chatParamsFromRequest(req)` + +Reads an HTTP `Request`, parses its JSON body, and validates it against AG-UI `RunAgentInputSchema`. Returns parsed chat parameters ready to spread into `chat()`. On a malformed body, **throws a 400 `Response`** that frameworks like TanStack Start, SolidStart, Remix, and React Router 7 return to the client automatically. + +```typescript +import { chat, chatParamsFromRequest, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +export async function POST(req: Request) { + const params = await chatParamsFromRequest(req); + const stream = chat({ + adapter: openaiText("gpt-4o"), + messages: params.messages, + tools: serverTools, + }); + return toServerSentEventsResponse(stream); +} +``` + +### Parameters + +- `req` - An incoming `Request` whose JSON body conforms to AG-UI `RunAgentInput` + +### Returns + +A promise resolving to `{ messages, threadId, runId, parentRunId?, tools, forwardedProps, state, context }`. + +> **Framework note.** Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown `Response` objects. In those, wrap with try/catch or use `chatParamsFromRequestBody(await req.json())` directly. + +## `chatParamsFromRequestBody(body)` + +Lower-level variant of `chatParamsFromRequest` that validates an already-parsed body. Rejects with an `AGUIError` on malformed input. Use this when you need explicit error handling control. + +```typescript +const body = await req.json(); +try { + const params = await chatParamsFromRequestBody(body); + // ... +} catch (error) { + return new Response(error.message, { status: 400 }); +} +``` + +## `mergeAgentTools(serverTools, clientTools)` + +Merges a server-side tool registry with the AG-UI client-declared tools received in the request payload. Server tools win on name collision; client-only tools become no-execute stubs that the runtime dispatches via `ClientToolRequest` events. + +```typescript +import { chat, chatParamsFromRequest, mergeAgentTools } from "@tanstack/ai"; + +const params = await chatParamsFromRequest(req); +const stream = chat({ + adapter: openaiText("gpt-4o"), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), +}); +``` + +### Parameters + +- `serverTools` - The server's `toolDefinition().server(...)` registry, keyed by tool name +- `clientTools` - The `tools` array from `chatParamsFromRequest`'s return value + +### Returns + +A merged tool record suitable for `chat({ tools })`. + ## `maxIterations(count)` Creates an agent loop strategy that limits iterations. diff --git a/docs/getting-started/quick-start-svelte.md b/docs/getting-started/quick-start-svelte.md index 0d3b6b14d..39c15189e 100644 --- a/docs/getting-started/quick-start-svelte.md +++ b/docs/getting-started/quick-start-svelte.md @@ -46,13 +46,14 @@ export const POST: RequestHandler = async ({ request }) => { ) } - const { messages, conversationId } = await request.json() + const body = await request.json() try { + // `chat()` uses the AG-UI `threadId` for devtools correlation + // when available — no need to plumb `conversationId` manually. const stream = chat({ adapter: openaiText('gpt-4o'), - messages, - conversationId, + messages: body.messages, }) return toServerSentEventsResponse(stream) diff --git a/docs/getting-started/quick-start-vue.md b/docs/getting-started/quick-start-vue.md index 23547fbcc..a993d31e1 100644 --- a/docs/getting-started/quick-start-vue.md +++ b/docs/getting-started/quick-start-vue.md @@ -41,7 +41,7 @@ const app = express() app.use(express.json()) app.post('/api/chat', async (req, res) => { - const { messages, conversationId } = req.body + const { messages } = req.body if (!process.env.OPENAI_API_KEY) { res.status(500).json({ error: 'OPENAI_API_KEY not configured' }) @@ -49,10 +49,11 @@ app.post('/api/chat', async (req, res) => { } try { + // `chat()` uses the AG-UI `threadId` for devtools correlation + // when available — no need to plumb `conversationId` manually. const stream = chat({ adapter: openaiText('gpt-4o'), messages, - conversationId, }) const response = toServerSentEventsResponse(stream) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index fb5e92577..e8f21f7d4 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -58,14 +58,14 @@ export const Route = createFileRoute("/api/chat")({ ); } - const { messages, conversationId } = await request.json(); + const body = await request.json(); try { - // Create a streaming chat response + // Create a streaming chat response. `chat()` reads the AG-UI + // `threadId` for devtools correlation when available. const stream = chat({ adapter: openaiText("gpt-5.2"), - messages, - conversationId, + messages: body.messages, }); // Convert stream to HTTP response @@ -108,14 +108,14 @@ export async function POST(request: Request) { ); } - const { messages, conversationId } = await request.json(); + const body = await request.json(); try { - // Create a streaming chat response + // Create a streaming chat response. `chat()` reads the AG-UI + // `threadId` for devtools correlation when available. const stream = chat({ adapter: openaiText("gpt-5.2"), - messages, - conversationId + messages: body.messages, }); // Convert stream to HTTP response diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 67ec4fb6b..923d2eb78 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -4,11 +4,11 @@ title: Migrating to AG-UI Client-to-Server Compliance # Migrating to AG-UI Client-to-Server Compliance -> **TL;DR:** Upgrade `@tanstack/ai` and `@tanstack/ai-client` together. The HTTP wire format changed to AG-UI `RunAgentInput`. Server endpoints must use `chatParamsFromRequestBody` and `mergeAgentTools`. Clients have nothing to do — `useChat` and the connection adapters handle the new format internally. +> **TL;DR:** This release is fully backward compatible. Upgrade `@tanstack/ai` and `@tanstack/ai-client` together and existing code keeps working — both the legacy `body` client option and the legacy `data` server-side wire field continue to function unchanged. The HTTP wire format gained AG-UI `RunAgentInput` fields (`threadId`, `runId`, `tools`, `forwardedProps`, etc.) for full AG-UI compliance, and the legacy fields are emitted alongside them as a deprecation bridge. New helpers (`chatParamsFromRequest`, `mergeAgentTools`) are available for opt-in conveniences. Migrate to the new names when convenient — both `body` (client) and `data` (wire) will be removed in a future major release. ## What changed -`@tanstack/ai-client` now POSTs an AG-UI 0.0.52 `RunAgentInput` request body. The previous shape (`{ messages, data, ...optionsBody }`) is no longer accepted by `@tanstack/ai`'s server helpers. +`@tanstack/ai-client` now POSTs an AG-UI 0.0.52 `RunAgentInput` request body. The previous fields (`messages`, `data`) are emitted alongside the new AG-UI fields so existing servers and clients keep working without code changes. ### Old wire shape @@ -19,7 +19,7 @@ title: Migrating to AG-UI Client-to-Server Compliance } ``` -### New wire shape +### New wire shape (with deprecation bridge) ```json { @@ -29,78 +29,164 @@ title: Migrating to AG-UI Client-to-Server Compliance "messages": [...], "tools": [...], "context": [], - "forwardedProps": {...} + "forwardedProps": {...}, + "data": {...} } ``` +`forwardedProps` and `data` carry the same content. New servers should read `forwardedProps`; legacy servers reading `data` keep working unchanged. The `data` field will be removed in a future major release. + The `messages` array carries TanStack `UIMessage` anchors with `parts` intact, plus AG-UI mirror fields (`content`, `toolCalls`) so strict AG-UI servers can parse it. Tool results and thinking parts are additionally emitted as separate `{role:'tool',...}` and `{role:'reasoning',...}` fan-out messages alongside the anchors. -## Server endpoint upgrade +## Backward compatibility & deprecation timeline + +This release introduces three compatibility bridges: + +| Surface | Before | After (deprecated, still works) | Recommended | +|---|---|---|---| +| Client option (`useChat`, `ChatClient`) | `body: { ... }` | `body: { ... }` | `forwardedProps: { ... }` | +| Server wire field | `body.data.X` | `body.data.X` (emitted as a mirror of `forwardedProps`) | `body.forwardedProps.X`, or `params.forwardedProps.X` via `chatParamsFromRequest` | +| Server `chat()` option | `conversationId` | `conversationId` (still accepted) | `threadId` (or rely on `chatParamsFromRequest`) | + +All three bridges will be removed in the next major release. Until then, you can mix old and new freely — if both `body` and `forwardedProps` are passed to `useChat`, they are merged with `forwardedProps` winning on key collision. + +### Automated codemod + +A jscodeshift codemod is available for the client-side renames. Run it against your codebase to flip every `useChat({ body })`, `new ChatClient({ body })`, `updateOptions({ body })`, Svelte `updateBody(...)`, and `chat({ conversationId })` to its canonical name in one pass: + +```bash +npx jscodeshift \ + --parser=tsx \ + -t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \ + "src/**/*.{ts,tsx}" +``` + +Add `--dry --print` to preview changes first. The codemod is import-source–gated, so files that don't import from `@tanstack/ai*` packages are left untouched. See [`codemods/ag-ui-compliance/README.md`](https://github.com/TanStack/ai/blob/main/codemods/ag-ui-compliance/README.md) for the full transform list, conflict-handling rules, and limitations. + +> **Server-side `body.data.X` rewrites are not automated.** Detecting whether a given `body.data.foo` read belongs to a TanStack AI route handler vs. unrelated code is unreliable in a syntactic codemod. Migrate those by hand using the Tier 2 / Tier 3 recipes below. + +### `conversationId` → `threadId` + +`conversationId` was the pre-AG-UI name for "a stable identifier for this conversation, used to correlate client and server devtools events." AG-UI's `threadId` is the same concept under the standard name. **`conversationId` is now a deprecated alias of `threadId` throughout the API** — passing either name resolves to the same internal value. + +**What changed on the wire:** the client no longer auto-emits `forwardedProps.conversationId`. It now sends only the AG-UI top-level `threadId` field. Anyone who explicitly sets `useChat({ forwardedProps: { conversationId } })` (or the legacy `body`) still has their value passed through unchanged. -### Before +**What this means for server code:** + +- **Most callers don't need to do anything.** When you don't pass `conversationId` to `chat()`, the runtime auto-generates a `threadId` and uses it for devtools event correlation automatically. Existing code keeps working. +- **`chat({ conversationId: 'foo' })` still works** — it's now treated as `chat({ threadId: 'foo' })` internally. No code change required. +- **`chat({ threadId: 'foo' })` is the canonical form** — prefer it in new code. If both are passed, `threadId` wins. +- **`TextOptions.conversationId` is `@deprecated`** in JSDoc and will be removed in a future major release. + +**Custom middleware:** `ChatMiddlewareContext` now exposes both `ctx.threadId` (canonical) and `ctx.conversationId` (deprecated alias, always equal to `ctx.threadId`). New middleware should read `ctx.threadId`; existing middleware reading `ctx.conversationId` keeps working. + +```ts +// Before — explicit conversationId plumbing +const params = await chatParamsFromRequest(req) +chat({ + messages: params.messages, + conversationId: params.forwardedProps.conversationId, // ← auto-emitted by old client +}) + +// After — drop the plumbing entirely +const params = await chatParamsFromRequest(req) +chat({ messages: params.messages }) +// devtools correlation auto-uses the resolved threadId +``` + +## Server endpoint upgrade — choose your tier + +The upgrade is **opt-in**: pick the tier that matches the features you use. Most servers fall into Tier 1 and need no code changes. + +### Tier 1 — Minimum (no changes for most servers) + +Keep reading `body.messages` and pass it through. `chat()` accepts mixed `UIMessage | ModelMessage` arrays and handles all AG-UI message-shape quirks internally — fan-out tool dedup, dropping `reasoning`/`activity`, collapsing `developer` → `system`. ```ts import { chat, toServerSentEventsResponse } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { - const { messages } = await req.json() + const body = await req.json() + const provider = body.data?.provider // ← still works (legacy mirror) + // or, equivalently and recommended: + // const provider = body.forwardedProps?.provider + const stream = chat({ adapter: openaiText('gpt-4o'), - messages, + messages: body.messages, // AG-UI mixed shape — works directly tools: serverTools, }) return toServerSentEventsResponse(stream) } ``` -### After +If your existing endpoint reads `body.data.X`, **leave it as-is** — the wire emits a `data` field that mirrors `forwardedProps` exactly until the next major release. Migrate to `body.forwardedProps.X` (or Tier 2's `params.forwardedProps.X`) at your convenience. + +### Tier 2 — Recommended for production + +Adopt `chatParamsFromRequest` when you want any of: + +- **Clean 400 responses** for malformed bodies (Zod validation against `RunAgentInputSchema`). +- **Access to `forwardedProps`** for client-driven options (provider, model, temperature, etc.). +- **Access to AG-UI metadata** like `threadId`, `runId`, and `parentRunId` for observability, logging, or downstream forwarding (the runtime auto-generates these when not supplied; you only need to read them off `params` if you have a use for them). ```ts import { chat, - chatParamsFromRequestBody, - mergeAgentTools, + chatParamsFromRequest, toServerSentEventsResponse, } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai/adapters' export async function POST(req: Request) { - let params - try { - params = await chatParamsFromRequestBody(await req.json()) - } catch (error) { - return new Response( - error instanceof Error ? error.message : 'Bad request', - { status: 400 }, - ) - } - + const params = await chatParamsFromRequest(req) const stream = chat({ adapter: openaiText('gpt-4o'), messages: params.messages, - tools: mergeAgentTools(serverTools, params.tools), - threadId: params.threadId, - runId: params.runId, - - // Explicitly allowlist forwardedProps — see security note below - temperature: - typeof params.forwardedProps.temperature === 'number' - ? params.forwardedProps.temperature - : undefined, - maxTokens: - typeof params.forwardedProps.maxTokens === 'number' - ? params.forwardedProps.maxTokens - : undefined, + tools: serverTools, }) + return toServerSentEventsResponse(stream) +} +``` + +`chatParamsFromRequest` reads `req.json()`, validates against AG-UI `RunAgentInputSchema`, and on failure **throws a 400 `Response`** that frameworks like TanStack Start, SolidStart, Remix, and React Router 7 return to the client automatically. + +> **Framework note.** Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown `Response` objects. In those, either wrap the call with try/catch and return the caught Response, or use `chatParamsFromRequestBody(await req.json())` directly with your own error handling. + +### Tier 3 — Optional: let the client advertise its tools + +`mergeAgentTools` lets the client declare its tools in the request payload (`RunAgentInput.tools`) and have them registered server-side on a per-request basis. **This is purely a convenience over the existing pattern**, not a migration requirement. + +If you were already registering client-side tools in your server's `tools` array — even ones without a `.server()` implementation — that pattern still works exactly as before. The runtime treats tools without `execute` as client-side and emits `ClientToolRequest` events; whether the registration came from a static array or `mergeAgentTools` is irrelevant. + +Adopt this tier only if you want the client to drive tool advertisement (e.g., your client surfaces different tools per session and you'd rather not keep the server's static registry in sync). The only delta from Tier 2 is the `tools` line — wrap `serverTools` with `mergeAgentTools(serverTools, params.tools)`: + +```ts +import { + chat, + chatParamsFromRequest, + mergeAgentTools, + toServerSentEventsResponse, +} from '@tanstack/ai' +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'), + messages: params.messages, + tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools + }) return toServerSentEventsResponse(stream) } ``` -`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core` and throws an `AGUIError` on a malformed shape. Surface this as HTTP 400. +`mergeAgentTools` registers client-declared tools as no-execute stubs server-side. The runtime emits a `ClientToolRequest` event when the model calls one; the client executes via its registered handler and posts the result back. -## `forwardedProps` security +## `forwardedProps` security (Tier 2+ only) + +Skip this section if you're on Tier 1. `forwardedProps` is only surfaced when you opt into `chatParamsFromRequest` (or `chatParamsFromRequestBody`). `forwardedProps` is arbitrary client-controlled JSON. **Do not** spread it directly into `chat({...})`: @@ -121,18 +207,45 @@ chat({ adapter: openaiText('gpt-4o'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), - threadId: params.threadId, - runId: params.runId, - temperature: typeof params.forwardedProps.temperature === 'number' - ? params.forwardedProps.temperature - : undefined, + temperature: + typeof params.forwardedProps.temperature === 'number' + ? params.forwardedProps.temperature + : undefined, + maxTokens: + typeof params.forwardedProps.maxTokens === 'number' + ? params.forwardedProps.maxTokens + : undefined, }) ``` -## Client-side: nothing to do +## Client-side: nothing required, one rename recommended `useChat` and the connection adapters (`fetchServerSentEvents`, `fetchHttpStream`) handle the new wire format internally. Existing `UIMessage` state is unchanged. `clientTools(...)` declarations are now automatically advertised to the server in the request payload. +### `body` → `forwardedProps` (recommended) + +The `body` option on `useChat` / `ChatClient` is now `@deprecated` in favor of `forwardedProps`. Both are accepted, both populate the same wire field. Migrate at your convenience: + +```ts +// Before — still works, but deprecated +useChat({ + connection: fetchServerSentEvents('/api/chat'), + body: { provider: 'openai', model: 'gpt-4o' }, +}) + +// After — recommended +useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: { provider: 'openai', model: 'gpt-4o' }, +}) +``` + +If both are passed during a partial migration, `forwardedProps` wins on key collision so stale `body` values don't shadow new ones. + +The Svelte equivalent renames `updateBody` → `updateForwardedProps`. The legacy `updateBody` is retained and marked `@deprecated`. + +### Optional: explicit thread control + If you instantiated a `ChatClient` directly and want to control the thread identifier, pass `threadId` via the constructor options: ```ts @@ -148,7 +261,7 @@ If you don't pass `threadId`, one is generated automatically and persists for th ## Tool-merge semantics - **Server tools win on name collision.** A tool registered server-side via `toolDefinition().server(...)` always executes server-side. -- **Client-only tools become no-execute stubs** in `chat()`. The runtime emits a `ClientToolRequest` event back to the client; the client's registered handler (via `clientTools(...)`) executes locally and posts the result. +- **Client-only tools become no-execute stubs** in `chat()` (when registered via `mergeAgentTools`). The runtime emits a `ClientToolRequest` event back to the client; the client's registered handler (via `clientTools(...)`) executes locally and posts the result. - **Dual-handler (both have it):** server executes, then `chat-client.ts`'s `onToolCall` fires the client's handler as a UI side-effect when the streamed tool result event arrives. The server's result is authoritative for the conversation. ## Talking to a foreign AG-UI server diff --git a/package.json b/package.json index e418bfedd..8ce41e9eb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:types": "nx affected --targets=test:types --exclude=examples/**", "test:knip": "knip", "test:docs": "node scripts/verify-links.ts", + "codemod:ag-ui-compliance": "pnpm --filter @tanstack/ai-codemods exec jscodeshift --parser=tsx -t ag-ui-compliance/transform.ts", "build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**", "build:all": "nx run-many --targets=build --exclude=examples/**", "watch": "pnpm run build:all && env NX_DAEMON=true nx watch --all -- pnpm run build:all", diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 850a541cb..2542e72a5 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -83,7 +83,11 @@ export class ChatClient { constructor(options: ChatClientOptions) { this.uniqueId = options.id || this.generateUniqueId('chat') this.threadId = options.threadId || this.generateUniqueId('thread') - this.body = options.body || {} + // Merge legacy `body` with new `forwardedProps`. Both populate the + // AG-UI `RunAgentInput.forwardedProps` wire field. `forwardedProps` + // wins on key collision so users migrating individual fields are + // not surprised by stale `body` values shadowing their new ones. + this.body = { ...(options.body || {}), ...(options.forwardedProps || {}) } this.connection = normalizeConnectionAdapter(options.connection) this.events = new DefaultChatClientEventEmitter(this.uniqueId) @@ -588,12 +592,15 @@ export class ChatClient { // Call onResponse callback await this.callbacksRef.current.onResponse() - // Merge body: base body + per-message body (per-message takes priority) - // Include conversationId for server-side event correlation + // Merge body: base body + per-message body (per-message takes priority). + // The AG-UI standard `threadId` is sent at the wire's top level for + // run/conversation correlation, so we no longer auto-emit a separate + // `conversationId` here — `chat({ threadId })` server-side covers the + // same role for devtools/observability. User-set values still pass + // through `forwardedProps` unchanged. const mergedBody = { ...this.body, ...this.pendingMessageBody, - conversationId: this.uniqueId, } // Clear the pending message body after use @@ -995,7 +1002,9 @@ export class ChatClient { */ updateOptions(options: { connection?: ConnectionAdapter + /** @deprecated Use `forwardedProps` instead. */ body?: Record + forwardedProps?: Record tools?: ReadonlyArray onResponse?: (response?: Response) => void | Promise onChunk?: (chunk: StreamChunk) => void @@ -1031,8 +1040,13 @@ export class ChatClient { this.subscribe() } } - if (options.body !== undefined) { - this.body = options.body + if (options.body !== undefined || options.forwardedProps !== undefined) { + // Merge the same way the constructor does; either side may be + // omitted, in which case it contributes nothing. + this.body = { + ...(options.body ?? {}), + ...(options.forwardedProps ?? {}), + } } if (options.tools !== undefined) { this.clientToolsRef.current = new Map() diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 22f89d6df..583ec2db2 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -314,8 +314,19 @@ export function fetchServerSentEvents( ...mergeHeaders(resolvedOptions.headers), } - // Build AG-UI RunAgentInput payload + // Build AG-UI RunAgentInput payload. + // + // Precedence (later spreads win): static adapter `body` is the base, + // overridden by `runContext.forwardedProps` (constructor body / + // forwardedProps options), overridden by per-message `data` passed + // to `connection.send`. Runtime values win over static config — + // this matches the documented "forwardedProps wins" semantic. const wireMessages = uiMessagesToWire(messages as Array) + const forwardedProps = { + ...resolvedOptions.body, + ...(runContext?.forwardedProps ?? {}), + ...data, + } const requestBody = { threadId: runContext?.threadId ?? generateRunId('thread'), runId: runContext?.runId ?? generateRunId('run'), @@ -326,11 +337,17 @@ export function fetchServerSentEvents( messages: wireMessages, tools: runContext?.clientTools ?? [], context: [], - forwardedProps: { - ...resolvedOptions.body, - ...(runContext?.forwardedProps ?? {}), - ...data, - }, + forwardedProps, + // Backward-compat mirror of `forwardedProps` under the legacy + // field name `data`. Server endpoints that have not migrated + // off the pre-AG-UI shape (`{ messages, data }`) keep working. + // AG-UI strict consumers strip this via `RunAgentInputSchema` + // (see `chatParamsFromRequestBody`). Will be removed when the + // legacy `body` client option is dropped. + // Shallow-cloned so that downstream mutation of `data` (e.g. + // by a logging interceptor or fetch wrapper) cannot corrupt + // `forwardedProps` and vice versa. + data: { ...forwardedProps }, } const fetchClient = resolvedOptions.fetchClient ?? fetch @@ -429,8 +446,19 @@ export function fetchHttpStream( ...mergeHeaders(resolvedOptions.headers), } - // Build AG-UI RunAgentInput payload + // Build AG-UI RunAgentInput payload. + // + // Precedence (later spreads win): static adapter `body` is the base, + // overridden by `runContext.forwardedProps` (constructor body / + // forwardedProps options), overridden by per-message `data` passed + // to `connection.send`. Runtime values win over static config — + // this matches the documented "forwardedProps wins" semantic. const wireMessages = uiMessagesToWire(messages as Array) + const forwardedProps = { + ...resolvedOptions.body, + ...(runContext?.forwardedProps ?? {}), + ...data, + } const requestBody = { threadId: runContext?.threadId ?? generateRunId('thread'), runId: runContext?.runId ?? generateRunId('run'), @@ -441,11 +469,17 @@ export function fetchHttpStream( messages: wireMessages, tools: runContext?.clientTools ?? [], context: [], - forwardedProps: { - ...resolvedOptions.body, - ...(runContext?.forwardedProps ?? {}), - ...data, - }, + forwardedProps, + // Backward-compat mirror of `forwardedProps` under the legacy + // field name `data`. Server endpoints that have not migrated + // off the pre-AG-UI shape (`{ messages, data }`) keep working. + // AG-UI strict consumers strip this via `RunAgentInputSchema` + // (see `chatParamsFromRequestBody`). Will be removed when the + // legacy `body` client option is dropped. + // Shallow-cloned so that downstream mutation of `data` (e.g. + // by a logging interceptor or fetch wrapper) cannot corrupt + // `forwardedProps` and vice versa. + data: { ...forwardedProps }, } const fetchClient = resolvedOptions.fetchClient ?? fetch diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index 9d5bf632e..eb2966d30 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -211,7 +211,22 @@ export interface ChatClientOptions< threadId?: string /** - * Additional body parameters to send + * Arbitrary client-controlled JSON forwarded to the server in the + * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session + * options like provider/model selection or feature flags that the + * server endpoint should read. + * + * Replaces the legacy `body` option. If both are provided, + * `forwardedProps` wins on key collision. + */ + forwardedProps?: Record + + /** + * @deprecated Use `forwardedProps` instead. `body` continues to work + * unchanged — its values are merged into the AG-UI + * `RunAgentInput.forwardedProps` field on the wire and are also + * mirrored under the legacy `data` field for servers that have not + * migrated yet. Will be removed in a future major release. */ body?: Record diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index ec997c868..b0540f910 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -1691,7 +1691,54 @@ describe('ChatClient', () => { expect(capturedData?.maxTokens).toBe(100) // From per-message body }) - it('should include conversationId in merged body', async () => { + it('should accept forwardedProps option and merge into request body', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + forwardedProps: { provider: 'openai', model: 'gpt-4o' }, + }) + + await client.sendMessage('Hello') + + expect(capturedData?.provider).toBe('openai') + expect(capturedData?.model).toBe('gpt-4o') + }) + + it('should merge body and forwardedProps with forwardedProps winning', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + // Legacy `body` and new `forwardedProps` declared together — + // simulates a mid-migration codebase. + body: { model: 'gpt-4', temperature: 0.7 }, + forwardedProps: { model: 'gpt-4o' }, + }) + + await client.sendMessage('Hello') + + // forwardedProps wins on key collision so partial migrations are sane. + expect(capturedData?.model).toBe('gpt-4o') + // Non-conflicting keys from `body` are still forwarded. + expect(capturedData?.temperature).toBe(0.7) + }) + + it('should not auto-emit `conversationId` in merged body (replaced by AG-UI threadId)', async () => { const chunks = createTextChunks('Response') let capturedData: Record | undefined const adapter = createMockConnectionAdapter({ @@ -1708,7 +1755,33 @@ describe('ChatClient', () => { await client.sendMessage('Hello') - expect(capturedData?.conversationId).toBe('my-conversation') + // `conversationId` was the pre-AG-UI auto-emitted field. The client + // now emits `threadId` at the wire's top level instead; the legacy + // auto-emit was dropped to avoid duplicating the same identifier. + // User-set `forwardedProps.conversationId` would still pass through. + expect(capturedData?.conversationId).toBeUndefined() + }) + + it('should pass through user-set conversationId via forwardedProps', async () => { + const chunks = createTextChunks('Response') + let capturedData: Record | undefined + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + capturedData = data + }, + }) + + const client = new ChatClient({ + connection: adapter, + forwardedProps: { conversationId: 'user-supplied' }, + }) + + await client.sendMessage('Hello') + + // Backward compat: a user explicitly setting `conversationId` (e.g. + // because their server still reads it) still works unchanged. + expect(capturedData?.conversationId).toBe('user-supplied') }) it('should clear per-message body after request', async () => { diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index b720b230a..5584f3f8e 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -332,6 +332,35 @@ describe('connection-adapters', () => { expect(body.forwardedProps).toMatchObject({ key: 'value' }) }) + it('should mirror forwardedProps under legacy `data` field for backward-compat', async () => { + const mockReader = { + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + } + const mockResponse = { + ok: true, + body: { getReader: () => mockReader }, + } + fetchMock.mockResolvedValue(mockResponse as any) + + const adapter = fetchServerSentEvents('/api/chat') + + for await (const _ of adapter.connect( + [{ role: 'user', content: 'Hello' }], + { provider: 'openai', model: 'gpt-4o' }, + )) { + // Consume + } + + const call = fetchMock.mock.calls[0] + const body = JSON.parse(call?.[1]?.body as string) + // Legacy server code reads `body.data.X`; new server code reads + // `body.forwardedProps.X`. Both must contain the same content + // until the legacy `body` client option is removed. + expect(body.data).toEqual(body.forwardedProps) + expect(body.data).toMatchObject({ provider: 'openai', model: 'gpt-4o' }) + }) + it('should use custom fetchClient when provided', async () => { const customFetch = vi.fn() const mockReader = { diff --git a/packages/typescript/ai-preact/src/use-chat.ts b/packages/typescript/ai-preact/src/use-chat.ts index dacd2be88..f9185de9c 100644 --- a/packages/typescript/ai-preact/src/use-chat.ts +++ b/packages/typescript/ai-preact/src/use-chat.ts @@ -60,6 +60,7 @@ export function useChat = any>( id: clientId, initialMessages: messagesToUse, body: optionsRef.current.body, + forwardedProps: optionsRef.current.forwardedProps, // Wrap every callback so the latest options are read at call time. // Capturing the function reference directly would freeze it to whatever // the parent passed on the first render. @@ -99,11 +100,15 @@ export function useChat = any>( }) }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. useEffect(() => { - client.updateOptions({ body: options.body }) - }, [client, options.body]) + client.updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) + }, [client, options.body, options.forwardedProps]) // Sync initial messages on mount only // Note: initialMessages are passed to ChatClient constructor, but we also diff --git a/packages/typescript/ai-react/src/use-chat.ts b/packages/typescript/ai-react/src/use-chat.ts index c95589874..3805cc2e8 100644 --- a/packages/typescript/ai-react/src/use-chat.ts +++ b/packages/typescript/ai-react/src/use-chat.ts @@ -58,6 +58,7 @@ export function useChat = any>( id: clientId, initialMessages: messagesToUse, body: optionsRef.current.body, + forwardedProps: optionsRef.current.forwardedProps, // Wrap every callback so the latest options are read at call time. // Capturing the function reference directly would freeze it to whatever // the parent passed on the first render. @@ -97,11 +98,17 @@ export function useChat = any>( }) }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // This allows dynamic values (like model selection) to be updated + // without recreating the client. Both fields populate the same + // wire payload; `forwardedProps` is preferred and `body` is + // deprecated but still supported. useEffect(() => { - client.updateOptions({ body: options.body }) - }, [client, options.body]) + client.updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) + }, [client, options.body, options.forwardedProps]) // Sync initial messages on mount only // Note: initialMessages are passed to ChatClient constructor, but we also diff --git a/packages/typescript/ai-solid/README.md b/packages/typescript/ai-solid/README.md index 1cbdcf4e1..72803cea7 100644 --- a/packages/typescript/ai-solid/README.md +++ b/packages/typescript/ai-solid/README.md @@ -71,7 +71,10 @@ interface UseChatOptions { // Configuration initialMessages?: UIMessage[] // Starting messages id?: string // Unique chat ID - body?: Record // Extra data to send + threadId?: string // AG-UI thread ID (auto-generated if omitted) + forwardedProps?: Record // Forwarded to AG-UI RunAgentInput.forwardedProps + /** @deprecated Use `forwardedProps` instead. */ + body?: Record // Callbacks onResponse?: (response?: Response) => void @@ -256,7 +259,7 @@ const chat = useChat({ 'X-Custom-Header': 'value', }, }), - body: { + forwardedProps: { userId: '123', sessionId: 'abc', }, diff --git a/packages/typescript/ai-solid/src/use-chat.ts b/packages/typescript/ai-solid/src/use-chat.ts index 0aff8603a..86614fba8 100644 --- a/packages/typescript/ai-solid/src/use-chat.ts +++ b/packages/typescript/ai-solid/src/use-chat.ts @@ -45,6 +45,7 @@ export function useChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk) => options.onChunk?.(chunk), onFinish: (message) => { @@ -83,11 +84,14 @@ export function useChat = any>( // Connection and other options are captured at creation time }, [clientId]) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. createEffect(() => { - const currentBody = options.body - client().updateOptions({ body: currentBody }) + client().updateOptions({ + body: options.body, + forwardedProps: options.forwardedProps, + }) }) // Sync initial messages on mount only diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts index 3a9eeb232..f46356f5a 100644 --- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts +++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts @@ -65,6 +65,7 @@ export function createChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: options.onResponse, onChunk: options.onChunk, onFinish: (message) => { @@ -150,10 +151,18 @@ export function createChat = any>( await client.addToolApprovalResponse(response) } + /** + * @deprecated Use `updateForwardedProps` instead. + * Both populate the same wire payload. + */ const updateBody = (newBody: Record) => { client.updateOptions({ body: newBody }) } + const updateForwardedProps = (newForwardedProps: Record) => { + client.updateOptions({ forwardedProps: newForwardedProps }) + } + // Return the chat interface with reactive getters // Using getters allows Svelte to track reactivity without needing $ prefix return { @@ -187,5 +196,6 @@ export function createChat = any>( addToolResult, addToolApprovalResponse, updateBody, + updateForwardedProps, } } diff --git a/packages/typescript/ai-svelte/src/types.ts b/packages/typescript/ai-svelte/src/types.ts index 88e6bb32f..2c11320f3 100644 --- a/packages/typescript/ai-svelte/src/types.ts +++ b/packages/typescript/ai-svelte/src/types.ts @@ -130,9 +130,15 @@ export interface CreateChatReturn< */ readonly sessionGenerating: boolean /** - * Update the body sent with requests (e.g., for changing model selection) + * @deprecated Use `updateForwardedProps` instead. Both populate the + * same wire payload; `updateBody` is retained for backward compatibility. */ updateBody: (body: Record) => void + /** + * Update the AG-UI `forwardedProps` sent with requests (e.g., for + * changing model selection or other client-driven options). + */ + updateForwardedProps: (forwardedProps: Record) => void } // Note: createChatClientOptions and InferChatMessages are now in @tanstack/ai-client diff --git a/packages/typescript/ai-vue/src/use-chat.ts b/packages/typescript/ai-vue/src/use-chat.ts index 3f10b4dcb..10beb524b 100644 --- a/packages/typescript/ai-vue/src/use-chat.ts +++ b/packages/typescript/ai-vue/src/use-chat.ts @@ -37,6 +37,7 @@ export function useChat = any>( id: clientId, initialMessages: options.initialMessages, body: options.body, + forwardedProps: options.forwardedProps, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk) => options.onChunk?.(chunk), onFinish: (message) => { @@ -72,12 +73,16 @@ export function useChat = any>( }, }) - // Sync body changes to the client - // This allows dynamic body values (like model selection) to be updated without recreating the client + // Sync body / forwardedProps changes to the client. + // Both populate the same wire payload; `forwardedProps` is preferred + // and `body` is deprecated but still supported. watch( - () => options.body, - (newBody) => { - client.updateOptions({ body: newBody }) + () => [options.body, options.forwardedProps] as const, + ([newBody, newForwardedProps]) => { + client.updateOptions({ + body: newBody, + forwardedProps: newForwardedProps, + }) }, ) diff --git a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 236ff887d..3b798bcb5 100644 --- a/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -66,8 +66,6 @@ export async function POST(req: Request) { adapter: openaiText('gpt-4o'), messages: params.messages, tools: mergeAgentTools(serverTools, params.tools), - threadId: params.threadId, - runId: params.runId, }) return toServerSentEventsResponse(stream) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index e9eef21ad..ec0a43cde 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -358,7 +358,13 @@ class TextEngine< ? { signal: config.params.abortController.signal } : undefined this.effectiveSignal = config.params.abortController?.signal - this.threadId = config.params.threadId || this.createId('thread') + // `conversationId` is the legacy alias of `threadId` — accept it + // as a fallback so `chat({ conversationId })` keeps working, with + // explicit `threadId` winning when both are set. + this.threadId = + config.params.threadId || + config.params.conversationId || + this.createId('thread') this.runIdOverride = config.params.runId this.parentRunIdOverride = config.params.parentRunId @@ -376,7 +382,10 @@ class TextEngine< this.middlewareCtx = { requestId: this.requestId, streamId: this.streamId, - conversationId: config.params.conversationId, + threadId: this.threadId, + // Legacy alias kept on the ctx so middleware that reads + // `ctx.conversationId` keeps working. Always equals `threadId`. + conversationId: this.threadId, phase: 'init' as ChatMiddlewarePhase, iteration: 0, chunkIndex: 0, @@ -424,7 +433,7 @@ class TextEngine< async *run(): AsyncGenerator { this.beforeRun() this.logger.agentLoop('run started', { - conversationId: this.middlewareCtx.conversationId, + threadId: this.middlewareCtx.threadId, }) try { @@ -503,7 +512,7 @@ class TextEngine< // Genuine error — call onError this.logger.errors('chat run failed', { error, - conversationId: this.middlewareCtx.conversationId, + threadId: this.middlewareCtx.threadId, }) await this.middlewareRunner.runOnError(this.middlewareCtx, { error, diff --git a/packages/typescript/ai/src/activities/chat/middleware/compose.ts b/packages/typescript/ai/src/activities/chat/middleware/compose.ts index b3e4cf6cc..912ea768d 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/compose.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/compose.ts @@ -26,7 +26,7 @@ function instrumentCtx(ctx: ChatMiddlewareContext) { return { requestId: ctx.requestId, streamId: ctx.streamId, - clientId: ctx.conversationId, + clientId: ctx.threadId, timestamp: Date.now(), } } diff --git a/packages/typescript/ai/src/activities/chat/middleware/types.ts b/packages/typescript/ai/src/activities/chat/middleware/types.ts index 19ce04586..c825a696b 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/types.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/types.ts @@ -28,7 +28,18 @@ export interface ChatMiddlewareContext { requestId: string /** Unique identifier for this stream */ streamId: string - /** Conversation identifier, if provided by the caller */ + /** + * AG-UI thread identifier — a stable per-conversation ID used to + * correlate client and server devtools events. Resolves to the + * caller-provided `threadId` (or legacy `conversationId`), or an + * auto-generated value when neither is supplied. + */ + threadId: string + /** + * @deprecated Use `threadId` instead. Retained as an alias of + * `threadId` so middleware written before the AG-UI rename keeps + * working unchanged. Will be removed in a future major release. + */ conversationId?: string /** Current lifecycle phase */ phase: ChatMiddlewarePhase diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index e32f183e6..8f7c677e4 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -170,6 +170,7 @@ export type { // Chat utilities export { + chatParamsFromRequest, chatParamsFromRequestBody, mergeAgentTools, } from './utilities/chat-params' diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 7abb1161c..6e847bb05 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -721,8 +721,14 @@ export interface TextOptions< */ outputSchema?: SchemaInput /** - * Conversation ID for correlating client and server-side devtools events. - * When provided, server-side events will be linked to the client conversation in devtools. + * @deprecated Use `threadId` instead. `conversationId` is the legacy + * pre-AG-UI name for the same concept (a stable per-conversation + * identifier used to correlate client/server devtools events). When + * `conversationId` is omitted, the runtime falls back to `threadId` + * automatically, so most callers can simply pass `threadId` (or rely + * on `chatParamsFromRequest`, which surfaces it on `params`). + * + * Will be removed in a future major release. */ conversationId?: string /** diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index d5afc08c2..7d9c47d32 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -93,6 +93,50 @@ export function chatParamsFromRequestBody(body: unknown): Promise<{ }) } +/** + * Read an HTTP `Request`, parse its JSON body, and validate it as an + * AG-UI `RunAgentInput` — collapsing the standard `req.json()` + + * `chatParamsFromRequestBody(...)` pair into a single call. + * + * On a malformed body or invalid AG-UI shape, this **throws a + * `Response`** with status 400 and a migration-pointing message in the + * body. Frameworks that natively handle thrown `Response` objects + * (TanStack Start, SolidStart, Remix, React Router 7) will return the + * 400 to the client automatically, so the handler reduces to: + * + * ```ts + * export async function POST(req: Request) { + * const params = await chatParamsFromRequest(req) + * // ...use params + * } + * ``` + * + * In frameworks that do not auto-handle thrown `Response` objects + * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call + * with try/catch and return the caught Response yourself, or use + * `chatParamsFromRequestBody` directly with your own JSON-parsing. + * + * @throws {Response} 400 on malformed JSON or invalid AG-UI shape. + */ +export async function chatParamsFromRequest( + req: Request, +): Promise>> { + let body: unknown + try { + body = await req.json() + } catch { + throw new Response('Invalid JSON body', { status: 400 }) + } + try { + return await chatParamsFromRequestBody(body) + } catch (error) { + throw new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } +} + /** * Merge a server-side tool registry with the AG-UI client-declared tools * received in the request body. diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts index 0c984288b..63ba36e27 100644 --- a/packages/typescript/ai/tests/chat-params.test.ts +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { + chatParamsFromRequest, chatParamsFromRequestBody, mergeAgentTools, } from '../src/utilities/chat-params' @@ -66,6 +67,64 @@ describe('chatParamsFromRequestBody', () => { }) }) +describe('chatParamsFromRequest', () => { + const validBody = { + threadId: 'thread-1', + runId: 'run-1', + state: {}, + messages: [ + { + id: 'm1', + role: 'user', + content: 'hello', + parts: [{ type: 'text', content: 'hello' }], + }, + ], + tools: [], + context: [], + forwardedProps: {}, + } + + const makeRequest = (body: unknown): Request => + new Request('https://example.test/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) + + it('returns parsed params on a valid body', async () => { + const params = await chatParamsFromRequest(makeRequest(validBody)) + expect(params.threadId).toBe('thread-1') + expect(params.runId).toBe('run-1') + expect(params.messages).toHaveLength(1) + }) + + it('throws a 400 Response when JSON is malformed', async () => { + const req = makeRequest('{not-json') + await expect(chatParamsFromRequest(req)).rejects.toBeInstanceOf(Response) + try { + await chatParamsFromRequest(req) + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response) + expect((thrown as Response).status).toBe(400) + } + }) + + it('throws a 400 Response with a migration-pointing message on invalid AG-UI shape', async () => { + const req = makeRequest({ messages: [], data: {} }) + try { + await chatParamsFromRequest(req) + throw new Error('should have thrown') + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response) + const res = thrown as Response + expect(res.status).toBe(400) + const body = await res.text() + expect(body).toMatch(/AG-UI|RunAgentInput|migration/i) + } + }) +}) + describe('mergeAgentTools', () => { const fakeServerTool = (name: string) => ({ name, diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index a3d98ad8f..ba2924698 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -1236,34 +1236,69 @@ describe('chat() middleware', () => { }) // ========================================================================== - // conversationId propagation - // ========================================================================== - describe('conversationId', () => { - it('should propagate conversationId to middleware context', async () => { - let capturedConvId: string | undefined - + // threadId / conversationId propagation + // ========================================================================== + describe('threadId / conversationId', () => { + const runChatWithMiddleware = async ( + params: { threadId?: string; conversationId?: string }, + ): Promise<{ ctxThreadId: string | undefined; ctxConvId: string | undefined }> => { + let ctxThreadId: string | undefined + let ctxConvId: string | undefined const { adapter } = createMockAdapter({ iterations: [ [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], ], }) - const middleware: ChatMiddleware = { name: 'test', onStart: (ctx) => { - capturedConvId = ctx.conversationId + ctxThreadId = ctx.threadId + ctxConvId = ctx.conversationId }, } - const stream = chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], middleware: [middleware], - conversationId: 'conv-42', + ...params, }) await collectChunks(stream as AsyncIterable) + return { ctxThreadId, ctxConvId } + } + + it('uses caller-provided threadId for both ctx.threadId and ctx.conversationId (legacy alias)', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + threadId: 'thread-7', + }) + expect(ctxThreadId).toBe('thread-7') + expect(ctxConvId).toBe('thread-7') + }) + + it('routes legacy `conversationId` option into threadId resolution', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + conversationId: 'conv-42', + }) + // Legacy `conversationId` is an alias for `threadId` — both fields + // on the middleware ctx resolve to the same value. + expect(ctxThreadId).toBe('conv-42') + expect(ctxConvId).toBe('conv-42') + }) + + it('explicit threadId wins when both are passed', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({ + threadId: 'thread-canonical', + conversationId: 'conv-legacy', + }) + expect(ctxThreadId).toBe('thread-canonical') + expect(ctxConvId).toBe('thread-canonical') + }) - expect(capturedConvId).toBe('conv-42') + it('auto-generates a threadId when neither is provided', async () => { + const { ctxThreadId, ctxConvId } = await runChatWithMiddleware({}) + expect(ctxThreadId).toMatch(/^thread/) + // ctx.conversationId mirrors threadId for backward compat with + // middleware that hasn't been migrated yet. + expect(ctxConvId).toBe(ctxThreadId) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2615b6fa6..0dd4740bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,24 @@ importers: specifier: ^4.0.14 version: 4.0.15(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + codemods: + devDependencies: + '@types/jscodeshift': + specifier: ^0.12.0 + version: 0.12.0 + '@types/node': + specifier: ^22.10.0 + version: 22.19.18 + jscodeshift: + specifier: ^17.1.1 + version: 17.3.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.4 + version: 4.1.4(@types/node@22.19.18)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + examples/php-slim: devDependencies: concurrently: @@ -1920,6 +1938,10 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} @@ -1932,6 +1954,10 @@ packages: resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1946,6 +1972,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -1976,12 +2008,22 @@ packages: resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-replace-supers@7.27.1': resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} @@ -2012,6 +2054,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -2024,12 +2072,42 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@7.27.1': resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -2048,12 +2126,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/preset-flow@7.27.1': + resolution: {integrity: sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@7.28.5': resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/register@7.29.3': + resolution: {integrity: sha512-F6C1KpIdoImKQfsD6HSxZ+mS4YY/2Q+JsqrmTC5ApVkTR2rG+nnbpjhWwzA5bDNu8mJjB3AryqDaWFLd4gCbJQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -2062,10 +2152,18 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -6315,6 +6413,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jscodeshift@0.12.0': + resolution: {integrity: sha512-Jr2fQbEoDmjwEa92TreR/mX2t9iAaY/l5P/GKezvK4BodXahex60PDLXaQR0vAgP0KfCzc1CivHusQB9NhzX8w==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -6339,6 +6440,9 @@ packages: '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} + '@types/node@22.19.18': + resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} + '@types/node@24.10.3': resolution: {integrity: sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==} @@ -6872,6 +6976,10 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + ast-types@0.14.2: + resolution: {integrity: sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==} + engines: {node: '>=4'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -7208,6 +7316,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -8070,6 +8182,14 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -8092,6 +8212,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flow-parser@0.313.0: + resolution: {integrity: sha512-JQaYSzcm2oEG4bCMMYxMBmJ3Uc4zQUQHwsB7Xvjy9+RmVLedUy3bnnDAwV2wZSyxk00vIKlNy+/FxFsoNYSDWQ==} + engines: {node: '>=0.4.0'} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -8672,6 +8796,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -8765,6 +8893,10 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + isolated-vm@6.1.2: resolution: {integrity: sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==} engines: {node: '>=22.0.0'} @@ -8840,6 +8972,16 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jscodeshift@17.3.0: + resolution: {integrity: sha512-LjFrGOIORqXBU+jwfC9nbkjmQfFldtMIoS6d9z2LG/lkmyNXsJAySPT+2SWXJEoE68/bCWcxKpXH37npftgmow==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@babel/preset-env': ^7.1.6 + peerDependenciesMeta: + '@babel/preset-env': + optional: true + jsdom@27.3.0: resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -8899,6 +9041,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -9043,6 +9189,10 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -9133,6 +9283,10 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -9457,6 +9611,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -9730,6 +9887,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -9800,6 +9961,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -9872,6 +10037,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -10187,6 +10356,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + recast@0.20.5: + resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==} + engines: {node: '>= 4'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -10396,6 +10569,10 @@ packages: sdp@3.2.1: resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -10485,6 +10662,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -11886,6 +12067,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -12049,6 +12234,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} '@babel/core@7.28.5': @@ -12079,6 +12270,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 @@ -12104,6 +12303,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.28.5': @@ -12139,6 +12351,8 @@ snapshots: '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12148,6 +12362,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -12174,6 +12397,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12184,6 +12412,20 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12192,6 +12434,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12213,6 +12476,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-flow@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12224,6 +12494,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/register@7.29.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.7 + source-map-support: 0.5.21 + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -12232,6 +12511,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -12244,6 +12529,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -16852,6 +17149,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jscodeshift@0.12.0': + dependencies: + ast-types: 0.14.2 + recast: 0.20.5 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -16879,6 +17181,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.18': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.3': dependencies: undici-types: 7.16.0 @@ -17244,6 +17550,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.4 @@ -17595,6 +17909,10 @@ snapshots: dependencies: tslib: 2.8.1 + ast-types@0.14.2: + dependencies: + tslib: 2.8.1 + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -17986,6 +18304,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + clone@1.0.4: {} clsx@2.1.1: {} @@ -18971,6 +19295,16 @@ snapshots: transitivePeerDependencies: - supports-color + find-cache-dir@2.1.0: + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -18996,6 +19330,8 @@ snapshots: flatted@3.3.3: {} + flow-parser@0.313.0: {} + follow-redirects@1.15.11: {} for-each@0.3.5: @@ -19666,6 +20002,10 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -19745,6 +20085,8 @@ snapshots: isexe@3.1.1: {} + isobject@3.0.1: {} + isolated-vm@6.1.2: dependencies: node-gyp-build: 4.8.4 @@ -19823,6 +20165,29 @@ snapshots: dependencies: argparse: 2.0.1 + jscodeshift@17.3.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.28.5) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.28.5) + '@babel/preset-flow': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/register': 7.29.3(@babel/core@7.28.5) + flow-parser: 0.313.0 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + neo-async: 2.6.2 + picocolors: 1.1.1 + recast: 0.23.11 + tmp: 0.2.5 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + jsdom@27.3.0(postcss@8.5.9): dependencies: '@acemir/cssom': 0.9.29 @@ -19897,6 +20262,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@4.1.5: {} klona@2.0.6: {} @@ -20047,6 +20414,11 @@ snapshots: locate-character@3.0.0: {} + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -20126,6 +20498,11 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -20634,6 +21011,8 @@ snapshots: negotiator@1.0.0: {} + neo-async@2.6.2: {} + netmask@2.0.2: {} nf3@0.3.10: {} @@ -21232,6 +21611,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -21319,6 +21702,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -21365,6 +21750,10 @@ snapshots: pirates@4.0.7: {} + pkg-dir@3.0.0: + dependencies: + find-up: 3.0.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -21779,6 +22168,13 @@ snapshots: readdirp@5.0.0: {} + recast@0.20.5: + dependencies: + ast-types: 0.14.2 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.8.1 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -22120,6 +22516,8 @@ snapshots: sdp@3.2.1: {} + semver@5.7.2: {} + semver@6.3.1: {} semver@7.5.4: @@ -22237,6 +22635,10 @@ snapshots: setprototypeof@1.2.0: {} + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -23422,6 +23824,23 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.18 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -23507,6 +23926,35 @@ snapshots: - tsx - yaml + vitest@4.1.4(@types/node@22.19.18)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.18 + happy-dom: 20.0.11 + jsdom: 27.3.0(postcss@8.5.9) + transitivePeerDependencies: + - msw + vitest@4.1.4(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.4 @@ -23736,6 +24184,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@8.18.0: {} ws@8.18.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 738adc729..3cfc9cbfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ packages: - 'packages/typescript/ai-code-mode/models-eval' - 'examples/*' - 'testing/*' + - 'codemods' From 96896ceccd60ee2fbcb1b8ce381a161b6de80733 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 16:00:39 +0200 Subject: [PATCH 23/29] fix(ai, codemods, docs): round-1 CR fixes for AG-UI compliance work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bucket (a) findings from the 7-agent CR round: - mergeAgentTools: return Array instead of Record. chat({ tools }) takes an array — the documented usage was broken at the type level. Tests updated to reflect the array shape. - chatParamsFromRequest: 400 Response no longer echoes the raw Zod validation paths (which can include user-payload fragments). Returns a fixed migration-pointing message; the underlying parser/AGUI error is attached as `cause` for server-side logs. - Migration guide: add a callout that servers reading `body.forwardedProps?.conversationId` will now get undefined, because the upgraded client no longer auto-emits it. The threadId fallback handles within-request correlation, but cross-request stable identifiers now require reading params.threadId. - chat-params test: build a fresh Request for each call so the malformed-JSON path is actually exercised on both invocations (Request.json() consumes the body stream). - codemod runner: introduce codemods/run.mjs which resolves user- supplied paths against INIT_CWD instead of the package directory pnpm switches into via --filter exec. The root pnpm script now delegates to this runner so `pnpm codemod:ag-ui-compliance "src/**"` works from repo root as documented. - Workspace alignment: codemods/package.json now uses @types/node ^24.10.1 and vitest ^4.0.14 to match the root workspace, so sherif doesn't flag inconsistent versions. Bucket (b) cleanup: - data wire-mirror is now shallow-cloned so future mutation of either field can't corrupt the other. - Codemod surfaces conflict-leave-alone cases via api.report so the user has actionable signal when both legacy and canonical keys coexist on the same call site. --- codemods/ag-ui-compliance/README.md | 9 +- codemods/ag-ui-compliance/transform.ts | 153 ++++++++++++------ codemods/package.json | 7 +- codemods/run.mjs | 50 ++++++ docs/migration/ag-ui-compliance.md | 6 +- package.json | 2 +- .../ai/src/utilities/chat-params.ts | 51 +++--- .../typescript/ai/tests/chat-params.test.ts | 71 +++++--- pnpm-lock.yaml | 69 +------- 9 files changed, 263 insertions(+), 155 deletions(-) create mode 100644 codemods/run.mjs diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md index 2e18aab63..018b8f180 100644 --- a/codemods/ag-ui-compliance/README.md +++ b/codemods/ag-ui-compliance/README.md @@ -49,7 +49,14 @@ pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" ## Conflict handling -If an object literal already declares **both** the legacy and the canonical key — for example, `useChat({ body: {...}, forwardedProps: {...} })` — the codemod leaves it alone. Renaming would produce a duplicate-key object literal and silently drop one of the two values; resolving the merge is a judgment call only the author can make. After the codemod runs, look for any places where you intentionally set both and merge them by hand. +If an object literal already declares **both** the legacy and the canonical key — for example, `useChat({ body: {...}, forwardedProps: {...} })` — the codemod leaves it alone and prints a warning of the form: + +``` +[ag-ui-compliance] path/to/file.tsx:42 — useChat({ body }): both legacy + and canonical keys are already present; left alone. Merge by hand. +``` + +Renaming would produce a duplicate-key object literal and silently drop one of the two values; resolving the merge is a judgment call only the author can make. Search the codemod's output for `[ag-ui-compliance]` lines and merge each call site manually. ## Limits and verification diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index 48da6799b..803eda7b5 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -120,38 +120,51 @@ function findKey( /** * Rename a property `oldName` → `newName` on the given object expression - * iff `oldName` is present and `newName` is not. Returns true if a - * rename happened. + * iff `oldName` is present and `newName` is not. Returns + * - `'renamed'` when the rename was applied, + * - `'conflict'` when both keys were already present and the rename was + * skipped (caller should surface this to the user — silently leaving + * it alone hides intentional decisions that still need a human merge), + * - `'skipped'` otherwise (no `oldName` key found, or the key was not + * an Identifier we can safely rewrite). */ function renameProperty( obj: ObjectExpression, oldName: string, newName: string, -): boolean { +): 'renamed' | 'conflict' | 'skipped' { const oldProp = findKey(obj, oldName) - if (!oldProp) return false + if (!oldProp) return 'skipped' if (findKey(obj, newName)) { - // Both keys present — author has set them deliberately. Leaving - // alone is safer than producing a duplicate-key object literal. - return false + return 'conflict' } if (oldProp.key.type === 'Identifier') { oldProp.key.name = newName - return true + return 'renamed' } - return false + return 'skipped' +} + +interface RenameStats { + renamed: number + conflicts: Array<{ filePath: string; line?: number; site: string }> } /** - * Rename the `body` key to `forwardedProps` on the first object-literal - * argument of every call site whose callee matches `predicate`. + * Rename `oldKey` → `newKey` on the first object-literal argument of every + * call site whose callee matches `predicate`. Conflicts (both keys already + * present) are recorded so the caller can surface them via `api.report`. */ -function renameBodyOnCalls( +function renameKeyOnCalls( j: JSCodeshift, root: Collection, + filePath: string, predicate: (path: ASTPath) => boolean, -): number { - let count = 0 + oldKey: string, + newKey: string, + siteLabel: string, + stats: RenameStats, +): void { root .find(j.CallExpression) .filter(predicate) @@ -160,11 +173,18 @@ function renameBodyOnCalls( const objArg = args.find( (a): a is ObjectExpression => a.type === 'ObjectExpression', ) - if (objArg && renameProperty(objArg, 'body', 'forwardedProps')) { - count++ + if (!objArg) return + const outcome = renameProperty(objArg, oldKey, newKey) + if (outcome === 'renamed') { + stats.renamed++ + } else if (outcome === 'conflict') { + stats.conflicts.push({ + filePath, + line: path.node.loc?.start.line, + site: siteLabel, + }) } }) - return count } export default function transform( @@ -186,14 +206,23 @@ export default function transform( return file.source } - let changed = 0 + const stats: RenameStats = { renamed: 0, conflicts: [] } // 1. useChat({ body }) → useChat({ forwardedProps }) if (facts.hasUseChat) { - changed += renameBodyOnCalls(j, root, (path) => { - const callee = path.node.callee - return callee.type === 'Identifier' && callee.name === 'useChat' - }) + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return callee.type === 'Identifier' && callee.name === 'useChat' + }, + 'body', + 'forwardedProps', + 'useChat({ body })', + stats, + ) } // 2. new ChatClient({ body }) → new ChatClient({ forwardedProps }) @@ -204,8 +233,16 @@ export default function transform( const objArg = path.node.arguments.find( (a): a is ObjectExpression => a.type === 'ObjectExpression', ) - if (objArg && renameProperty(objArg, 'body', 'forwardedProps')) { - changed++ + if (!objArg) return + const outcome = renameProperty(objArg, 'body', 'forwardedProps') + if (outcome === 'renamed') { + stats.renamed++ + } else if (outcome === 'conflict') { + stats.conflicts.push({ + filePath: file.path, + line: path.node.loc?.start.line, + site: 'new ChatClient({ body })', + }) } }) @@ -216,15 +253,24 @@ export default function transform( // ChatClient (already checked) and pattern-match on the method // name. `updateOptions` is distinctive enough that false matches // are unlikely in a TanStack AI codebase. - changed += renameBodyOnCalls(j, root, (path) => { - const callee = path.node.callee - return ( - callee.type === 'MemberExpression' && - !callee.computed && - callee.property.type === 'Identifier' && - callee.property.name === 'updateOptions' - ) - }) + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'updateOptions' + ) + }, + 'body', + 'forwardedProps', + 'updateOptions({ body })', + stats, + ) } // 4. chat.updateBody(x) → chat.updateForwardedProps(x) @@ -234,25 +280,42 @@ export default function transform( if (path.node.property.type !== 'Identifier') return if (path.node.property.name !== 'updateBody') return path.node.property.name = 'updateForwardedProps' - changed++ + stats.renamed++ }) } // 5. chat({ conversationId }) → chat({ threadId }) if (facts.hasChat) { - root.find(j.CallExpression).forEach((path) => { - const callee = path.node.callee - if (callee.type !== 'Identifier' || callee.name !== 'chat') return - const objArg = path.node.arguments.find( - (a): a is ObjectExpression => a.type === 'ObjectExpression', - ) - if (objArg && renameProperty(objArg, 'conversationId', 'threadId')) { - changed++ - } - }) + renameKeyOnCalls( + j, + root, + file.path, + (path) => { + const callee = path.node.callee + return callee.type === 'Identifier' && callee.name === 'chat' + }, + 'conversationId', + 'threadId', + 'chat({ conversationId })', + stats, + ) + } + + // Surface conflicts so users can resolve them by hand. The codemod + // intentionally leaves `{ body, forwardedProps }` (or other dual-key) + // objects alone — silently swallowing them would either drop one + // value or produce a duplicate-key object literal. + for (const conflict of stats.conflicts) { + const where = + conflict.line !== undefined + ? `${conflict.filePath}:${conflict.line}` + : conflict.filePath + api.report( + `[ag-ui-compliance] ${where} — ${conflict.site}: both legacy and canonical keys are already present; left alone. Merge by hand.`, + ) } - return changed > 0 ? root.toSource() : file.source + return stats.renamed > 0 ? root.toSource() : file.source } // jscodeshift inspects `.parser` on the default export to choose its diff --git a/codemods/package.json b/codemods/package.json index 73654abf2..8d20d0910 100644 --- a/codemods/package.json +++ b/codemods/package.json @@ -6,13 +6,14 @@ "type": "module", "scripts": { "test": "vitest run", - "test:dev": "vitest" + "test:dev": "vitest", + "ag-ui-compliance": "node ./run.mjs ag-ui-compliance" }, "devDependencies": { "@types/jscodeshift": "^0.12.0", - "@types/node": "^22.10.0", + "@types/node": "^24.10.1", "jscodeshift": "^17.1.1", "typescript": "5.9.3", - "vitest": "^4.1.4" + "vitest": "^4.0.14" } } diff --git a/codemods/run.mjs b/codemods/run.mjs new file mode 100644 index 000000000..a8be02e6d --- /dev/null +++ b/codemods/run.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Codemod runner. + * + * Resolves user-supplied glob/file arguments against the original + * `INIT_CWD` (the directory the user ran `pnpm` from) instead of the + * package directory pnpm switches into when `--filter ... exec` is used. + * + * Usage (from the repo root): + * pnpm codemod:ag-ui-compliance "src/**\/*.{ts,tsx}" + * + * The first argument is the codemod folder name under `codemods/`. + * The remainder are jscodeshift's own arguments (paths and flags). + */ +import { spawnSync } from 'node:child_process' +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import process from 'node:process' + +const here = dirname(fileURLToPath(import.meta.url)) +const [codemodName, ...rest] = process.argv.slice(2) + +if (!codemodName) { + console.error('Usage: codemod-runner [jscodeshift args...]') + process.exit(2) +} + +const transformPath = resolve(here, codemodName, 'transform.ts') +// `INIT_CWD` is set by pnpm to the directory the user ran `pnpm` from +// (preserved across `--filter ... exec`). Fall back to the parent shell's +// CWD if the runner is invoked outside pnpm. +const userCwd = process.env['INIT_CWD'] || process.cwd() + +// Resolve any positional path-like argument against `userCwd`. Flag +// arguments (start with `-`) are passed through untouched. +const args = ['--parser=tsx', '-t', transformPath] +for (const arg of rest) { + if (arg.startsWith('-') || isAbsolute(arg)) { + args.push(arg) + } else { + args.push(resolve(userCwd, arg)) + } +} + +const result = spawnSync('jscodeshift', args, { + cwd: here, + stdio: 'inherit', + shell: process.platform === 'win32', +}) +process.exit(result.status ?? 1) diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 923d2eb78..438b297b3 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -73,11 +73,13 @@ Add `--dry --print` to preview changes first. The codemod is import-source–gat **What this means for server code:** -- **Most callers don't need to do anything.** When you don't pass `conversationId` to `chat()`, the runtime auto-generates a `threadId` and uses it for devtools event correlation automatically. Existing code keeps working. -- **`chat({ conversationId: 'foo' })` still works** — it's now treated as `chat({ threadId: 'foo' })` internally. No code change required. +- **Server code that doesn't reference `conversationId` is unaffected.** When `chat({ conversationId })` is omitted, the runtime auto-generates a stable `threadId` per request and uses it for devtools event correlation. +- **`chat({ conversationId: 'foo' })` still works** — `conversationId` is now a deprecated alias for `threadId`, resolved internally. No code change required. - **`chat({ threadId: 'foo' })` is the canonical form** — prefer it in new code. If both are passed, `threadId` wins. - **`TextOptions.conversationId` is `@deprecated`** in JSDoc and will be removed in a future major release. +> **One real behavior change to verify.** If your server reads `body.forwardedProps?.conversationId` (or the legacy `body.data?.conversationId`) and threads it into `chat({ conversationId })`, the value will now be `undefined` for any client running the upgraded `@tanstack/ai-client`, because the client no longer auto-emits `conversationId`. The fall-back to an auto-generated `threadId` keeps devtools correlation working *within* a single request, but **threadId stability across requests now depends on the client sending its own `threadId`** (which `ChatClient` does — see the AG-UI top-level `threadId` field surfaced via `params.threadId`). To restore the prior cross-request stable identifier, switch the server to read `params.threadId` and pass it to `chat({ threadId: params.threadId })`, or rely on the auto-fallback if cross-request stability is not required. + **Custom middleware:** `ChatMiddlewareContext` now exposes both `ctx.threadId` (canonical) and `ctx.conversationId` (deprecated alias, always equal to `ctx.threadId`). New middleware should read `ctx.threadId`; existing middleware reading `ctx.conversationId` keeps working. ```ts diff --git a/package.json b/package.json index 8ce41e9eb..78f7b7997 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:types": "nx affected --targets=test:types --exclude=examples/**", "test:knip": "knip", "test:docs": "node scripts/verify-links.ts", - "codemod:ag-ui-compliance": "pnpm --filter @tanstack/ai-codemods exec jscodeshift --parser=tsx -t ag-ui-compliance/transform.ts", + "codemod:ag-ui-compliance": "pnpm --filter @tanstack/ai-codemods exec node ./run.mjs ag-ui-compliance", "build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**", "build:all": "nx run-many --targets=build --exclude=examples/**", "watch": "pnpm run build:all && env NX_DAEMON=true nx watch --all -- pnpm run build:all", diff --git a/packages/typescript/ai/src/utilities/chat-params.ts b/packages/typescript/ai/src/utilities/chat-params.ts index 7d9c47d32..3d45d7914 100644 --- a/packages/typescript/ai/src/utilities/chat-params.ts +++ b/packages/typescript/ai/src/utilities/chat-params.ts @@ -124,21 +124,34 @@ export async function chatParamsFromRequest( let body: unknown try { body = await req.json() - } catch { - throw new Response('Invalid JSON body', { status: 400 }) + } catch (cause) { + // Preserve the underlying error on the thrown Response for + // server-side observability without leaking it to the client. + const res = new Response( + 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.', + { status: 400 }, + ) + ;(res as unknown as { cause?: unknown }).cause = cause + throw res } try { return await chatParamsFromRequestBody(body) - } catch (error) { - throw new Response( - error instanceof Error ? error.message : 'Bad request', + } catch (cause) { + // Generic public message — avoid echoing Zod paths (which can contain + // user payload fragments) or internal validator strings to the client. + // The original AGUIError is attached as `cause` so server logs can + // surface it without exposing it to remote callers. + const res = new Response( + 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.', { status: 400 }, ) + ;(res as unknown as { cause?: unknown }).cause = cause + throw res } } /** - * Merge a server-side tool registry with the AG-UI client-declared tools + * Merge a server-side tool array with the AG-UI client-declared tools * received in the request body. * * Rules: @@ -152,33 +165,35 @@ export async function chatParamsFromRequest( * them — server emits a tool-call request, client executes via its * registered handler, client posts back the result. * - * @param serverTools - The server's `toolDefinition().server(...)` registry, - * keyed by tool name. + * @param serverTools - The server's tool array (e.g. from + * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`. * @param clientTools - The `tools` array received from - * `chatParamsFromRequestBody(...)`. - * @returns A merged record suitable for `chat({ tools })`. + * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`. + * @returns A merged array suitable for `chat({ tools })`. */ export function mergeAgentTools( - serverTools: Record, - clientTools: Array<{ + serverTools: ReadonlyArray, + clientTools: ReadonlyArray<{ name: string description: string parameters: JSONSchema }>, -): Record { - const merged: Record = { ...serverTools } +): Array { + const seen = new Set(serverTools.map((t) => t.name)) + const merged: Array = [...serverTools] for (const ct of clientTools) { - if (ct.name in merged) { - // Server wins + if (seen.has(ct.name)) { + // Server wins on name collision. continue } - merged[ct.name] = { + seen.add(ct.name) + merged.push({ name: ct.name, description: ct.description, inputSchema: ct.parameters, // No `execute` — runtime treats this as a client-side tool and // emits ClientToolRequest events. - } as Tool + } as Tool) } return merged } diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts index 63ba36e27..5109a4123 100644 --- a/packages/typescript/ai/tests/chat-params.test.ts +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -100,13 +100,24 @@ describe('chatParamsFromRequest', () => { }) it('throws a 400 Response when JSON is malformed', async () => { - const req = makeRequest('{not-json') - await expect(chatParamsFromRequest(req)).rejects.toBeInstanceOf(Response) + // `Request.json()` consumes the body — every call needs a fresh + // Request so the second invocation actually exercises the parse-failure + // path rather than the "body already read" branch. + await expect( + chatParamsFromRequest(makeRequest('{not-json')), + ).rejects.toBeInstanceOf(Response) + try { - await chatParamsFromRequest(req) + await chatParamsFromRequest(makeRequest('{not-json')) } catch (thrown) { expect(thrown).toBeInstanceOf(Response) - expect((thrown as Response).status).toBe(400) + const res = thrown as Response + expect(res.status).toBe(400) + const body = await res.text() + // Public message must NOT echo Zod / parser internals. + expect(body).toMatch(/AG-UI|migration/i) + // Underlying error is preserved as `cause` for server-side logs. + expect((res as unknown as { cause?: unknown }).cause).toBeDefined() } }) @@ -120,7 +131,9 @@ describe('chatParamsFromRequest', () => { const res = thrown as Response expect(res.status).toBe(400) const body = await res.text() - expect(body).toMatch(/AG-UI|RunAgentInput|migration/i) + expect(body).toMatch(/AG-UI|migration/i) + // Original AGUIError is attached as `cause`. + expect((res as unknown as { cause?: unknown }).cause).toBeDefined() } }) }) @@ -134,14 +147,15 @@ describe('mergeAgentTools', () => { }) it('returns server tools unchanged when client list is empty', () => { - const server = { greet: fakeServerTool('greet') } + const server = [fakeServerTool('greet')] const result = mergeAgentTools(server, []) - expect(Object.keys(result)).toEqual(['greet']) - expect(result['greet']!.execute).toBeDefined() + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('greet') + expect(result[0]!.execute).toBeDefined() }) it('adds client-only tools as no-execute stubs', () => { - const server = {} + const server: Array> = [] const client = [ { name: 'showToast', @@ -150,17 +164,15 @@ describe('mergeAgentTools', () => { }, ] const result = mergeAgentTools(server, client) - expect(Object.keys(result)).toEqual(['showToast']) - expect(result['showToast']!.execute).toBeUndefined() - expect(result['showToast']!.inputSchema).toEqual({ - type: 'object', - properties: {}, - }) - expect(result['showToast']!.description).toBe('render a toast') + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('showToast') + expect(result[0]!.execute).toBeUndefined() + expect(result[0]!.inputSchema).toEqual({ type: 'object', properties: {} }) + expect(result[0]!.description).toBe('render a toast') }) it('server wins on name collision (client declaration ignored)', () => { - const server = { greet: fakeServerTool('greet') } + const server = [fakeServerTool('greet')] const client = [ { name: 'greet', @@ -169,11 +181,30 @@ describe('mergeAgentTools', () => { }, ] const result = mergeAgentTools(server, client) - expect(result['greet']!.description).toBe('server greet') - expect(result['greet']!.execute).toBeDefined() + expect(result).toHaveLength(1) + expect(result[0]!.description).toBe('server greet') + expect(result[0]!.execute).toBeDefined() + }) + + it('preserves the order: server tools first, then unique client tools', () => { + const server = [fakeServerTool('alpha'), fakeServerTool('beta')] + const client = [ + { + name: 'beta', // collides — should NOT be added again + description: 'overridden', + parameters: { type: 'object', properties: {} }, + }, + { + name: 'gamma', + description: 'a client-only tool', + parameters: { type: 'object', properties: {} }, + }, + ] + const result = mergeAgentTools(server, client) + expect(result.map((t) => t.name)).toEqual(['alpha', 'beta', 'gamma']) }) it('handles empty server and empty client', () => { - expect(mergeAgentTools({}, [])).toEqual({}) + expect(mergeAgentTools([], [])).toEqual([]) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dd4740bc..c9669744b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,8 +92,8 @@ importers: specifier: ^0.12.0 version: 0.12.0 '@types/node': - specifier: ^22.10.0 - version: 22.19.18 + specifier: ^24.10.1 + version: 24.10.3 jscodeshift: specifier: ^17.1.1 version: 17.3.0 @@ -101,8 +101,8 @@ importers: specifier: 5.9.3 version: 5.9.3 vitest: - specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.18)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^4.0.14 + version: 4.1.4(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) examples/php-slim: devDependencies: @@ -6440,9 +6440,6 @@ packages: '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} - '@types/node@22.19.18': - resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} - '@types/node@24.10.3': resolution: {integrity: sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==} @@ -17181,10 +17178,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.19.18': - dependencies: - undici-types: 6.21.0 - '@types/node@24.10.3': dependencies: undici-types: 7.16.0 @@ -17550,14 +17543,6 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.1.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.4 @@ -23824,23 +23809,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.18 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.2 - vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -23926,35 +23894,6 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@22.19.18)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@22.19.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.18 - happy-dom: 20.0.11 - jsdom: 27.3.0(postcss@8.5.9) - transitivePeerDependencies: - - msw - vitest@4.1.4(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.4 From c8231fa2e2f63e14ec3e0330c4de706d4a62025a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 16:13:18 +0200 Subject: [PATCH 24/29] fix: round-2 CR fixes (codemod shorthand + updateOptions partial replace) Bucket (a) findings from the 7-agent confirmation round: - Codemod renameProperty: shorthand `useChat({ body })` was being rewritten to broken `{ forwardedProps }` referencing an undefined identifier. Now expanded to `{ forwardedProps: body }`. Added shorthand-body fixture + test. - Codemod test harness: now captures `api.report` calls and the conflict-leave-alone test asserts the warning fires. Previously the test passed a no-op report stub, so a regression in the conflict- warning path would have gone undetected. Also added an explicit "no-reports for clean transforms" case. - ChatClient.updateOptions: tracking body / forwardedProps in a single merged slot meant `updateOptions({ forwardedProps })` would silently wipe a previously-set body (and vice versa). Now stored separately and merged at send time; updateOptions replaces each slot independently. Added regression tests for both partial-update directions. --- .../__testfixtures__/shorthand-body.input.tsx | 15 ++++++ .../shorthand-body.output.tsx | 15 ++++++ codemods/ag-ui-compliance/transform.test.ts | 49 ++++++++++++++++--- codemods/ag-ui-compliance/transform.ts | 19 +++++++ .../typescript/ai-client/src/chat-client.ts | 46 ++++++++++------- .../ai-client/tests/chat-client.test.ts | 47 ++++++++++++++++++ 6 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx create mode 100644 codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx diff --git a/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx new file mode 100644 index 000000000..f49d988bd --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.input.tsx @@ -0,0 +1,15 @@ +// Shorthand-key edge case: `body` is a local variable, passed via +// shorthand. After the rename, the call must still reference the +// `body` identifier (i.e. `forwardedProps: body`), NOT the literal +// `forwardedProps` (which is undefined in this scope). + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const body = { provider: 'openai' } + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + body, + }) + return result +} diff --git a/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx new file mode 100644 index 000000000..c03cb1bf1 --- /dev/null +++ b/codemods/ag-ui-compliance/__testfixtures__/shorthand-body.output.tsx @@ -0,0 +1,15 @@ +// Shorthand-key edge case: `body` is a local variable, passed via +// shorthand. After the rename, the call must still reference the +// `body` identifier (i.e. `forwardedProps: body`), NOT the literal +// `forwardedProps` (which is undefined in this scope). + +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +export function Chat() { + const body = { provider: 'openai' } + const result = useChat({ + connection: fetchServerSentEvents('/api/chat'), + forwardedProps: body, + }) + return result +} diff --git a/codemods/ag-ui-compliance/transform.test.ts b/codemods/ag-ui-compliance/transform.test.ts index 8db3d9ca9..c22a0d42b 100644 --- a/codemods/ag-ui-compliance/transform.test.ts +++ b/codemods/ag-ui-compliance/transform.test.ts @@ -13,8 +13,12 @@ function read(name: string): string { return readFileSync(resolve(FIXTURES, name), 'utf-8') } -function runTransform(fixtureBaseName: string, ext: 'ts' | 'tsx'): string { +function runTransform( + fixtureBaseName: string, + ext: 'ts' | 'tsx', +): { output: string; reports: Array } { const source = read(`${fixtureBaseName}.input.${ext}`) + const reports: Array = [] const j = jscodeshift.withParser('tsx') const result = transform( { path: `${fixtureBaseName}.input.${ext}`, source }, @@ -22,11 +26,18 @@ function runTransform(fixtureBaseName: string, ext: 'ts' | 'tsx'): string { jscodeshift: j, j, stats: () => {}, - report: () => {}, + report: (msg: string) => { + reports.push(msg) + }, }, {}, ) - return typeof result === 'string' ? result : source + if (typeof result !== 'string') { + throw new Error( + `transform returned ${typeof result} for ${fixtureBaseName}.input.${ext}; expected a string`, + ) + } + return { output: result, reports } } // Normalize line endings — fixtures may be saved as CRLF on Windows @@ -36,10 +47,14 @@ function normalize(s: string): string { return s.replace(/\r\n/g, '\n').trim() } -function expectFixture(name: string, ext: 'ts' | 'tsx' = 'ts'): void { +function expectFixture( + name: string, + ext: 'ts' | 'tsx' = 'ts', +): { reports: Array } { const expected = read(`${name}.output.${ext}`) - const actual = runTransform(name, ext) - expect(normalize(actual)).toBe(normalize(expected)) + const { output, reports } = runTransform(name, ext) + expect(normalize(output)).toBe(normalize(expected)) + return { reports } } describe('ag-ui-compliance codemod', () => { @@ -47,6 +62,10 @@ describe('ag-ui-compliance codemod', () => { expectFixture('use-chat-body', 'tsx') }) + it('expands a shorthand `body` property to `forwardedProps: body` (preserves the original identifier reference)', () => { + expectFixture('shorthand-body', 'tsx') + }) + it('renames body on ChatClient constructor and updateOptions calls', () => { expectFixture('chat-client-body') }) @@ -63,7 +82,21 @@ describe('ag-ui-compliance codemod', () => { expectFixture('no-imports') }) - it('leaves objects that already declare both keys untouched', () => { - expectFixture('conflict-leave-alone') + it('leaves objects that already declare both keys untouched, and reports the conflict for human resolution', () => { + const { reports } = expectFixture('conflict-leave-alone') + // The codemod must surface conflicts via api.report so users can + // find and merge them; silently leaving them alone hides intentional + // (or accidental) dual-key state from the migration audit. + expect(reports.length).toBeGreaterThan(0) + expect( + reports.some( + (r) => r.includes('useChat({ body })') && r.includes('left alone'), + ), + ).toBe(true) + }) + + it('emits no report messages for clean transforms', () => { + const { reports } = expectFixture('use-chat-body', 'tsx') + expect(reports).toEqual([]) }) }) diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index 803eda7b5..05f640372 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -139,6 +139,25 @@ function renameProperty( return 'conflict' } if (oldProp.key.type === 'Identifier') { + // For shorthand `{ body }`, the AST stores `key === value === Identifier('body')` + // (or two equal-named Identifier nodes plus `shorthand: true`). Mutating + // only the key would leave the printer emitting `{ forwardedProps }`, + // which silently references an undefined identifier in the user's + // scope. Expand to long form so the original `body` reference survives: + // `{ body }` → `{ forwardedProps: body }`. + const propAsAny = oldProp as unknown as { + shorthand?: boolean + value?: { type?: string; name?: string } + } + if ( + propAsAny.shorthand && + propAsAny.value?.type === 'Identifier' && + propAsAny.value.name === oldName + ) { + // Leave value pointing at the original identifier; only flip + // `shorthand` off and rename the key. + propAsAny.shorthand = false + } oldProp.key.name = newName return 'renamed' } diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 2542e72a5..6ffac2c5a 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -31,7 +31,12 @@ export class ChatClient { private connection: SubscribeConnectionAdapter private uniqueId: string private threadId: string - private body: Record = {} + // Track the legacy `body` option and the canonical `forwardedProps` + // option as separate slots so that `updateOptions({ forwardedProps })` + // doesn't wipe a previously-set `body` (and vice versa). They are + // merged on every send, with `forwardedProps` winning on key collision. + private bodyOption: Record = {} + private forwardedPropsOption: Record = {} private pendingMessageBody: Record | undefined = undefined private isLoading = false private isSubscribed = false @@ -83,11 +88,13 @@ export class ChatClient { constructor(options: ChatClientOptions) { this.uniqueId = options.id || this.generateUniqueId('chat') this.threadId = options.threadId || this.generateUniqueId('thread') - // Merge legacy `body` with new `forwardedProps`. Both populate the - // AG-UI `RunAgentInput.forwardedProps` wire field. `forwardedProps` - // wins on key collision so users migrating individual fields are - // not surprised by stale `body` values shadowing their new ones. - this.body = { ...(options.body || {}), ...(options.forwardedProps || {}) } + // Both `body` (deprecated) and `forwardedProps` populate the AG-UI + // `RunAgentInput.forwardedProps` wire field. They are stored + // separately so `updateOptions` can replace one without touching the + // other; the merge happens at send time, with `forwardedProps` + // winning on key collision. + this.bodyOption = options.body || {} + this.forwardedPropsOption = options.forwardedProps || {} this.connection = normalizeConnectionAdapter(options.connection) this.events = new DefaultChatClientEventEmitter(this.uniqueId) @@ -592,14 +599,18 @@ export class ChatClient { // Call onResponse callback await this.callbacksRef.current.onResponse() - // Merge body: base body + per-message body (per-message takes priority). + // Merge sources for the wire `forwardedProps` field, in priority + // order (later spreads win): + // 1. Legacy `body` option (deprecated). + // 2. Canonical `forwardedProps` option (wins over `body`). + // 3. Per-message `body` arg passed to `sendMessage` (highest). // The AG-UI standard `threadId` is sent at the wire's top level for // run/conversation correlation, so we no longer auto-emit a separate // `conversationId` here — `chat({ threadId })` server-side covers the - // same role for devtools/observability. User-set values still pass - // through `forwardedProps` unchanged. + // same role for devtools/observability. const mergedBody = { - ...this.body, + ...this.bodyOption, + ...this.forwardedPropsOption, ...this.pendingMessageBody, } @@ -1040,13 +1051,14 @@ export class ChatClient { this.subscribe() } } - if (options.body !== undefined || options.forwardedProps !== undefined) { - // Merge the same way the constructor does; either side may be - // omitted, in which case it contributes nothing. - this.body = { - ...(options.body ?? {}), - ...(options.forwardedProps ?? {}), - } + // Replace each slot independently so callers can update one without + // wiping the other. (Passing `undefined` for either field is a "leave + // unchanged" signal — to clear a slot, pass an empty object `{}`.) + if (options.body !== undefined) { + this.bodyOption = options.body + } + if (options.forwardedProps !== undefined) { + this.forwardedPropsOption = options.forwardedProps } if (options.tools !== undefined) { this.clientToolsRef.current = new Map() diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index b0540f910..ca8b1e7c5 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -1712,6 +1712,53 @@ describe('ChatClient', () => { expect(capturedData?.model).toBe('gpt-4o') }) + it('updateOptions({ forwardedProps }) leaves a previously-set body intact', async () => { + const chunks = createTextChunks('Response') + const captures: Array | undefined> = [] + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + captures.push(data) + }, + }) + + const client = new ChatClient({ + connection: adapter, + body: { provider: 'openai' }, + forwardedProps: { model: 'gpt-4' }, + }) + + // Replace only `forwardedProps` — `body` must survive. + client.updateOptions({ forwardedProps: { model: 'gpt-4o' } }) + + await client.sendMessage('Hi') + expect(captures[0]?.provider).toBe('openai') + expect(captures[0]?.model).toBe('gpt-4o') + }) + + it('updateOptions({ body }) leaves a previously-set forwardedProps intact', async () => { + const chunks = createTextChunks('Response') + const captures: Array | undefined> = [] + const adapter = createMockConnectionAdapter({ + chunks, + onConnect: (_messages, data) => { + captures.push(data) + }, + }) + + const client = new ChatClient({ + connection: adapter, + body: { provider: 'openai' }, + forwardedProps: { model: 'gpt-4' }, + }) + + client.updateOptions({ body: { provider: 'anthropic' } }) + + await client.sendMessage('Hi') + expect(captures[0]?.provider).toBe('anthropic') + expect(captures[0]?.model).toBe('gpt-4') + }) + it('should merge body and forwardedProps with forwardedProps winning', async () => { const chunks = createTextChunks('Response') let capturedData: Record | undefined From 59d9faca53b6773fcafbd3d04d075485435a00e9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 18:24:35 +0200 Subject: [PATCH 25/29] fix: address CodeRabbit feedback on AG-UI compliance PR Critical - Codemod fixture for `chat({ conversationId })`: read from `body.threadId` (the AG-UI top-level wire field) instead of the now-undefined `body.forwardedProps?.conversationId`. Document the value-source limitation in the codemod README. Major - chat-client.ts: convert each client tool's `inputSchema` to JSON Schema via `convertSchemaToJsonSchema` before sending on the wire. `inputSchema` is a Standard Schema instance (Zod, ArkType, Valibot, etc.); foreign AG-UI servers expect JSON Schema in `RunAgentInput.tools[].parameters`. Mirrors the existing realtime- client.ts pattern. - Codemod: narrow the Svelte `updateBody` rename gate from "any import from `@tanstack/ai-svelte`" to "imports `createChat`". A file that imports types or other helpers from the package but uses an unrelated `.updateBody(...)` from elsewhere will no longer be falsely rewritten. Minor - ESLint sort-imports: alphabetize named imports in `transform.test.ts` and `chat-params.test.ts`. - `codemods/package.json`: bump `@types/jscodeshift` to `^17.1.1` to match `jscodeshift@^17.1.1` (was `^0.12.0`, mismatched major). - `chat-architecture.md`: fix relative link to the migration guide from the package's nested docs/ directory. - Codemod README: add `text` language identifier to the conflict- warning code block to satisfy MD040. Skipped - "gpt-5.2 doesn't exist": false positive (CodeRabbit's web search hallucinated a 2026 release; the rest of the repo uses gpt-5.2). - Tier-2 quick-start expansion: keeping the Tier-1 minimal `body.messages` example intentional. Tier-2 helper usage is documented in the migration guide. - `uiMessagesToWire` signature widening: out-of-scope refactor across packages; documented for follow-up. --- codemods/ag-ui-compliance/README.md | 3 +- .../chat-conversation-id.input.ts | 2 +- .../chat-conversation-id.output.ts | 2 +- codemods/ag-ui-compliance/transform.test.ts | 4 +-- codemods/ag-ui-compliance/transform.ts | 18 ++++++---- codemods/package.json | 2 +- .../typescript/ai-client/src/chat-client.ts | 16 +++++---- .../typescript/ai/docs/chat-architecture.md | 2 +- .../typescript/ai/tests/chat-params.test.ts | 2 +- pnpm-lock.yaml | 35 +++++-------------- 10 files changed, 38 insertions(+), 48 deletions(-) diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md index 018b8f180..2b2d79567 100644 --- a/codemods/ag-ui-compliance/README.md +++ b/codemods/ag-ui-compliance/README.md @@ -20,6 +20,7 @@ Each rename is gated by an **import-source check** — the codemod only rewrites - **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md). - **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too. +- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property *key* `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one. ## Running it @@ -51,7 +52,7 @@ pnpm codemod:ag-ui-compliance "examples/**/*.{ts,tsx}" If an object literal already declares **both** the legacy and the canonical key — for example, `useChat({ body: {...}, forwardedProps: {...} })` — the codemod leaves it alone and prints a warning of the form: -``` +```text [ag-ui-compliance] path/to/file.tsx:42 — useChat({ body }): both legacy and canonical keys are already present; left alone. Merge by hand. ``` diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts index 11ada7b89..6e9194cf6 100644 --- a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.input.ts @@ -6,7 +6,7 @@ export async function POST(req: Request) { const stream = chat({ adapter: openaiText('gpt-4o'), messages: body.messages, - conversationId: body.forwardedProps?.conversationId, + conversationId: body.threadId, }) return toServerSentEventsResponse(stream) } diff --git a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts index 85bd72610..5fbf1df7c 100644 --- a/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts +++ b/codemods/ag-ui-compliance/__testfixtures__/chat-conversation-id.output.ts @@ -6,7 +6,7 @@ export async function POST(req: Request) { const stream = chat({ adapter: openaiText('gpt-4o'), messages: body.messages, - threadId: body.forwardedProps?.conversationId, + threadId: body.threadId, }) return toServerSentEventsResponse(stream) } diff --git a/codemods/ag-ui-compliance/transform.test.ts b/codemods/ag-ui-compliance/transform.test.ts index c22a0d42b..b19c0e451 100644 --- a/codemods/ag-ui-compliance/transform.test.ts +++ b/codemods/ag-ui-compliance/transform.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' -import { resolve, dirname } from 'node:path' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import jscodeshift from 'jscodeshift' +import { describe, expect, it } from 'vitest' import transform from './transform' const __filename = fileURLToPath(import.meta.url) diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index 05f640372..01a3c8ca9 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -53,8 +53,8 @@ interface ImportFacts { hasUseChat: boolean /** Whether `ChatClient` is imported from `@tanstack/ai-client`. */ hasChatClient: boolean - /** Whether anything is imported from `@tanstack/ai-svelte`. */ - hasSvelte: boolean + /** Whether `createChat` is imported from `@tanstack/ai-svelte`. */ + hasCreateChat: boolean /** Whether `chat` is imported from `@tanstack/ai`. */ hasChat: boolean } @@ -66,7 +66,7 @@ function collectImportFacts( const facts: ImportFacts = { hasUseChat: false, hasChatClient: false, - hasSvelte: false, + hasCreateChat: false, hasChat: false, } @@ -88,8 +88,12 @@ function collectImportFacts( if (source === CLIENT_PACKAGE && importedNames.has('ChatClient')) { facts.hasChatClient = true } - if (source === SVELTE_PACKAGE) { - facts.hasSvelte = true + // Gate the Svelte `updateBody` rename on the specific `createChat` + // import name, not "any import from the package" — otherwise an + // unrelated `.updateBody(...)` call elsewhere in the same file + // (a form helper, custom store, etc.) would be silently rewritten. + if (source === SVELTE_PACKAGE && importedNames.has('createChat')) { + facts.hasCreateChat = true } if (source === CORE_PACKAGE && importedNames.has('chat')) { facts.hasChat = true @@ -219,7 +223,7 @@ export default function transform( if ( !facts.hasUseChat && !facts.hasChatClient && - !facts.hasSvelte && + !facts.hasCreateChat && !facts.hasChat ) { return file.source @@ -293,7 +297,7 @@ export default function transform( } // 4. chat.updateBody(x) → chat.updateForwardedProps(x) - if (facts.hasSvelte) { + if (facts.hasCreateChat) { root.find(j.MemberExpression).forEach((path) => { if (path.node.computed) return if (path.node.property.type !== 'Identifier') return diff --git a/codemods/package.json b/codemods/package.json index 8d20d0910..5bee64e8f 100644 --- a/codemods/package.json +++ b/codemods/package.json @@ -10,7 +10,7 @@ "ag-ui-compliance": "node ./run.mjs ag-ui-compliance" }, "devDependencies": { - "@types/jscodeshift": "^0.12.0", + "@types/jscodeshift": "^17.1.1", "@types/node": "^24.10.1", "jscodeshift": "^17.1.1", "typescript": "5.9.3", diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index d75641889..755a8e2a9 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -1,5 +1,6 @@ import { StreamProcessor, + convertSchemaToJsonSchema, generateMessageId, normalizeToUIMessage, } from '@tanstack/ai' @@ -167,11 +168,7 @@ export class ChatClient { this.events.textUpdated(this.currentStreamId, messageId, content) } }, - onThinkingUpdate: ( - messageId: string, - _stepId: string, - content: string, - ) => { + onThinkingUpdate: (messageId: string, content: string) => { // Emit thinking update to devtools if (this.currentStreamId) { this.events.thinkingUpdated( @@ -652,6 +649,11 @@ export class ChatClient { // Build per-send run context for AG-UI compliance // Note: mergedBody already contains the merged this.body + pendingMessageBody // (pendingMessageBody was cleared above, so we use mergedBody as forwardedProps) + // Convert each client tool's `inputSchema` (a Standard Schema: + // Zod, ArkType, Valibot, etc.) to JSON Schema for the wire. Foreign + // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect + // JSON Schema; sending a Standard Schema instance directly would + // serialize to an unusable shape. const runContext = { threadId: this.threadId, runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -659,7 +661,9 @@ export class ChatClient { (t) => ({ name: t.name, description: t.description, - parameters: t.inputSchema || { type: 'object' }, + parameters: t.inputSchema + ? convertSchemaToJsonSchema(t.inputSchema) + : { type: 'object' }, }), ), forwardedProps: { ...mergedBody }, diff --git a/packages/typescript/ai/docs/chat-architecture.md b/packages/typescript/ai/docs/chat-architecture.md index c0693bb45..687a0b50b 100644 --- a/packages/typescript/ai/docs/chat-architecture.md +++ b/packages/typescript/ai/docs/chat-architecture.md @@ -83,7 +83,7 @@ Each entry in `messages` is either: On the server, `chatParamsFromRequestBody` validates the body and returns the parsed fields. `convertMessagesToModelMessages` (called inside `chat()`) handles dedup: when an anchor's `parts` already contain a `tool-result`, the matching fan-out tool message is dropped from the `ModelMessage[]` fed to the LLM. `reasoning` and `activity` messages are dropped (no `ModelMessage` equivalent today); `developer` messages collapse to `system`. -For the migration story when upgrading, see `docs/migration/ag-ui-compliance.md`. +For the migration story when upgrading, see [`docs/migration/ag-ui-compliance.md`](../../../../docs/migration/ag-ui-compliance.md). --- diff --git a/packages/typescript/ai/tests/chat-params.test.ts b/packages/typescript/ai/tests/chat-params.test.ts index 5109a4123..711e8ca7a 100644 --- a/packages/typescript/ai/tests/chat-params.test.ts +++ b/packages/typescript/ai/tests/chat-params.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { chatParamsFromRequest, chatParamsFromRequestBody, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b1de271c..5521a3d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,8 +89,8 @@ importers: codemods: devDependencies: '@types/jscodeshift': - specifier: ^0.12.0 - version: 0.12.0 + specifier: ^17.1.1 + version: 17.3.0 '@types/node': specifier: ^24.10.1 version: 24.10.3 @@ -102,7 +102,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.14 - version: 4.1.4(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.9))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) examples/php-slim: devDependencies: @@ -6440,8 +6440,8 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/jscodeshift@0.12.0': - resolution: {integrity: sha512-Jr2fQbEoDmjwEa92TreR/mX2t9iAaY/l5P/GKezvK4BodXahex60PDLXaQR0vAgP0KfCzc1CivHusQB9NhzX8w==} + '@types/jscodeshift@17.3.0': + resolution: {integrity: sha512-ogvGG8VQQqAQQ096uRh+d6tBHrYuZjsumHirKtvBa5qEyTMN3IQJ7apo+sw9lxaB/iKWIhbbLlF3zmAWk9XQIg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -7000,10 +7000,6 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} - ast-types@0.14.2: - resolution: {integrity: sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==} - engines: {node: '>=4'} - ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -10383,10 +10379,6 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} - recast@0.20.5: - resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==} - engines: {node: '>= 4'} - recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -17193,10 +17185,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/jscodeshift@0.12.0': + '@types/jscodeshift@17.3.0': dependencies: - ast-types: 0.14.2 - recast: 0.20.5 + ast-types: 0.16.1 + recast: 0.23.11 '@types/json-schema@7.0.15': {} @@ -17941,10 +17933,6 @@ snapshots: dependencies: tslib: 2.8.1 - ast-types@0.14.2: - dependencies: - tslib: 2.8.1 - ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -22204,13 +22192,6 @@ snapshots: readdirp@5.0.0: {} - recast@0.20.5: - dependencies: - ast-types: 0.14.2 - esprima: 4.0.1 - source-map: 0.6.1 - tslib: 2.8.1 - recast@0.23.11: dependencies: ast-types: 0.16.1 From c3bdd2c623a2a49a9a590e8aa16f2eaf20cbe4f2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 16:25:50 +0000 Subject: [PATCH 26/29] ci: apply automated fixes --- codemods/README.md | 4 ++-- codemods/ag-ui-compliance/README.md | 14 +++++++------- codemods/ag-ui-compliance/transform.ts | 15 ++++++--------- packages/typescript/ai/tests/middleware.test.ts | 10 +++++++--- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/codemods/README.md b/codemods/README.md index 4299515c7..087be4867 100644 --- a/codemods/README.md +++ b/codemods/README.md @@ -6,8 +6,8 @@ Each codemod lives in its own subdirectory and is named after the migration it c ## Available codemods -| Codemod | Migrates | -|---|---| +| Codemod | Migrates | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`ag-ui-compliance`](./ag-ui-compliance) | Client-side renames introduced by the AG-UI client/server compliance release: `body` → `forwardedProps` on `useChat` / `ChatClient` / `updateOptions`, Svelte's `updateBody` → `updateForwardedProps`, and `chat({ conversationId })` → `chat({ threadId })`. | ## Running a codemod diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md index 2b2d79567..dbfc27077 100644 --- a/codemods/ag-ui-compliance/README.md +++ b/codemods/ag-ui-compliance/README.md @@ -6,13 +6,13 @@ Migrates client-side TanStack AI code from the legacy field names to the AG-UI ## What it does -| Before | After | -|---|---| -| `useChat({ body: {...} })` | `useChat({ forwardedProps: {...} })` | -| `new ChatClient({ body: {...} })` | `new ChatClient({ forwardedProps: {...} })` | +| Before | After | +| --------------------------------------- | ------------------------------------------------- | +| `useChat({ body: {...} })` | `useChat({ forwardedProps: {...} })` | +| `new ChatClient({ body: {...} })` | `new ChatClient({ forwardedProps: {...} })` | | `client.updateOptions({ body: {...} })` | `client.updateOptions({ forwardedProps: {...} })` | -| `chat.updateBody(x)` *(Svelte)* | `chat.updateForwardedProps(x)` | -| `chat({ conversationId: x })` | `chat({ threadId: x })` | +| `chat.updateBody(x)` _(Svelte)_ | `chat.updateForwardedProps(x)` | +| `chat({ conversationId: x })` | `chat({ threadId: x })` | Each rename is gated by an **import-source check** — the codemod only rewrites a call site if the relevant identifier (`useChat`, `ChatClient`, etc.) is imported from a known TanStack AI package in the same file. Files that just happen to use a `body` key on unrelated object literals are left untouched. @@ -20,7 +20,7 @@ Each rename is gated by an **import-source check** — the codemod only rewrites - **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md). - **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too. -- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property *key* `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one. +- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one. ## Running it diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index 01a3c8ca9..37eb15462 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -59,10 +59,7 @@ interface ImportFacts { hasChat: boolean } -function collectImportFacts( - j: JSCodeshift, - root: Collection, -): ImportFacts { +function collectImportFacts(j: JSCodeshift, root: Collection): ImportFacts { const facts: ImportFacts = { hasUseChat: false, hasChatClient: false, @@ -82,7 +79,10 @@ function collectImportFacts( } } - if (FRAMEWORK_USE_CHAT_PACKAGES.has(source) && importedNames.has('useChat')) { + if ( + FRAMEWORK_USE_CHAT_PACKAGES.has(source) && + importedNames.has('useChat') + ) { facts.hasUseChat = true } if (source === CLIENT_PACKAGE && importedNames.has('ChatClient')) { @@ -107,10 +107,7 @@ function collectImportFacts( * Find a Property by its (Identifier) key name on an ObjectExpression. * Skips spread elements, computed keys, and non-Identifier shorthand keys. */ -function findKey( - obj: ObjectExpression, - name: string, -): Property | undefined { +function findKey(obj: ObjectExpression, name: string): Property | undefined { for (const prop of obj.properties) { if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') continue if (prop.computed) continue diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index ba2924698..d0c2e8297 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -1239,9 +1239,13 @@ describe('chat() middleware', () => { // threadId / conversationId propagation // ========================================================================== describe('threadId / conversationId', () => { - const runChatWithMiddleware = async ( - params: { threadId?: string; conversationId?: string }, - ): Promise<{ ctxThreadId: string | undefined; ctxConvId: string | undefined }> => { + const runChatWithMiddleware = async (params: { + threadId?: string + conversationId?: string + }): Promise<{ + ctxThreadId: string | undefined + ctxConvId: string | undefined + }> => { let ctxThreadId: string | undefined let ctxConvId: string | undefined const { adapter } = createMockAdapter({ From 3c451865de9d98d3e8aa3272a8826cc41ccfcdb6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 18:53:26 +0200 Subject: [PATCH 27/29] fix(examples/ts-react-chat): use array form for serverTools in mergeAgentTools call mergeAgentTools changed from `Record` to `Array` in the round-1 CR pass, but this example was still constructing a record via Object.fromEntries(...). Pass the array directly and drop the now- unused `Tool` type import. --- examples/ts-react-chat/src/routes/api.tanchat.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 7000efe22..386160a00 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -14,7 +14,7 @@ import { geminiText } from '@tanstack/ai-gemini' import { openRouterText } from '@tanstack/ai-openrouter' import { grokText } from '@tanstack/ai-grok' import { groqText } from '@tanstack/ai-groq' -import type { AnyTextAdapter, ChatMiddleware, Tool } from '@tanstack/ai' +import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai' import { addToCartToolDef, addToWishListToolDef, @@ -77,7 +77,7 @@ const addToCartToolServer = addToCartToolDef.server((args, context) => { } }) -const serverToolsList = [ +const serverTools = [ getGuitars, // Server tool recommendGuitarToolDef, // No server execute - client will handle addToCartToolServer, @@ -88,10 +88,7 @@ const serverToolsList = [ calculateFinancing, searchGuitars, ] - -const serverTools: Record = Object.fromEntries( - serverToolsList.map((t) => [t.name, t]), -) + const loggingMiddleware: ChatMiddleware = { name: 'logging', From efa547abd7f06c76c99d6dc85b4d198c6fc83329 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 16:54:50 +0000 Subject: [PATCH 28/29] ci: apply automated fixes --- examples/ts-react-chat/src/routes/api.tanchat.ts | 1 - 1 file changed, 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 386160a00..c5a677ef4 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -88,7 +88,6 @@ const serverTools = [ calculateFinancing, searchGuitars, ] - const loggingMiddleware: ChatMiddleware = { name: 'logging', From 02da6d30e4628bfbafe6d626aa1fb0b84398f500 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 19:06:28 +0200 Subject: [PATCH 29/29] fix(ci): exclude codemod test fixtures from knip's unused-files check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knip statically analyzes imports to find unused files, but the codemod fixtures are loaded at test time via readFileSync(...) — knip can't see those reads and was flagging all 14 fixtures as unused. Add codemods/**/__testfixtures__/** to knip's ignore list. --- knip.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 7ece05b5b..f0de2fd92 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,8 @@ "packages/typescript/ai-openai/src/audio/audio-provider-options.ts", "packages/typescript/ai-openai/src/audio/transcribe-provider-options.ts", "packages/typescript/ai-openai/src/image/image-provider-options.ts", - "packages/typescript/ai-devtools/src/production.ts" + "packages/typescript/ai-devtools/src/production.ts", + "codemods/**/__testfixtures__/**" ], "ignoreExportsUsedInFile": true, "workspaces": {