From 813d5438b38f66e6db5f2208ae50266dd3ab4a14 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:32:51 +1100 Subject: [PATCH 1/7] feat(ai-ui-message-stream): bridge TanStack AI and the AI SDK UI message stream Add @tanstack/ai-ui-message-stream. Serve AI SDK useChat from chat() with chatParamsFromAISDKRequest and toUIMessageStream, or point TanStack useChat at an AI SDK route with fromChatTransport. Text, reasoning, tools, client tools, tool approvals, activity, subagents and stream resume cross the bridge. Shared state goes from a TanStack server to an AI SDK client. Adds unit tests against the real ai and @tanstack/ai-client code, E2E scenarios, two migration docs pages, and the migration skill update. --- .changeset/ai-ui-message-stream.md | 10 + docs/config.json | 12 +- .../ai-sdk-client-tanstack-server.md | 257 ++++++++ docs/migration/migration-from-vercel-ai.md | 5 + .../tanstack-client-ai-sdk-server.md | 188 ++++++ packages/ai-ui-message-stream/README.md | 94 +++ packages/ai-ui-message-stream/package.json | 65 ++ .../ai-ui-message-stream/src/data-parts.ts | 198 ++++++ packages/ai-ui-message-stream/src/index.ts | 147 +++++ packages/ai-ui-message-stream/src/messages.ts | 286 ++++++++ packages/ai-ui-message-stream/src/request.ts | 96 +++ packages/ai-ui-message-stream/src/resume.ts | 42 ++ packages/ai-ui-message-stream/src/stream.ts | 616 ++++++++++++++++++ .../tests/approvals.test.ts | 382 +++++++++++ .../tests/data-parts.test.ts | 292 +++++++++ .../ai-ui-message-stream/tests/resume.test.ts | 231 +++++++ .../tests/ui-message-stream.test.ts | 354 ++++++++++ packages/ai-ui-message-stream/tsconfig.json | 8 + packages/ai-ui-message-stream/vite.config.ts | 37 ++ pnpm-lock.yaml | 228 ++++++- skills/tanstack-ai-migration/SKILL.md | 15 + testing/e2e/fixtures/ai-sdk/approval.json | 27 + testing/e2e/package.json | 4 + testing/e2e/src/routeTree.gen.ts | 212 ++++-- testing/e2e/src/routes/ai-sdk-client.tsx | 67 ++ testing/e2e/src/routes/ai-sdk-resume.tsx | 43 ++ testing/e2e/src/routes/ai-sdk-server.tsx | 88 +++ testing/e2e/src/routes/api.ai-sdk-client.ts | 75 +++ testing/e2e/src/routes/api.ai-sdk-resume.$.ts | 45 ++ testing/e2e/src/routes/api.ai-sdk-server.ts | 71 ++ testing/e2e/tests/ui-message-stream.spec.ts | 85 +++ 31 files changed, 4233 insertions(+), 47 deletions(-) create mode 100644 .changeset/ai-ui-message-stream.md create mode 100644 docs/migration/ai-sdk-client-tanstack-server.md create mode 100644 docs/migration/tanstack-client-ai-sdk-server.md create mode 100644 packages/ai-ui-message-stream/README.md create mode 100644 packages/ai-ui-message-stream/package.json create mode 100644 packages/ai-ui-message-stream/src/data-parts.ts create mode 100644 packages/ai-ui-message-stream/src/index.ts create mode 100644 packages/ai-ui-message-stream/src/messages.ts create mode 100644 packages/ai-ui-message-stream/src/request.ts create mode 100644 packages/ai-ui-message-stream/src/resume.ts create mode 100644 packages/ai-ui-message-stream/src/stream.ts create mode 100644 packages/ai-ui-message-stream/tests/approvals.test.ts create mode 100644 packages/ai-ui-message-stream/tests/data-parts.test.ts create mode 100644 packages/ai-ui-message-stream/tests/resume.test.ts create mode 100644 packages/ai-ui-message-stream/tests/ui-message-stream.test.ts create mode 100644 packages/ai-ui-message-stream/tsconfig.json create mode 100644 packages/ai-ui-message-stream/vite.config.ts create mode 100644 testing/e2e/fixtures/ai-sdk/approval.json create mode 100644 testing/e2e/src/routes/ai-sdk-client.tsx create mode 100644 testing/e2e/src/routes/ai-sdk-resume.tsx create mode 100644 testing/e2e/src/routes/ai-sdk-server.tsx create mode 100644 testing/e2e/src/routes/api.ai-sdk-client.ts create mode 100644 testing/e2e/src/routes/api.ai-sdk-resume.$.ts create mode 100644 testing/e2e/src/routes/api.ai-sdk-server.ts create mode 100644 testing/e2e/tests/ui-message-stream.spec.ts diff --git a/.changeset/ai-ui-message-stream.md b/.changeset/ai-ui-message-stream.md new file mode 100644 index 0000000000..d2bf2a9104 --- /dev/null +++ b/.changeset/ai-ui-message-stream.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-ui-message-stream': minor +--- + +Add `@tanstack/ai-ui-message-stream`, which connects TanStack AI to the Vercel AI SDK UI message stream. + +- Serve AI SDK `useChat` from `chat()` with `chatParamsFromAISDKRequest` and `toUIMessageStream`. Use `toResumableUIMessageStreamResponse` and `resumeUIMessageStreamResponse` for `useChat({ resume: true })`. +- Point TanStack `useChat` at an AI SDK route with `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })`. + +Text, reasoning with its signature, tool calls and results, client tools, tool approvals, activity, subagents and stream resume cross in both directions. Shared state goes from a TanStack server to an AI SDK client. diff --git a/docs/config.json b/docs/config.json index 359b32483f..b59d33f743 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1951,13 +1951,23 @@ "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai", "addedAt": "2026-04-20", - "updatedAt": "2026-10-22" + "updatedAt": "2026-10-23" }, { "label": "Vercel AI SDK: full API mapping", "to": "migration/migration-from-vercel-ai-advanced", "addedAt": "2026-10-21" }, + { + "label": "AI SDK useChat with a TanStack AI server", + "to": "migration/ai-sdk-client-tanstack-server", + "addedAt": "2026-10-23" + }, + { + "label": "TanStack useChat with an AI SDK server", + "to": "migration/tanstack-client-ai-sdk-server", + "addedAt": "2026-10-23" + }, { "label": "AG-UI Client Compliance", "to": "migration/ag-ui-compliance", diff --git a/docs/migration/ai-sdk-client-tanstack-server.md b/docs/migration/ai-sdk-client-tanstack-server.md new file mode 100644 index 0000000000..838e3d6ada --- /dev/null +++ b/docs/migration/ai-sdk-client-tanstack-server.md @@ -0,0 +1,257 @@ +--- +title: AI SDK useChat with a TanStack AI server +id: ai-sdk-client-tanstack-server +order: 3 +description: "Serve the Vercel AI SDK useChat hook from TanStack AI chat(). Read the request with chatParamsFromAISDKRequest and convert the reply with toUIMessageStream. Your AI SDK UI does not change." +keywords: + - tanstack ai + - vercel ai sdk + - useChat + - ai elements + - ui message stream + - toUIMessageStream + - tool approval + - resumable streams + - migration +--- + +Serve the AI SDK `useChat` hook from TanStack AI `chat()`. Read the request with `chatParamsFromAISDKRequest`, and convert the reply with `toUIMessageStream`. Your AI SDK client code, AI Elements included, does not change. + +Use this page to move the server first. To move the client first, see [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). + +## 1. Install the packages + + + +react: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +vue: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +solid: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +svelte: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +preact: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +angular: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +octane: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +vanilla: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai + + + +Keep the `ai` package. The server uses its response helper, and your client still uses `useChat`. + +## 2. Replace the server route + +```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +What each call does: + +- `chatParamsFromAISDKRequest` reads the body that `useChat` sends. It returns the `threadId` (the chat id), the `messages`, and the answers to tool approvals. If the body is not valid, it throws a `Response` with status 400. +- `toUIMessageStream` converts the events of `chat()` into the AI SDK UI message stream. + +## 3. Keep the client + +The client from your AI SDK app works as it is: + +```tsx +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' + +export function Chat() { + const { messages, sendMessage } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }) + + return ( +
+ {messages.map((m) => ( +
+ {m.parts.map((p, i) => (p.type === 'text' ? {p.text} : null))} +
+ ))} + +
+ ) +} +``` + +## 4. Add tools + +Server tools run in `chat()`. The client shows each call as a `tool-` part with its input and output. + +- **Client tool:** a tool definition with no `.server()` implementation. `chat()` stops at the call, and the AI SDK client runs it in `onToolCall`. +- **Tool approval:** set `needsApproval: true`. `chat()` stops before the tool runs, and the client shows the part in the `approval-requested` state. + +```typescript +import { chat, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(({ city }) => ({ city, temperature: 72 })) + +const bookFlight = toolDefinition({ + name: 'bookFlight', + description: 'Book a flight', + inputSchema: z.object({ to: z.string() }), + needsApproval: true, +}).server(({ to }) => ({ booked: to })) + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + tools: [getWeather, bookFlight], + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +On the client, answer the approval with `addToolApprovalResponse`. Then `sendAutomaticallyWhen` sends the answer to the server, and `chat()` runs or skips the tool. + +The server keeps the id of the paused run in the message metadata, under `tanstack.runId`. If you validate metadata with `messageMetadataSchema`, allow that key. + +```tsx +import { useChat } from '@ai-sdk/react' +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithApprovalResponses, +} from 'ai' + +export function Chat() { + const { messages, addToolApprovalResponse } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + }) + + return messages.map((m) => + m.parts.map((part) => + part.type === 'tool-bookFlight' && part.state === 'approval-requested' ? ( + + ) : null, + ), + ) +} +``` + +## 5. Show activity, state and subagents + +`toUIMessageStream` sends each of these as a data part. The client replaces a data part in place when it gets a new value for the same id. + +- **Activity:** a `data-` part with the activity id. +- **Shared state:** a `data-state` part with the full state. +- **Subagent:** a `data-subagent` part per child, with its name, status and text. Its data has the type `SubagentData`. + +Type the data parts on `useChat` to read them: + +```tsx +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import type { SubagentData } from '@tanstack/ai-ui-message-stream' +import type { UIMessage } from 'ai' + +type ChatMessage = UIMessage + +export function Subagents() { + const { messages } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }) + + return messages.flatMap((m) => + m.parts.map((part) => + part.type === 'data-subagent' ? ( +

+ {part.data.name}: {part.data.status} +

+ ) : null, + ), + ) +} +``` + +The AI SDK client does not send state back. A change to the state on the client does not reach `chat()`. + +## 6. Resume a stream after a reload + +AI SDK `useChat({ resume: true })` asks `GET /api/chat//stream` for the active run when the page loads. To answer it, record each run in a durability log, and keep a record of the active run of each chat. This example uses the runs store of [`@tanstack/ai-persistence`](../persistence/overview): + +```typescript +import { chat, memoryStream } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { + chatParamsFromAISDKRequest, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '@tanstack/ai-ui-message-stream' +import { persistence } from './persistence' + +// POST /api/chat +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + const runId = crypto.randomUUID() + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + runId, + middleware: [withPersistence(persistence)], + ...params, + }) + + return toResumableUIMessageStreamResponse(stream, memoryStream({ runId })) +} + +// GET /api/chat/:id/stream +export async function GET(request: Request) { + const chatId = new URL(request.url).pathname.split('/').at(-2) ?? '' + const run = await persistence.stores.runs.findActiveRun(chatId) + return resumeUIMessageStreamResponse(run && memoryStream({ runId: run.runId })) +} +``` + +- `toResumableUIMessageStreamResponse` writes the run to the log. The run continues when the client goes away. +- `resumeUIMessageStreamResponse` replays the run from its start. With no active run, it returns 204, and the client does nothing. +- `memoryStream` keeps the log in the memory of one process. For more than one server, use [`@tanstack/ai-durable-stream`](../resumable-streams/overview). + +## What does not go through + +- A change to shared state on the client. The AI SDK client does not send state. +- The `reason` text of an approval answer. +- A `stop()` on the client stops the reading, not the run. To stop the run, pass an `abortController` to `chat()`. + +## Done + +Your AI SDK `useChat` UI streams replies, tools, approvals, activity and subagents from `chat()`, and it continues a reply after a reload. diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index 5bb016b038..c11edf4f7e 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -25,6 +25,11 @@ Move a Vercel AI SDK app (`ai` and `@ai-sdk/*`) to TanStack AI by swapping the p Do the steps below in order. The "Before" samples are AI SDK v5 and v6 code. Where v4 is different, the page says so. For every other API, see [Vercel AI SDK: full API mapping](./migration-from-vercel-ai-advanced). +To move one side at a time, connect the two SDKs over the AI SDK UI message stream: + +- Server first: [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). +- Client first: [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). + ## Port a chat app ### 1. Swap the packages diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md new file mode 100644 index 0000000000..5fcd3b3dbb --- /dev/null +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -0,0 +1,188 @@ +--- +title: TanStack useChat with an AI SDK server +id: tanstack-client-ai-sdk-server +order: 4 +description: "Point TanStack AI useChat at a Vercel AI SDK route. Wrap an AI SDK ChatTransport with fromChatTransport and pass it as the connection. Your AI SDK server does not change." +keywords: + - tanstack ai + - vercel ai sdk + - useChat + - streamText + - ui message stream + - fromChatTransport + - tool approval + - resumable streams + - migration +--- + +Point TanStack AI `useChat` at a Vercel AI SDK route. Wrap an AI SDK `ChatTransport` with `fromChatTransport`, and pass it as the `connection`. Your `streamText` route does not change. + +Use this page to move the client first, for example to use `useChat` in a framework that the AI SDK does not cover. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). + +## 1. Install the packages + + + +react: @tanstack/ai-react @tanstack/ai-ui-message-stream ai +vue: @tanstack/ai-vue @tanstack/ai-ui-message-stream ai +solid: @tanstack/ai-solid @tanstack/ai-ui-message-stream ai +svelte: @tanstack/ai-svelte @tanstack/ai-ui-message-stream ai +preact: @tanstack/ai-preact @tanstack/ai-ui-message-stream ai +angular: @tanstack/ai-angular @tanstack/ai-ui-message-stream ai +octane: @tanstack/ai-octane @tanstack/ai-ui-message-stream ai octane +vanilla: @tanstack/ai-client @tanstack/ai-ui-message-stream ai + + + +## 2. Connect useChat to the route + +```tsx +import { useState } from 'react' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function Chat() { + const [input, setInput] = useState('') + const { messages, sendMessage } = useChat({ + connection, + threadId: 'support-chat', + }) + + return ( +
+ {messages.map((message) => ( +
+ {message.parts.map((part, idx) => + part.type === 'text' ? {part.content} : null, + )} +
+ ))} + setInput(e.target.value)} /> + +
+ ) +} +``` + +`DefaultChatTransport` sends the request and reads the stream, as it does for the AI SDK hook. Its options work the same, for example `headers`, `credentials` and `prepareSendMessagesRequest`. The `threadId` goes to the route as the chat `id`. + +## 3. Keep the server + +Your AI SDK route works as it is: + +```typescript +import { openai } from '@ai-sdk/openai' +import { convertToModelMessages, streamText } from 'ai' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const result = streamText({ + model: openai('gpt-6.1-sol'), + messages: await convertToModelMessages(messages), + }) + + return result.toUIMessageStreamResponse() +} +``` + +## 4. Run client tools and approve tool calls + +Define the tools of the route on the client too, with the same names. Pass them to `useChat` and to `fromChatTransport`: + +- **Client tool:** give it a `.client()` implementation. When the route stops at the call, the client runs the tool and sends the output back. +- **Tool approval:** a tool that needs approval on the route shows up in `interrupts`. Resolve it with `interrupt.resolveInterrupt(true)` or `false`. The answer goes back to the route, and `streamText` runs or skips the tool. + +```tsx +import { toolDefinition } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +const getLocation = toolDefinition({ + name: 'getLocation', + description: 'Get the location of the user', + inputSchema: z.object({}), +}).client(() => ({ city: 'Sydney' })) + +const bookFlight = toolDefinition({ + name: 'bookFlight', + description: 'Book a flight', + inputSchema: z.object({ to: z.string() }), + needsApproval: true, +}) + +const tools = [getLocation, bookFlight] as const + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), + { tools }, +) + +export function Chat() { + const { interrupts } = useChat({ + connection, + threadId: 'support-chat', + tools, + }) + + return interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + ) +} +``` + +## 5. Show data parts as activity + +A data part with an `id` (`writer.write({ type: 'data-weather', id, data })` on the route) arrives as an activity message of type `weather`. A new value for the same id replaces it. A transient data part, or one with no `id`, arrives as a custom event named `data-`. + +## 6. Resume a stream + +A dropped connection needs nothing. The connection calls `reconnectToStream` on the transport, and the reply continues. + +To continue a reply after a page reload, add client persistence, and pass the `threadId` to `fromChatTransport`. The route must support the AI SDK resume `GET` (`/api/chat//stream`): + +```tsx +import { localStoragePersistence, useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const threadId = 'support-chat' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), + { threadId }, +) + +export function useSupportChat() { + return useChat({ + connection, + threadId, + persistence: localStoragePersistence(), + }) +} +``` + +Use one connection for each thread. The rejoin after a reload needs a transport that replays the reply from its start, as `DefaultChatTransport` does. + +## What does not go through + +- AG-UI shared state. The AI SDK has no state. +- Sources, generated files and message metadata. + +## Done + +TanStack `useChat` streams replies, runs tools, asks for approvals, and continues after a reload against your AI SDK route. diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md new file mode 100644 index 0000000000..3db7787123 --- /dev/null +++ b/packages/ai-ui-message-stream/README.md @@ -0,0 +1,94 @@ +
+ + + + TanStack AI + +
+ +
+ +# @tanstack/ai-ui-message-stream + +Connect TanStack AI to the Vercel AI SDK UI message stream, the wire format of the AI SDK `useChat` hook. + +- Serve AI SDK `useChat` from TanStack AI `chat()`. +- Call an AI SDK `streamText` route from TanStack AI `useChat`. + +## Installation + +```bash +pnpm add @tanstack/ai-ui-message-stream @tanstack/ai ai +``` + +## AI SDK client, TanStack AI server + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +## TanStack AI client, AI SDK server + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection, + threadId: 'support-chat', + }) + // render messages +} +``` + +## API + +| Export | Direction | +| --- | --- | +| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | +| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | +| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | +| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | +| `fromChatTransport(transport, { threadId, tools })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | +| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | +| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | + +Text, reasoning, tools, tool approvals, activity, subagents and stream resume cross in both directions where the other side has them. Shared state goes from a TanStack server to an AI SDK client only, because the AI SDK client does not send state. + +## Docs + +- [AI SDK useChat with a TanStack AI server](https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server) +- [TanStack useChat with an AI SDK server](https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server) diff --git a/packages/ai-ui-message-stream/package.json b/packages/ai-ui-message-stream/package.json new file mode 100644 index 0000000000..9a7e092e1c --- /dev/null +++ b/packages/ai-ui-message-stream/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tanstack/ai-ui-message-stream", + "version": "0.0.1", + "description": "Connect TanStack AI to the Vercel AI SDK UI message stream: serve AI SDK useChat from chat(), or call an AI SDK route from TanStack useChat.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-ui-message-stream" + }, + "keywords": [ + "ai", + "tanstack", + "ai-sdk", + "ag-ui", + "ui-message-stream", + "usechat" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:coverage": "vitest run --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "fast-json-patch": "^3.1.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "@tanstack/ai-client": "workspace:^", + "ai": "^7.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/ai-client": { + "optional": true + } + }, + "devDependencies": { + "@ag-ui/core": "1.0.0", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@vitest/coverage-v8": "4.1.10", + "ai": "7.0.148", + "zod": "^4.2.0" + } +} diff --git a/packages/ai-ui-message-stream/src/data-parts.ts b/packages/ai-ui-message-stream/src/data-parts.ts new file mode 100644 index 0000000000..8d2ec2c244 --- /dev/null +++ b/packages/ai-ui-message-stream/src/data-parts.ts @@ -0,0 +1,198 @@ +import jsonPatch from 'fast-json-patch' +import { EventType } from '@tanstack/ai' +import type { StreamChunk, SubagentHandleData } from '@tanstack/ai' +import type { UIMessageChunk } from 'ai' + +type ActivitySnapshot = Extract +type JsonPatch = Extract['delta'] + +/** + * The data of a `data-subagent` part: the TanStack subagent card, with the + * child's text in place of its messages. + */ +export interface SubagentData extends Omit< + SubagentHandleData, + 'messages' | 'parentRunId' +> { + text: string +} + +/** An AI SDK `data-` chunk. */ +export type DataChunk = Extract + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Turns AG-UI activity, state and subagent events into AI SDK data chunks. + * Each chunk has an `id`, so the AI SDK client replaces the data part in place + * when the same `data-` and `id` come again. Make one per stream. + * + * - `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA`: `data-`, `id` is the + * activity `messageId`, data is the full content. + * - `STATE_SNAPSHOT` / `STATE_DELTA`: `data-state`, `id: 'state'`, data is the + * full state. + * - Events with a `subagentRunId`: `data-subagent`, `id` is the + * `subagentRunId`, data is a {@link SubagentData}. + */ +export class DataParts { + private readonly activities = new Map() + private state: unknown = {} + private readonly subagents = new Map() + + /** The data chunks for one event. Empty for any other event. */ + chunks(event: StreamChunk): Array { + if ('subagentRunId' in event && event.subagentRunId !== undefined) + return this.subagent(event, event.subagentRunId) + + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only activity and state events have data parts. + switch (event.type) { + case EventType.ACTIVITY_SNAPSHOT: { + const existing = this.activities.get(event.messageId) + if (existing && event.replace === false) return [] + this.activities.set(event.messageId, event) + return [activityChunk(event)] + } + case EventType.ACTIVITY_DELTA: { + // Same rules as the TanStack client: no snapshot, another type, a + // failed patch, or content that is no longer an object keep the old + // content. + const existing = this.activities.get(event.messageId) + if (existing?.activityType !== event.activityType) { + console.warn( + `ACTIVITY_DELTA: No '${event.activityType}' activity '${event.messageId}'`, + ) + return [] + } + const content = patched(existing.content, event.patch) + if (!isRecord(content)) return [] + const next = { ...existing, content } + this.activities.set(event.messageId, next) + return [activityChunk(next)] + } + case EventType.STATE_SNAPSHOT: + this.state = event.snapshot + return [{ type: 'data-state', id: 'state', data: this.state }] + case EventType.STATE_DELTA: { + const state = patched(this.state, event.delta) + if (state === undefined) return [] + this.state = state + return [{ type: 'data-state', id: 'state', data: state }] + } + default: + return [] + } + } + + // ponytail: re-sends the whole card on every child text delta, so the wire + // carries O(n²) text for a long child. Throttle or send a text-only delta + // part if children get long. + private subagent(event: StreamChunk, id: string): Array { + let card = this.subagents.get(id) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- the card shows lifecycle and text only. + switch (event.type) { + case EventType.SUBAGENT_STARTED: + if (card) { + // A resume continues the same child. + card.status = 'running' + delete card.interruptIds + delete card.error + if (event.metadata !== undefined) card.metadata = event.metadata + break + } + card = { + id, + name: event.name, + ...(event.description !== undefined && { + description: event.description, + }), + status: 'running', + ...(event.parentSubagentRunId !== undefined && { + parentSubagentRunId: event.parentSubagentRunId, + }), + ...(event.parentToolCallId !== undefined && { + parentToolCallId: event.parentToolCallId, + }), + ...(event.metadata !== undefined && { metadata: event.metadata }), + text: '', + } + this.subagents.set(id, card) + break + case EventType.TEXT_MESSAGE_START: + // Keep the child's text messages apart. Nothing to show yet. + if (card && card.text !== '') card.text += '\n\n' + return [] + case EventType.TEXT_MESSAGE_CONTENT: + if (!card) return [] + card.text += event.delta + break + case EventType.SUBAGENT_FINISHED: + if (!card || card.status === 'error') return [] + if (event.outcome?.type === 'suspended') { + card.status = 'suspended' + card.interruptIds = event.outcome.interruptIds ?? [] + } else { + card.status = 'finished' + } + break + case EventType.SUBAGENT_ERROR: + if (!card) return [] + card.status = 'error' + card.error = { + message: event.message, + ...(event.code !== undefined && { code: event.code }), + } + break + default: + return [] + } + return [{ type: 'data-subagent', id, data: structuredClone(card) }] + } +} + +function activityChunk(event: ActivitySnapshot): UIMessageChunk { + return { + type: `data-${event.activityType}`, + id: event.messageId, + data: event.content, + } +} + +export function isDataChunk(chunk: UIMessageChunk): chunk is DataChunk { + return chunk.type.startsWith('data-') +} + +/** + * The AG-UI event for an AI SDK data chunk. A persistent chunk with an `id` + * and object data is an activity, so the TanStack client shows it as an + * activity message and replaces it when the same `id` comes again. AG-UI + * activity content must be an object, so other data, transient chunks and + * chunks without an `id` stay CUSTOM events named `data-`. + */ +export function dataChunkEvent(chunk: DataChunk): StreamChunk { + if ( + chunk.transient !== true && + chunk.id !== undefined && + isRecord(chunk.data) + ) + return { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: chunk.id, + activityType: chunk.type.slice('data-'.length), + content: chunk.data, + replace: true, + } + return { type: EventType.CUSTOM, name: chunk.type, value: chunk.data } +} + +/** `doc` with an RFC 6902 patch applied, or undefined when the patch fails. */ +function patched(doc: unknown, patch: JsonPatch): unknown { + try { + return jsonPatch.applyPatch(structuredClone(doc), patch, true, false) + .newDocument + } catch (error) { + console.warn('JSON Patch failed', error) + return undefined + } +} diff --git a/packages/ai-ui-message-stream/src/index.ts b/packages/ai-ui-message-stream/src/index.ts new file mode 100644 index 0000000000..536024ffb2 --- /dev/null +++ b/packages/ai-ui-message-stream/src/index.ts @@ -0,0 +1,147 @@ +import { modelMessagesToUIMessages } from '@tanstack/ai' +import type { ModelMessage, UIMessage } from '@tanstack/ai' +import type { ConnectConnectionAdapter } from '@tanstack/ai-client' +import type { + ChatTransport, + UIMessage as AISDKUIMessage, + UIMessageChunk, +} from 'ai' +import { toAISDKMessages } from './messages' +import { fromUIMessageStream } from './stream' +import type { FromUIMessageStreamOptions } from './stream' + +export { fromAISDKMessages, toAISDKMessages } from './messages' +export { fromUIMessageStream, toUIMessageStream } from './stream' +export type { FromUIMessageStreamOptions } from './stream' +export { + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from './resume' +export { + chatParamsFromAISDKRequest, + chatParamsFromAISDKRequestBody, +} from './request' +export type { AISDKChatParams } from './request' +export type { SubagentData } from './data-parts' + +function asUIMessages( + messages: Array | Array, +): Array { + const list: ReadonlyArray = messages + const ui = list.filter((m): m is UIMessage => 'parts' in m) + if (ui.length === list.length) return ui + return modelMessagesToUIMessages( + list.filter((m): m is ModelMessage => !('parts' in m)), + ) +} + +type ReconnectOptions = Parameters< + ChatTransport['reconnectToStream'] +>[0] + +/** + * Read `stream`, and when the connection drops, read the rest through + * `transport.reconnectToStream`. A replayed prefix is skipped. + */ +function reconnecting( + transport: ChatTransport, + stream: ReadableStream, + options: ReconnectOptions, +): ReadableStream { + let reader = stream.getReader() + let delivered = 0 + let skip = 0 + // ponytail: a reconnect that delivers nothing new ends the stream with the + // error. Add attempts and backoff if one retry without progress is too few. + let deliveredAtReconnect: number | undefined + return new ReadableStream({ + async pull(controller) { + for (;;) { + try { + const { done, value } = await reader.read() + if (done) return controller.close() + // ponytail: skips by count, so the replay must repeat the chunks in + // the same order, as the AI SDK resumable streams do. + if (skip > 0) { + skip-- + continue + } + delivered++ + return controller.enqueue(value) + } catch (error) { + if (options.abortSignal?.aborted) throw error + if (deliveredAtReconnect === delivered) throw error + deliveredAtReconnect = delivered + const resumed = await transport.reconnectToStream(options) + if (!resumed) throw error + reader = resumed.getReader() + skip = transport.resumeStreamIsReplay === true ? delivered : 0 + } + } + }, + cancel: (reason) => reader.cancel(reason), + }) +} + +/** + * Connect the TanStack chat client to an AI SDK route through an AI SDK + * `ChatTransport`, such as `new DefaultChatTransport({ api: '/api/chat' })`. + * + * The transport sends the history as AI SDK `UIMessage`s and reads the UI + * message stream. The chat `threadId` is the AI SDK `chatId`. Data passed to + * `sendMessage` goes in the request body, and per-run headers go on the + * request. + * + * When the stream drops, the adapter reconnects with + * `transport.reconnectToStream` and continues the run. Set `threadId` to also + * rejoin a run after a page reload (`joinRun`), with a chat `persistence` + * adapter on the client. For that, the transport must replay the stream from + * its start (`resumeStreamIsReplay`, true for `DefaultChatTransport`). + * + * Pass the same `tools` as `useChat({ tools })`, so a tool approval is a + * `tool-approval` interrupt that `resolveInterrupt(true)` answers. + */ +export function fromChatTransport( + transport: ChatTransport, + { + threadId, + tools, + }: Pick & { threadId?: string } = {}, +): ConnectConnectionAdapter { + return { + async *connect(messages, data, abortSignal, runContext) { + const stream = await transport.sendMessages({ + trigger: 'submit-message', + chatId: runContext.threadId, + messageId: undefined, + messages: toAISDKMessages(asUIMessages(messages)), + abortSignal, + ...(runContext.headers && { headers: runContext.headers }), + ...(data && { body: data }), + }) + yield* fromUIMessageStream( + reconnecting(transport, stream, { + chatId: runContext.threadId, + abortSignal, + ...(runContext.headers && { headers: runContext.headers }), + }), + { ...runContext, tools }, + ) + }, + // A rejoin gets only the run id, and the AI SDK reconnects by chat id. + ...(threadId !== undefined && + transport.resumeStreamIsReplay === true && { + async *joinRun(runId: string, abortSignal?: AbortSignal) { + const options = { chatId: threadId, abortSignal } + const stream = await transport.reconnectToStream(options) + // Thrown before the first event, so the client drops its resume pointer. + if (!stream) throw new Error(`No active stream for chat ${threadId}`) + yield* fromUIMessageStream(reconnecting(transport, stream, options), { + threadId, + runId, + tools, + }) + }, + }), + } +} diff --git a/packages/ai-ui-message-stream/src/messages.ts b/packages/ai-ui-message-stream/src/messages.ts new file mode 100644 index 0000000000..c518bd7613 --- /dev/null +++ b/packages/ai-ui-message-stream/src/messages.ts @@ -0,0 +1,286 @@ +import { getToolName, isToolUIPart } from 'ai' +import type { + ContentPart, + ContentPartSource, + MessagePart, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai' +import type { + DynamicToolUIPart, + FileUIPart, + ProviderMetadata, + UIMessage as AISDKUIMessage, + ToolUIPart, + UIMessagePart, + UIDataTypes, + UITools, +} from 'ai' + +type AISDKPart = UIMessagePart + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isProviderMetadata(value: unknown): value is ProviderMetadata { + return isRecord(value) && Object.values(value).every(isRecord) +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +const DATA_URL = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s + +function fileToContentPart(part: FileUIPart): ContentPart { + const match = DATA_URL.exec(part.url) + const source: ContentPartSource = match + ? { + type: 'data', + value: match[2] ?? '', + mimeType: match[1] ?? part.mediaType, + } + : { type: 'url', value: part.url, mimeType: part.mediaType } + const kind = part.mediaType.split('/')[0] + if (kind === 'image' || kind === 'audio' || kind === 'video') { + return { type: kind, source } + } + return { type: 'document', source } +} + +function toolParts( + part: ToolUIPart | DynamicToolUIPart, +): Array { + const call: ToolCallPart = { + type: 'tool-call', + id: part.toolCallId, + name: getToolName(part), + arguments: JSON.stringify(part.input ?? {}), + input: part.input, + state: + part.state === 'input-streaming' + ? 'input-streaming' + : part.state === 'output-available' + ? 'complete' + : part.state === 'output-error' || part.state === 'output-denied' + ? 'error' + : 'input-complete', + } + // A result goes right after its call, so a text after the tool replays + // after the result. + if (part.state === 'output-available') { + const result: ToolResultPart = { + type: 'tool-result', + toolCallId: part.toolCallId, + content: + typeof part.output === 'string' + ? part.output + : JSON.stringify(part.output ?? null), + state: 'complete', + } + return [call, result] + } + if (part.state === 'output-error' || part.state === 'output-denied') { + const error = + part.state === 'output-error' + ? part.errorText + : (part.approval.reason ?? 'The user denied this tool call') + const result: ToolResultPart = { + type: 'tool-result', + toolCallId: part.toolCallId, + content: error, + state: 'error', + error, + ...(part.state === 'output-denied' && { outcome: 'denied' }), + } + return [call, result] + } + return [call] +} + +function fromAISDKPart(part: AISDKPart): Array { + if (isToolUIPart(part)) return toolParts(part) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, data, step and custom parts are UI only; default drops them. + switch (part.type) { + case 'text': + return [{ type: 'text', content: part.text }] + case 'reasoning': { + const tanstack = part.providerMetadata?.tanstack + const signature = isRecord(tanstack) ? tanstack.signature : undefined + return [ + { + type: 'thinking', + content: part.text, + ...(typeof signature === 'string' && { signature }), + ...(isRecord(tanstack) && + tanstack.redacted === true && { redacted: true }), + }, + ] + } + case 'file': + return [fileToContentPart(part)] + default: + // Sources, data parts, step markers and custom parts are UI only. + return [] + } +} + +/** + * Convert AI SDK `UIMessage`s (the `messages` body that AI SDK `useChat` + * posts) into TanStack `UIMessage`s for `chat({ messages })`. + * + * Text, files, reasoning and tool calls with their results are kept. A client + * tool's output and a denied tool call go back to the model as tool results. + * A call that waits for approval, or has an answer, stays pending: + * `chatParamsFromAISDKRequest` sends the answer to `chat()` as `resume`. + */ +export function fromAISDKMessages( + messages: ReadonlyArray, +): Array { + return messages.map((message) => ({ + id: message.id, + role: message.role, + parts: message.parts.flatMap(fromAISDKPart), + })) +} + +function sourceToUrl(source: ContentPartSource): string { + return source.type === 'data' + ? `data:${source.mimeType};base64,${source.value}` + : source.value +} + +function toolOutput(content: ToolResultPart['content']): unknown { + if (typeof content !== 'string') return content + return parseJson(content) ?? content +} + +function toAISDKParts(message: UIMessage): Array { + const results = new Map() + for (const part of message.parts) { + if (part.type === 'tool-result') results.set(part.toolCallId, part) + } + + const parts: Array = [] + for (const part of message.parts) { + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- tool results join their calls; TanStack UI-only parts are dropped by default. + switch (part.type) { + case 'text': + parts.push({ type: 'text', text: part.content }) + break + case 'thinking': { + // `fromUIMessageStream` stores the AI SDK provider metadata as the + // signature. Send it back as it came. + const providerMetadata = + part.signature === undefined ? undefined : parseJson(part.signature) + parts.push({ + type: 'reasoning', + text: part.content, + ...(isProviderMetadata(providerMetadata) && { providerMetadata }), + }) + break + } + case 'image': + case 'audio': + case 'video': + case 'document': + parts.push({ + type: 'file', + mediaType: + part.source.mimeType ?? + (part.type === 'document' ? 'application/octet-stream' : part.type), + url: sourceToUrl(part.source), + }) + break + case 'tool-call': { + const input = part.input ?? parseJson(part.arguments) ?? {} + const result = results.get(part.id) + const base = { + type: 'dynamic-tool' as const, + toolName: part.name, + toolCallId: part.id, + input, + } + if (result?.outcome === 'denied' && part.approval) { + parts.push({ + ...base, + state: 'output-denied', + approval: { id: part.approval.id, approved: false }, + }) + } else if (result?.state === 'error') { + parts.push({ + ...base, + state: 'output-error', + errorText: + result.error ?? + (typeof result.content === 'string' + ? result.content + : JSON.stringify(result.content)), + }) + } else if (result !== undefined) { + parts.push({ + ...base, + state: 'output-available', + output: toolOutput(result.content), + }) + } else if (part.output !== undefined) { + parts.push({ + ...base, + state: 'output-available', + output: part.output, + }) + } else if (part.approval?.approved !== undefined) { + // The AI SDK route runs or denies the tool from this answer. + parts.push({ + ...base, + state: 'approval-responded', + approval: { + id: part.approval.id, + approved: part.approval.approved, + }, + }) + } else if (part.approval) { + parts.push({ + ...base, + state: 'approval-requested', + approval: { id: part.approval.id }, + }) + } else { + parts.push({ ...base, state: 'input-available' }) + } + break + } + default: + // Results are joined with their calls above. Activity, subagent, + // structured-output and UI-resource parts are TanStack UI only. + break + } + } + return parts +} + +/** + * Convert TanStack chat messages into AI SDK `UIMessage`s, the history an AI + * SDK route reads with `convertToModelMessages`. Tool calls become + * `dynamic-tool` parts. + */ +export function toAISDKMessages( + messages: ReadonlyArray, +): Array { + const out: Array = [] + for (const message of messages) { + if (message.role === 'activity') continue + out.push({ + id: message.id, + role: message.role, + parts: toAISDKParts(message), + }) + } + return out +} diff --git a/packages/ai-ui-message-stream/src/request.ts b/packages/ai-ui-message-stream/src/request.ts new file mode 100644 index 0000000000..c3ecfc2d6e --- /dev/null +++ b/packages/ai-ui-message-stream/src/request.ts @@ -0,0 +1,96 @@ +import { isToolUIPart, validateUIMessages } from 'ai' +import { fromAISDKMessages } from './messages' +import { tanstackField } from './stream' +import type { RunAgentResumeItem, UIMessage } from '@tanstack/ai' +import type { UIMessage as AISDKUIMessage } from 'ai' + +/** What `chat()` needs from an AI SDK `useChat` request. */ +export interface AISDKChatParams { + /** The AI SDK chat id. */ + threadId: string + messages: Array + /** The user's answers to tool approvals, when there are any. */ + resume?: Array + /** + * Set with `resume`: the paused TanStack run, from the last assistant + * message's `metadata.tanstack.runId`, else the chat id. + */ + parentRunId?: string +} + +/** + * Read the body that AI SDK `useChat` posts (`{ id, messages }`) as + * `chat()` parameters: `chat({ adapter, tools, ...params })`. + * + * A tool part in `approval-responded` state becomes a resume item, so + * `chat()` runs or denies the paused tool call. + * + * @throws An error when the body is not a chat request or a message is not + * a valid AI SDK `UIMessage`. + */ +export async function chatParamsFromAISDKRequestBody( + body: unknown, +): Promise { + if ( + typeof body !== 'object' || + body === null || + !('id' in body) || + typeof body.id !== 'string' || + !('messages' in body) + ) { + throw new Error('The body must have an `id` and `messages`.') + } + const messages = await validateUIMessages({ + messages: body.messages, + }) + + const resume = messages.flatMap((message) => + message.parts.flatMap( + (part): Array => + isToolUIPart(part) && part.state === 'approval-responded' + ? [ + { + interruptId: part.approval.id, + status: 'resolved', + payload: { approved: part.approval.approved }, + }, + ] + : [], + ), + ) + + // `toUIMessageStream` puts the run id in the message metadata. + const runId = tanstackField( + messages.filter((m) => m.role === 'assistant').at(-1)?.metadata, + 'runId', + ) + return { + threadId: body.id, + messages: fromAISDKMessages(messages), + ...(resume.length > 0 && { + resume, + parentRunId: typeof runId === 'string' ? runId : body.id, + }), + } +} + +/** + * Read an AI SDK `useChat` request as `chat()` parameters. See + * `chatParamsFromAISDKRequestBody`. + * + * @throws {Response} 400 when the body is not JSON or not a valid chat + * request. + */ +export async function chatParamsFromAISDKRequest( + request: Request, +): Promise { + try { + return await chatParamsFromAISDKRequestBody(await request.json()) + } catch (cause) { + // Keep the details on `cause` for server logs, not in the response. + throw Object.assign( + new Response('Invalid AI SDK chat request body.', { status: 400 }), + { cause }, + ) + } +} diff --git a/packages/ai-ui-message-stream/src/resume.ts b/packages/ai-ui-message-stream/src/resume.ts new file mode 100644 index 0000000000..11f4d26dec --- /dev/null +++ b/packages/ai-ui-message-stream/src/resume.ts @@ -0,0 +1,42 @@ +import { replayRunStream, toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk, StreamDurability } from '@tanstack/ai' +import { createUIMessageStreamResponse } from 'ai' +import { toUIMessageStream } from './stream' + +/** + * Answer the AI SDK resume request (`GET {api}/{chatId}/stream`, which + * `useChat({ resume: true })` sends on mount). Pass the durability log of the + * chat's active run. The run is replayed from its start, as the AI SDK + * `DefaultChatTransport` expects. With no log, the response is the AI SDK's + * "nothing to resume" 204. + */ +export function resumeUIMessageStreamResponse( + durability: StreamDurability | null | undefined, +): Response { + if (!durability) return new Response(null, { status: 204 }) + return createUIMessageStreamResponse({ + stream: toUIMessageStream(replayRunStream(durability)), + }) +} + +/** + * Serve a `chat()` run to the AI SDK `useChat` so that it can be resumed. The + * run is written to `durability` and keeps running when the client goes away. + * The response reads the run back from the log, the same way + * {@link resumeUIMessageStreamResponse} does. + */ +export function toResumableUIMessageStreamResponse( + stream: AsyncIterable, + durability: StreamDurability, +): Response { + // ponytail: the SSE helper owns the only exported durable producer. It + // writes the run to the log and does not wait for a reader, so its unused + // SSE body is drained (a cancel would mark the run as detached). Export the + // producer from @tanstack/ai to drop the extra SSE encoding. + void toServerSentEventsResponse(stream, { + durability: { adapter: durability }, + }) + .body?.pipeTo(new WritableStream()) + .catch(() => {}) + return resumeUIMessageStreamResponse(durability) +} diff --git a/packages/ai-ui-message-stream/src/stream.ts b/packages/ai-ui-message-stream/src/stream.ts new file mode 100644 index 0000000000..2fb6a97db7 --- /dev/null +++ b/packages/ai-ui-message-stream/src/stream.ts @@ -0,0 +1,616 @@ +import { + EventType, + INTERRUPT_BINDING_VERSION, + RUN_ACCEPTED_EVENT, + hashSchemaInput, + normalizeApprovalSchema, + readInterruptBinding, + withInterruptBinding, +} from '@tanstack/ai' +import type { + AnyClientTool, + Interrupt, + StreamChunk, + ToolResultOutcome, +} from '@tanstack/ai' +import type { FinishReason, ProviderMetadata, UIMessageChunk } from 'ai' +import { DataParts, dataChunkEvent, isDataChunk } from './data-parts' + +const TO_AI_SDK_FINISH: Partial> = { + stop: 'stop', + length: 'length', + content_filter: 'content-filter', + tool_calls: 'tool-calls', +} + +const FROM_AI_SDK_FINISH: Record< + FinishReason, + 'stop' | 'length' | 'content_filter' | null +> = { + stop: 'stop', + length: 'length', + 'content-filter': 'content_filter', + // A TanStack `tool_calls` finish means more steps follow in the same + // stream. An AI SDK stream has ended here, and the client must run its + // tools, so the reason is left out. + 'tool-calls': null, + error: null, + other: null, +} + +/** TanStack marks a redacted thinking block by this reasoning message id prefix. */ +const REDACTED_PREFIX = 'redacted_thinking-' + +/** `metadata.tanstack[key]`, or undefined. */ +export function tanstackField(metadata: unknown, key: string): unknown { + const tanstack: unknown = + typeof metadata === 'object' && metadata !== null && 'tanstack' in metadata + ? metadata.tanstack + : undefined + return typeof tanstack === 'object' && tanstack !== null && key in tanstack + ? Reflect.get(tanstack, key) + : undefined +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +/** Tool output as the AI SDK client renders it: parsed JSON, else the text. */ +function parseOutput(content: string | ReadonlyArray): unknown { + if (typeof content !== 'string') return content + return parseJson(content) ?? content +} + +/** + * Convert the AG-UI event stream of `chat()` into an AI SDK UI message + * stream. Serve it with the AI SDK's `createUIMessageStreamResponse`, and the + * AI SDK `useChat` reads it like a `streamText` route. + * + * A tool approval becomes a `tool-approval-request` chunk. Read the answer + * with `chatParamsFromAISDKRequest`. The message metadata gets the paused + * TanStack run id as `{ tanstack: { runId } }`, so the answer resumes that + * run. A subagent's approval shows on its card only. An activity becomes a `data-` + * part, shared state a `data-state` part, and each subagent a `data-subagent` + * card, each updated in place by id. A CUSTOM event becomes a transient + * `data-` chunk, which reaches `onData`. + */ +export function toUIMessageStream( + stream: AsyncIterable, +): ReadableStream { + async function* convert(): AsyncGenerator { + let started = false + let errored = false + let finishReason: FinishReason | undefined + const tools = new Map() + const signatures = new Map() + const dataParts = new DataParts() + + function* start(messageId?: string): Generator { + if (started) return + started = true + yield messageId === undefined + ? { type: 'start' } + : { type: 'start', messageId } + } + + for await (const event of stream) { + // A subagent's events go to its card, not to this message. + if ('subagentRunId' in event && event.subagentRunId !== undefined) { + const chunks = dataParts.chunks(event) + if (chunks.length > 0) { + yield* start() + yield* chunks + } + continue + } + + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- steps and message snapshots have no AI SDK chunk; default skips them. + switch (event.type) { + case EventType.TEXT_MESSAGE_START: + yield* start(event.messageId) + yield { type: 'text-start', id: event.messageId } + break + case EventType.TEXT_MESSAGE_CONTENT: + yield { type: 'text-delta', id: event.messageId, delta: event.delta } + break + case EventType.TEXT_MESSAGE_END: + yield { type: 'text-end', id: event.messageId } + break + + case EventType.REASONING_MESSAGE_START: + yield* start() + yield { type: 'reasoning-start', id: event.messageId } + break + case EventType.REASONING_MESSAGE_CONTENT: + yield { + type: 'reasoning-delta', + id: event.messageId, + delta: event.delta, + } + break + case EventType.REASONING_ENCRYPTED_VALUE: + // ponytail: only a signature sent before REASONING_MESSAGE_END is + // kept, which is the order the TanStack adapters use. Buffer the + // reasoning-end if an adapter ever sends it later. + if (event.subtype === 'message') { + signatures.set(event.entityId, event.encryptedValue) + } + break + case EventType.REASONING_MESSAGE_END: { + const signature = signatures.get(event.messageId) + const providerMetadata: ProviderMetadata | undefined = + signature === undefined + ? undefined + : { + tanstack: { + signature, + ...(event.messageId.startsWith(REDACTED_PREFIX) && { + redacted: true, + }), + }, + } + yield { + type: 'reasoning-end', + id: event.messageId, + ...(providerMetadata && { providerMetadata }), + } + break + } + + case EventType.TOOL_CALL_START: + yield* start(event.parentMessageId) + tools.set(event.toolCallId, { name: event.toolCallName, args: '' }) + yield { + type: 'tool-input-start', + toolCallId: event.toolCallId, + toolName: event.toolCallName, + } + break + case EventType.TOOL_CALL_ARGS: { + const tool = tools.get(event.toolCallId) + if (tool) tool.args += event.delta + yield { + type: 'tool-input-delta', + toolCallId: event.toolCallId, + inputTextDelta: event.delta, + } + break + } + case EventType.TOOL_CALL_END: { + const tool = tools.get(event.toolCallId) + if (!tool) break + const input = + tanstackField(event.metadata, 'input') ?? + (tool.args === '' ? {} : parseJson(tool.args)) + yield { + type: 'tool-input-available', + toolCallId: event.toolCallId, + toolName: tool.name, + input, + } + break + } + case EventType.TOOL_CALL_RESULT: + // A resumed run can start with a result. A start without an id + // keeps the AI SDK message that holds the tool call. + yield* start() + if (tanstackField(event.metadata, 'toolResultOutcome') === 'denied') { + yield { type: 'tool-output-denied', toolCallId: event.toolCallId } + } else if ( + tanstackField(event.metadata, 'state') === 'output-error' + ) { + yield { + type: 'tool-output-error', + toolCallId: event.toolCallId, + errorText: + typeof event.content === 'string' + ? event.content + : JSON.stringify(event.content), + } + } else { + yield { + type: 'tool-output-available', + toolCallId: event.toolCallId, + output: parseOutput(event.content), + } + } + break + + case EventType.ACTIVITY_SNAPSHOT: + case EventType.ACTIVITY_DELTA: + case EventType.STATE_SNAPSHOT: + case EventType.STATE_DELTA: + yield* start() + yield* dataParts.chunks(event) + break + + case EventType.CUSTOM: + // The durability log marks an accepted run. It is not app data. + if (event.name === RUN_ACCEPTED_EVENT) break + yield* start() + yield { + type: `data-${event.name}`, + data: event.value, + transient: true, + } + break + + case EventType.RUN_FINISHED: { + const reason = tanstackField(event.metadata, 'finishReason') + if (typeof reason === 'string') + finishReason = TO_AI_SDK_FINISH[reason] + // A tool approval pauses the run. A client tool needs no chunk: the + // AI SDK client runs a tool call that has no output. A subagent's + // tool call is not in this message (the AI SDK client throws on an + // unknown one), so its approval shows on the subagent card only. + if (event.outcome?.type === 'interrupt') { + for (const interrupt of event.outcome.interrupts) { + if ( + interrupt.metadata?.kind !== 'approval' || + interrupt.toolCallId === undefined || + !tools.has(interrupt.toolCallId) + ) + continue + yield* start() + yield { + type: 'tool-approval-request', + approvalId: interrupt.id, + toolCallId: interrupt.toolCallId, + } + // The binding names the paused run. The event `runId` can be + // the adapter's own id. + const runId = readInterruptBinding(interrupt)?.interruptedRunId + if (runId !== undefined) { + yield { + type: 'message-metadata', + messageMetadata: { tanstack: { runId } }, + } + } + } + } + break + } + case EventType.RUN_ERROR: + errored = true + yield { type: 'error', errorText: event.message } + break + + default: + break + } + } + + if (errored) return + yield* start() + yield finishReason === undefined + ? { type: 'finish' } + : { type: 'finish', finishReason } + } + + const iterator = convert() + return new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next() + if (done) controller.close() + else controller.enqueue(value) + }, + async cancel() { + await iterator.return(undefined) + }, + }) +} + +/** Options for {@link fromUIMessageStream}. */ +export interface FromUIMessageStreamOptions { + threadId: string + runId: string + /** + * The client's tools, as passed to `useChat({ tools })`. A tool approval for + * one of them with `needsApproval: true` is a `tool-approval` interrupt. + */ + tools?: ReadonlyArray +} + +/** + * Convert an AI SDK UI message stream into AG-UI events for the TanStack chat + * client. All text and tool calls go into one assistant message, as the AI + * SDK builds them. + * + * A tool approval request ends the run with an approval interrupt. For a + * tool in `tools`, it is a `tool-approval` interrupt: answer it with + * `resolveInterrupt(true)` or `client.resolveInterrupts(true)`. For another + * tool, it is a generic interrupt: answer it with + * `resolveInterrupt({ approved })`. The next request sends the tool part as + * `approval-responded`. Sources, files + * and step boundaries are skipped. A `data-` part with an id and object + * data becomes an activity of type ``. Other data chunks become CUSTOM + * events named `data-`. + */ +export async function* fromUIMessageStream( + stream: ReadableStream, + { threadId, runId, tools: clientTools }: FromUIMessageStreamOptions, +): AsyncGenerator { + let messageId = `${runId}-assistant` + let ended = false + // AI SDK reasoning ids are only unique inside one step, so each reasoning + // part gets its own AG-UI message id. + const reasoningIds = new Map() + let reasoningCount = 0 + const tools = new Map() + const inputs = new Map() + const approvals: Array = [] + + function* finished( + metadata?: Record, + ): Generator { + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + outcome: + approvals.length > 0 + ? { type: 'interrupt', interrupts: approvals } + : { type: 'success' }, + ...(metadata && { metadata }), + } + } + + function* ensureToolStarted( + toolCallId: string, + toolName: string, + input: unknown, + ): Generator { + let tool = tools.get(toolCallId) + if (!tool) { + tool = { started: true, hasArgs: false } + tools.set(toolCallId, tool) + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: toolName, + parentMessageId: messageId, + } + } + if (!tool.hasArgs) { + tool.hasArgs = true + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: JSON.stringify(input ?? {}), + } + } + } + + yield { type: EventType.RUN_STARTED, threadId, runId } + + for await (const chunk of stream) { + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, files, steps and approvals are skipped; default maps data parts. + switch (chunk.type) { + case 'start': + if (chunk.messageId !== undefined) messageId = chunk.messageId + break + + // Every AI SDK text part belongs to the one assistant message. The + // TanStack client starts a new text segment on each START. + case 'text-start': + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + } + break + case 'text-delta': + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: chunk.delta, + } + break + case 'text-end': + yield { type: EventType.TEXT_MESSAGE_END, messageId } + break + + case 'reasoning-start': { + const id = `${messageId}-reasoning-${reasoningCount++}` + reasoningIds.set(chunk.id, id) + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: id, + role: 'reasoning', + } + break + } + case 'reasoning-delta': { + const id = reasoningIds.get(chunk.id) + if (id !== undefined) { + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: id, + delta: chunk.delta, + } + } + break + } + case 'reasoning-end': { + const id = reasoningIds.get(chunk.id) + if (id === undefined) break + // The provider metadata holds the reasoning signature. The client + // keeps it as an opaque value and sends it back unchanged. + if (chunk.providerMetadata !== undefined) { + yield { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'message', + entityId: id, + encryptedValue: JSON.stringify(chunk.providerMetadata), + } + } + yield { type: EventType.REASONING_MESSAGE_END, messageId: id } + break + } + + case 'tool-input-start': + tools.set(chunk.toolCallId, { started: true, hasArgs: false }) + yield { + type: EventType.TOOL_CALL_START, + toolCallId: chunk.toolCallId, + toolCallName: chunk.toolName, + parentMessageId: messageId, + } + break + case 'tool-input-delta': { + const tool = tools.get(chunk.toolCallId) + if (tool) tool.hasArgs = true + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: chunk.toolCallId, + delta: chunk.inputTextDelta, + } + break + } + case 'tool-input-available': + inputs.set(chunk.toolCallId, { + toolName: chunk.toolName, + input: chunk.input, + }) + yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) + yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId } + break + case 'tool-approval-request': { + // `kind: 'approval'` shows the approval on the tool call. + const call = inputs.get(chunk.toolCallId) + const descriptor: Interrupt = { + id: chunk.approvalId, + reason: 'tool_call', + toolCallId: chunk.toolCallId, + metadata: { kind: 'approval', ...call }, + } + const tool = clientTools?.find((t) => t.name === call?.toolName) + if (call && tool?.needsApproval === true) { + // The binding chat() makes. The client accepts it only when the + // schema hashes match its own copy of the tool. + const schema = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, + ) + approvals.push( + withInterruptBinding( + { ...descriptor, responseSchema: schema.responseSchema }, + { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: chunk.approvalId, + toolName: call.toolName, + toolCallId: chunk.toolCallId, + originalArgs: call.input, + inputSchemaHash: hashSchemaInput(tool.inputSchema), + approvalSchemaHash: schema.approvalSchemaHash, + responseSchemaHash: schema.responseSchemaHash, + interruptedRunId: runId, + generation: 0, + }, + ), + ) + break + } + // A generic binding makes the interrupt resolvable without the + // client's copy of the tool. + approvals.push( + withInterruptBinding(descriptor, { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: chunk.approvalId, + interruptedRunId: runId, + generation: 0, + }), + ) + break + } + case 'tool-output-denied': + yield toolError( + chunk.toolCallId, + 'The user denied this tool call', + 'denied', + ) + break + case 'tool-input-error': + yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) + yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId } + yield toolError(chunk.toolCallId, chunk.errorText) + break + case 'tool-output-available': + // A preliminary output is a progress update. Only the last one is the result. + if (chunk.preliminary === true) break + yield { + type: EventType.TOOL_CALL_RESULT, + messageId: `${chunk.toolCallId}-result`, + toolCallId: chunk.toolCallId, + content: + typeof chunk.output === 'string' + ? chunk.output + : JSON.stringify(chunk.output ?? null), + role: 'tool', + } + break + case 'tool-output-error': + yield toolError(chunk.toolCallId, chunk.errorText) + break + + case 'error': + ended = true + yield { type: EventType.RUN_ERROR, message: chunk.errorText } + break + case 'abort': + ended = true + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + outcome: { type: 'cancelled' }, + } + break + case 'finish': { + ended = true + const finishReason = + chunk.finishReason === undefined + ? undefined + : FROM_AI_SDK_FINISH[chunk.finishReason] + yield* finished( + finishReason == null ? undefined : { tanstack: { finishReason } }, + ) + break + } + + default: + if (isDataChunk(chunk)) yield dataChunkEvent(chunk) + break + } + if (ended) return + } + + yield* finished() +} + +function toolError( + toolCallId: string, + errorText: string, + toolResultOutcome?: ToolResultOutcome, +): StreamChunk { + return { + type: EventType.TOOL_CALL_RESULT, + messageId: `${toolCallId}-result`, + toolCallId, + content: errorText, + role: 'tool', + metadata: { + tanstack: { + state: 'output-error', + ...(toolResultOutcome && { toolResultOutcome }), + }, + }, + } +} diff --git a/packages/ai-ui-message-stream/tests/approvals.test.ts b/packages/ai-ui-message-stream/tests/approvals.test.ts new file mode 100644 index 0000000000..025a3bf67e --- /dev/null +++ b/packages/ai-ui-message-stream/tests/approvals.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { fromChatTransport, toUIMessageStream } from '../src' +import { chatParamsFromAISDKRequest } from '../src/request' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +describe('TanStack server, AI SDK client: approvals', () => { + async function run(approved: boolean) { + const fake = fakeText() + fake.setResponses([ + { toolCalls: [{ name: 'deleteFile', input: { path: 'a.txt' } }] }, + ({ request }) => ({ + text: `Result: ${String(request.messages.at(-1)?.content)}`, + }), + ]) + const execute = vi.fn(() => ({ deleted: true })) + const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, + }).server(execute) + + // The run ids chat() sees, from a middleware. + const runs: Array<{ runId: string; parentRunId?: string }> = [] + const middleware = [ + { + onStart: (ctx: { runId: string; parentRunId?: string }) => { + runs.push({ runId: ctx.runId, parentRunId: ctx.parentRunId }) + }, + }, + ] + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Delete a.txt' }], + } + + // Turn 1: the AI SDK client sends the user message. + const firstParams = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { + method: 'POST', + body: JSON.stringify({ id: 'chat-1', messages: [user] }), + }), + ) + let first: UIMessage | undefined + for await (const message of readUIMessageStream({ + stream: toUIMessageStream( + chat({ + adapter: fake, + tools: [deleteFile], + middleware, + ...firstParams, + }), + ), + })) + first = message + // The message keeps the paused TanStack run id. + expect(first?.metadata).toEqual({ tanstack: { runId: runs[0]?.runId } }) + const requested = first?.parts.find((p) => p.type === 'tool-deleteFile') + expect(requested).toMatchObject({ + state: 'approval-requested', + input: { path: 'a.txt' }, + approval: { id: expect.any(String) }, + }) + expect(execute).not.toHaveBeenCalled() + + // The AI SDK client answers: `addToolApprovalResponse` sets this state. + const answered: UIMessage = { + ...first!, + parts: first!.parts.map((part) => + part.type === 'tool-deleteFile' && part.state === 'approval-requested' + ? { + ...part, + state: 'approval-responded', + approval: { id: part.approval.id, approved }, + } + : part, + ), + } + + // Turn 2: the history goes back, and chat() resumes the paused call. + const params = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { + method: 'POST', + body: JSON.stringify({ id: 'chat-1', messages: [user, answered] }), + }), + ) + expect(params.resume).toEqual([ + { + interruptId: answered.parts.flatMap((p) => + p.type === 'tool-deleteFile' && p.state === 'approval-responded' + ? [p.approval.id] + : [], + )[0], + status: 'resolved', + payload: { approved }, + }, + ]) + // The resume names the paused run, not the chat id. + expect(params.parentRunId).toBe(runs[0]?.runId) + expect(params.parentRunId).not.toBe('chat-1') + let second: UIMessage | undefined + for await (const message of readUIMessageStream({ + // The AI SDK client continues the last assistant message. + message: answered, + stream: toUIMessageStream( + chat({ adapter: fake, tools: [deleteFile], middleware, ...params }), + ), + })) + second = message + expect(runs[1]?.parentRunId).toBe(runs[0]?.runId) + return { execute, second: second! } + } + + it('runs the tool after the user approves', async () => { + const { execute, second } = await run(true) + expect(execute).toHaveBeenCalledTimes(1) + expect(second.parts).toMatchObject([ + { + type: 'tool-deleteFile', + state: 'output-available', + output: { deleted: true }, + }, + { type: 'text', text: 'Result: {"deleted":true}' }, + ]) + }) + + it('skips the tool after the user denies', async () => { + const { execute, second } = await run(false) + expect(execute).not.toHaveBeenCalled() + expect(second.parts).toMatchObject([ + { + type: 'tool-deleteFile', + state: 'output-denied', + approval: { approved: false }, + }, + { type: 'text', text: expect.stringContaining('Result:') }, + ]) + }) + + it('shows a subagent approval on its card, not as a top-level request', async () => { + const events: Array = [ + { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'researcher', + parentMessageId: 'm', + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' }, + { + type: EventType.SUBAGENT_STARTED, + subagentRunId: 's-1', + name: 'researcher', + parentToolCallId: 'call-1', + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'c-1', + toolCallName: 'deleteFile', + subagentRunId: 's-1', + }, + { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 's-1', + outcome: { type: 'suspended', interruptIds: ['ap-1'] }, + }, + { + type: EventType.RUN_FINISHED, + threadId: 't', + runId: 'r', + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'ap-1', + reason: 'tool_call', + toolCallId: 'c-1', + subagentRunId: 's-1', + metadata: { kind: 'approval' }, + }, + ], + }, + }, + ] + async function* source() { + yield* events + } + let message: UIMessage | undefined + // The AI SDK client throws on an approval for an unknown tool call. + for await (const m of readUIMessageStream({ + stream: toUIMessageStream(source()), + })) + message = m + expect(message?.parts).toMatchObject([ + { type: 'tool-researcher', toolCallId: 'call-1' }, + { + type: 'data-subagent', + id: 's-1', + data: { status: 'suspended', interruptIds: ['ap-1'] }, + }, + ]) + }) + + it('rejects a body that is not an AI SDK chat request', async () => { + const error: unknown = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { method: 'POST', body: '{}' }), + ).catch((e: unknown) => e) + expect(error).toBeInstanceOf(Response) + expect(error instanceof Response && error.status).toBe(400) + }) +}) + +describe('AI SDK server, TanStack client: approvals', () => { + const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, + }) + + const tools = [deleteFile] as const + + /** `bound`: the adapter gets the tools too, as the docs show. */ + async function run( + approved: boolean, + answer: (client: ChatClient) => void, + bound = true, + ) { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve( + chunkStream( + requests.length === 1 + ? [ + { type: 'start', messageId: 'a-1' }, + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'deleteFile', + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'deleteFile', + input: { path: 'a.txt' }, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + }, + { type: 'finish', finishReason: 'tool-calls' }, + ] + : [ + { type: 'start', messageId: 'a-1' }, + approved + ? { + type: 'tool-output-available', + toolCallId: 'c-1', + output: { deleted: true }, + } + : { type: 'tool-output-denied', toolCallId: 'c-1' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'All done.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ], + ), + ) + }, + reconnectToStream: () => Promise.resolve(null), + } + const client = new ChatClient({ + threadId: 'thread-1', + tools, + connection: fromChatTransport(transport, bound ? { tools } : {}), + }) + + await client.sendMessage('Delete a.txt') + const toolCall = client + .getMessages() + .flatMap((m) => m.parts) + .find((p) => p.type === 'tool-call') + expect(toolCall).toMatchObject({ + state: 'approval-requested', + approval: { id: 'ap-1' }, + }) + + expect(client.getInterrupts()).toMatchObject([ + { + kind: bound ? 'tool-approval' : 'generic', + canResolve: true, + id: 'ap-1', + metadata: { toolName: 'deleteFile', input: { path: 'a.txt' } }, + }, + ]) + answer(client) + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(requests[1]!.find((m) => m.role === 'assistant')?.parts).toEqual([ + { + type: 'dynamic-tool', + toolName: 'deleteFile', + toolCallId: 'c-1', + input: { path: 'a.txt' }, + state: 'approval-responded', + approval: { id: 'ap-1', approved }, + }, + ]) + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('All done.'), + ) + expect(client.getInterrupts()).toEqual([]) + return client + } + + it('sends the approval and shows the tool output', async () => { + const client = await run(true, (c) => { + const [interrupt] = c.getInterrupts() + if (interrupt?.kind !== 'tool-approval') throw new Error('not bound') + interrupt.resolveInterrupt(true) + }) + const parts = client.getMessages().flatMap((m) => m.parts) + expect(parts).toContainEqual( + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'c-1', + content: '{"deleted":true}', + }), + ) + }) + + it('sends the denial and marks the tool result as denied', async () => { + const client = await run(false, (c) => c.resolveInterrupts(false)) + const parts = client.getMessages().flatMap((m) => m.parts) + expect(parts).toContainEqual( + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'c-1', + state: 'error', + outcome: 'denied', + }), + ) + }) + + it('falls back to a generic interrupt when the adapter has no tools', async () => { + const client = await run( + true, + (c) => { + const [interrupt] = c.getInterrupts() + if (interrupt?.kind !== 'generic') throw new Error('expected generic') + interrupt.resolveInterrupt({ approved: true }) + }, + false, + ) + expect(client.getMessages().flatMap((m) => m.parts)).toContainEqual( + expect.objectContaining({ type: 'tool-result', toolCallId: 'c-1' }), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/data-parts.test.ts b/packages/ai-ui-message-stream/tests/data-parts.test.ts new file mode 100644 index 0000000000..5c9f5af8ba --- /dev/null +++ b/packages/ai-ui-message-stream/tests/data-parts.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType } from '@tanstack/ai' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { fromChatTransport } from '../src' +import { DataParts, dataChunkEvent, isDataChunk } from '../src/data-parts' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +/** The last AI SDK message built from these events' data chunks. */ +async function readMessage(list: Array): Promise { + const parts = new DataParts() + const chunks: Array = [ + { type: 'start', messageId: 'm' }, + ...list.flatMap((event) => parts.chunks(event)), + { type: 'finish' }, + ] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }) + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) last = message + if (!last) throw new Error('no message') + return last +} + +describe('DataParts (TanStack server, AI SDK client)', () => { + it('replaces an activity part in place on each delta', async () => { + const message = await readMessage([ + { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'plan-1', + activityType: 'plan', + content: { steps: ['a'], done: false }, + }, + { + type: EventType.ACTIVITY_DELTA, + messageId: 'plan-1', + activityType: 'plan', + patch: [ + { op: 'add', path: '/steps/-', value: 'b' }, + { op: 'replace', path: '/done', value: true }, + ], + }, + // A failed patch keeps the last content. + { + type: EventType.ACTIVITY_DELTA, + messageId: 'plan-1', + activityType: 'plan', + patch: [{ op: 'remove', path: '/missing' }], + }, + // No snapshot for this id. + { + type: EventType.ACTIVITY_DELTA, + messageId: 'other', + activityType: 'plan', + patch: [], + }, + ]) + expect(message.parts).toEqual([ + { + type: 'data-plan', + id: 'plan-1', + data: { steps: ['a', 'b'], done: true }, + }, + ]) + }) + + it('keeps an activity when replace is false', async () => { + const snapshot = { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'a', + activityType: 'plan', + content: { v: 1 }, + } as const + const message = await readMessage([ + snapshot, + { ...snapshot, content: { v: 2 }, replace: false }, + ]) + expect(message.parts).toEqual([ + { type: 'data-plan', id: 'a', data: { v: 1 } }, + ]) + }) + + it('sends the full shared state on snapshot and delta', async () => { + const message = await readMessage([ + { type: EventType.STATE_SNAPSHOT, snapshot: { count: 1, tags: ['x'] } }, + { + type: EventType.STATE_DELTA, + delta: [ + { op: 'test', path: '/tags', value: ['x'] }, + { op: 'copy', from: '/count', path: '/previous' }, + { op: 'replace', path: '/count', value: 2 }, + { op: 'move', from: '/tags/0', path: '/first' }, + ], + }, + ]) + expect(message.parts).toEqual([ + { + type: 'data-state', + id: 'state', + data: { count: 2, tags: [], previous: 1, first: 'x' }, + }, + ]) + }) + + it('shows a subagent card going from running to finished', async () => { + const parts = new DataParts() + const tagged = { subagentRunId: 'sub-1' } + const list: Array = [ + { + type: EventType.SUBAGENT_STARTED, + ...tagged, + name: 'researcher', + description: 'Finds facts', + parentToolCallId: 'call-1', + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'c1', + role: 'assistant', + ...tagged, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'c1', + delta: 'Found ', + ...tagged, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'c1', + delta: 'it.', + ...tagged, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'c1', ...tagged }, + { type: EventType.SUBAGENT_FINISHED, ...tagged }, + ] + for (const event of list) EventSchemas.parse(event) + + const statuses = list.flatMap((event) => + parts + .chunks(event) + .map((chunk) => + 'data' in chunk && typeof chunk.data === 'object' && chunk.data + ? 'status' in chunk.data && chunk.data.status + : undefined, + ), + ) + expect(statuses).toEqual(['running', 'running', 'running', 'finished']) + + const message = await readMessage(list) + expect(message.parts).toEqual([ + { + type: 'data-subagent', + id: 'sub-1', + data: { + id: 'sub-1', + name: 'researcher', + description: 'Finds facts', + parentToolCallId: 'call-1', + status: 'finished', + text: 'Found it.', + }, + }, + ]) + }) + + it('marks a subagent card as errored or suspended', async () => { + const message = await readMessage([ + { type: EventType.SUBAGENT_STARTED, subagentRunId: 'a', name: 'a' }, + { + type: EventType.SUBAGENT_ERROR, + subagentRunId: 'a', + message: 'boom', + code: 'E1', + }, + // An error is final. + { type: EventType.SUBAGENT_FINISHED, subagentRunId: 'a' }, + { type: EventType.SUBAGENT_STARTED, subagentRunId: 'b', name: 'b' }, + { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 'b', + outcome: { type: 'suspended', interruptIds: ['i1'] }, + }, + ]) + expect(message.parts.map((p) => 'data' in p && p.data)).toEqual([ + { + id: 'a', + name: 'a', + status: 'error', + error: { message: 'boom', code: 'E1' }, + text: '', + }, + { + id: 'b', + name: 'b', + status: 'suspended', + interruptIds: ['i1'], + text: '', + }, + ]) + }) +}) + +describe('dataChunkEvent (AI SDK server, TanStack client)', () => { + it('maps a persistent data chunk with an id to an activity snapshot', () => { + const chunk: UIMessageChunk = { + type: 'data-weather', + id: 'w-1', + data: { city: 'Oslo' }, + } + if (!isDataChunk(chunk)) throw new Error('not a data chunk') + const event = dataChunkEvent(chunk) + EventSchemas.parse(event) + expect(event).toEqual({ + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'w-1', + activityType: 'weather', + content: { city: 'Oslo' }, + replace: true, + }) + }) + + it.each([ + { type: 'data-ping', id: 'p', data: { a: 1 }, transient: true }, + { type: 'data-ping', data: { a: 1 } }, + { type: 'data-ping', id: 'p', data: 'text' }, + { type: 'data-ping', id: 'p', data: [1, 2] }, + ])('keeps $type as a CUSTOM event: %o', (chunk) => { + if (!isDataChunk(chunk)) throw new Error('not a data chunk') + const event = dataChunkEvent(chunk) + EventSchemas.parse(event) + expect(event).toEqual({ + type: EventType.CUSTOM, + name: 'data-ping', + value: chunk.data, + }) + }) + + it('shows the activity in the TanStack client and replaces it by id', async () => { + const chunks: Array = [ + { type: 'start', messageId: 'a' }, + { type: 'text-start', id: '0' }, + { type: 'data-weather', id: 'w-1', data: { city: 'Oslo' } }, + { type: 'data-weather', id: 'w-1', data: { city: 'Bergen' } }, + { type: 'text-delta', id: '0', delta: 'Sunny.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ] + const transport: ChatTransport = { + sendMessages: () => + Promise.resolve( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }), + ), + reconnectToStream: () => Promise.resolve(null), + } + const client = new ChatClient({ + threadId: 't', + connection: fromChatTransport(transport), + }) + await client.sendMessage('weather?') + await vi.waitFor(() => + expect(client.getMessages().map((m) => [m.role, m.parts])).toEqual([ + ['user', [expect.objectContaining({ type: 'text' })]], + [ + 'assistant', + [expect.objectContaining({ type: 'text', content: 'Sunny.' })], + ], + [ + 'activity', + [ + { + type: 'activity', + activityType: 'weather', + content: { city: 'Bergen' }, + }, + ], + ], + ]), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/resume.test.ts b/packages/ai-ui-message-stream/tests/resume.test.ts new file mode 100644 index 0000000000..53bcaac9d8 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/resume.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { chat, memoryStream } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { ChatClient } from '@tanstack/ai-client' +import { DefaultChatTransport, readUIMessageStream } from 'ai' +import { + fromAISDKMessages, + fromChatTransport, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '../src' +import type { ChatPersistedState } from '@tanstack/ai-client' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +const REPLY: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'Hel' }, + { type: 'text-delta', id: 't', delta: 'lo.' }, + { type: 'text-end', id: 't' }, + { type: 'finish', finishReason: 'stop' }, +] + +/** A stream of `list` that fails with a dropped connection after `dropAfter` chunks. */ +function chunkStream( + list: Array, + dropAfter = Infinity, +): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index === dropAfter) controller.error(new TypeError('network error')) + else if (index === list.length) controller.close() + else controller.enqueue(list[index++]!) + }, + }) +} + +function fakeTransport( + reconnect: () => ReadableStream | null, +): ChatTransport & { reconnects: Array } { + const reconnects: Array = [] + return { + resumeStreamIsReplay: true, + reconnects, + sendMessages: () => Promise.resolve(chunkStream(REPLY, 3)), + reconnectToStream: ({ chatId }) => { + reconnects.push(chatId) + return Promise.resolve(reconnect()) + }, + } +} + +function assistantText(client: ChatClient): string { + const last = client.getMessages().at(-1) + if (last?.role !== 'assistant') return '' + return last.parts.map((p) => (p.type === 'text' ? p.content : '')).join('') +} + +describe('TanStack client, AI SDK route: resume', () => { + it('reconnects a dropped stream and skips the replayed prefix', async () => { + const transport = fakeTransport(() => chunkStream(REPLY)) + const client = new ChatClient({ + threadId: 'chat-1', + connection: fromChatTransport(transport), + }) + + await client.sendMessage('hi') + + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getMessages().map((m) => m.role)).toEqual([ + 'user', + 'assistant', + ]) + expect(assistantText(client)).toBe('Hello.') + expect(client.getError()).toBeUndefined() + }) + + it('rejoins the run after a page reload from the persisted resume pointer', async () => { + const transport = fakeTransport(() => chunkStream(REPLY)) + const saved: ChatPersistedState = { + messages: [ + { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', content: 'hi' }], + }, + { + id: 'a-1', + role: 'assistant', + parts: [{ type: 'text', content: 'Hel' }], + }, + ], + resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } }, + } + const client = new ChatClient({ + threadId: 'chat-1', + persistence: { + getItem: () => saved, + setItem: () => {}, + removeItem: () => {}, + }, + connection: fromChatTransport(transport, { threadId: 'chat-1' }), + }) + + client.attach() + + await vi.waitFor(() => expect(assistantText(client)).toBe('Hello.')) + await vi.waitFor(() => expect(client.getIsLoading()).toBe(false)) + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getMessages().map((m) => m.role)).toEqual([ + 'user', + 'assistant', + ]) + }) + + it('drops the resume pointer when the route has nothing to resume', async () => { + const transport = fakeTransport(() => null) + const writes: Array = [] + const client = new ChatClient({ + threadId: 'chat-1', + persistence: { + getItem: () => ({ + messages: [ + { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', content: 'hi' }], + }, + ], + resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } }, + }), + setItem: (_id, state) => void writes.push(state), + removeItem: () => {}, + }, + connection: fromChatTransport(transport, { threadId: 'chat-1' }), + }) + + client.attach() + + await vi.waitFor(() => { + expect(writes).not.toHaveLength(0) + expect(writes.at(-1)?.resume).toBeUndefined() + }) + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getError()).toBeUndefined() + }) +}) + +describe('AI SDK client, TanStack route: resume', () => { + function server() { + const fake = fakeText() + fake.setResponses([{ text: 'Resumed answer.' }]) + // The app records the chat's active run. With `@tanstack/ai-persistence` + // this is `stores.runs.findActiveRun(chatId)`. + const activeRuns = new Map() + + async function POST(request: Request): Promise { + const body: { id: string; messages: Array } = + await request.json() + const runId = crypto.randomUUID() + activeRuns.set(body.id, runId) + return toResumableUIMessageStreamResponse( + chat({ + adapter: fake, + threadId: body.id, + runId, + messages: fromAISDKMessages(body.messages), + }), + memoryStream({ runId }), + ) + } + + // GET /api/chat/:id/stream + function GET(chatId: string): Response { + const runId = activeRuns.get(chatId) + return resumeUIMessageStreamResponse( + runId === undefined ? null : memoryStream({ runId }), + ) + } + + return new DefaultChatTransport({ + api: 'http://test/api/chat', + fetch: async (input, init) => { + const url = new URL(String(input)) + const match = /^\/api\/chat\/(.+)\/stream$/.exec(url.pathname) + if (match) return GET(decodeURIComponent(match[1]!)) + return POST(new Request(url, init)) + }, + }) + } + + async function lastMessage( + stream: ReadableStream, + ): Promise { + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) last = message + return last + } + + it('replays the whole run on the resume GET after the POST is dropped', async () => { + const transport = server() + const posted = await transport.sendMessages({ + trigger: 'submit-message', + chatId: 'chat-1', + messageId: undefined, + messages: [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }, + ], + abortSignal: undefined, + }) + // The page reloads: the POST response is dropped after its first chunk. + const reader = posted.getReader() + await reader.read() + await reader.cancel() + + const resumed = await transport.reconnectToStream({ chatId: 'chat-1' }) + expect(resumed).not.toBeNull() + const message = await lastMessage(resumed!) + + expect(message?.parts.filter((p) => p.type === 'text')).toMatchObject([ + { type: 'text', text: 'Resumed answer.' }, + ]) + expect(JSON.stringify(message)).not.toContain('run.accepted') + }) + + it('answers 204 (no stream) when the chat has no active run', async () => { + const transport = server() + expect(await transport.reconnectToStream({ chatId: 'other' })).toBeNull() + }) +}) diff --git a/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts new file mode 100644 index 0000000000..8dece75597 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType, chat, convertMessagesToModelMessages } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { + fromAISDKMessages, + fromChatTransport, + fromUIMessageStream, + toUIMessageStream, +} from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +async function* events(list: Array): AsyncGenerator { + yield* list +} + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +describe('TanStack server, AI SDK client', () => { + it('builds the AI SDK message and replays it into chat()', async () => { + const stream = toUIMessageStream( + events([ + { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' }, + { + type: EventType.REASONING_MESSAGE_START, + messageId: 'think-1', + role: 'reasoning', + }, + { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: 'think-1', + delta: 'Plan.', + }, + { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'message', + entityId: 'think-1', + encryptedValue: 'sig-1', + }, + { type: EventType.REASONING_MESSAGE_END, messageId: 'think-1' }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'msg-1', + role: 'assistant', + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + delta: 'Checking.', + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + parentMessageId: 'msg-1', + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"city":"NYC"}', + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' }, + { + type: EventType.TOOL_CALL_RESULT, + messageId: 'res-1', + toolCallId: 'call-1', + content: '{"temp":72}', + role: 'tool', + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'msg-1', + role: 'assistant', + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + delta: 'It is 72F.', + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' }, + { + type: EventType.RUN_FINISHED, + threadId: 't', + runId: 'r', + outcome: { type: 'success' }, + metadata: { tanstack: { finishReason: 'stop' } }, + }, + ]), + ) + + let assistant: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) { + assistant = message + } + + expect(assistant?.parts).toMatchObject([ + { + type: 'reasoning', + text: 'Plan.', + providerMetadata: { tanstack: { signature: 'sig-1' } }, + }, + { type: 'text', text: 'Checking.' }, + { + type: 'tool-weather', + toolCallId: 'call-1', + state: 'output-available', + input: { city: 'NYC' }, + output: { temp: 72 }, + }, + { type: 'text', text: 'It is 72F.' }, + ]) + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather in NYC?' }], + } + const history = convertMessagesToModelMessages( + fromAISDKMessages([user, assistant!]), + ) + expect(history).toMatchObject([ + { role: 'user', content: 'Weather in NYC?' }, + { + role: 'assistant', + content: 'Checking.', + thinking: [{ content: 'Plan.', signature: 'sig-1' }], + toolCalls: [ + { + id: 'call-1', + function: { name: 'weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call-1', content: '{"temp":72}' }, + { role: 'assistant', content: 'It is 72F.' }, + ]) + }) + + it('serves real chat() output, then continues after an AI SDK client tool', async () => { + const fake = fakeText() + fake.setResponses([ + { + thinking: 'Plan.', + toolCalls: [{ name: 'weather', input: { city: 'NYC' } }], + }, + { toolCalls: [{ name: 'ask', input: { q: 'Units?' } }] }, + ({ request }) => ({ + text: `Answer: ${String(request.messages.at(-1)?.content)}`, + }), + ]) + const weather = toolDefinition({ + name: 'weather', + description: 'Weather for a city', + inputSchema: z.object({ city: z.string() }), + }).server(() => ({ temp: 72 })) + const ask = toolDefinition({ + name: 'ask', + description: 'Ask the user', + inputSchema: z.object({ q: z.string() }), + }) + + async function turn(messages: Array): Promise { + const stream = toUIMessageStream( + chat({ + adapter: fake, + threadId: 'chat-1', + messages: fromAISDKMessages(messages), + tools: [weather, ask], + }), + ) + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) + last = message + return last! + } + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather?' }], + } + const first = await turn([user]) + expect(first.parts).toMatchObject([ + { type: 'reasoning', text: 'Plan.' }, + { type: 'tool-weather', state: 'output-available', output: { temp: 72 } }, + { type: 'tool-ask', state: 'input-available', input: { q: 'Units?' } }, + ]) + + // The AI SDK client runs `ask` and posts the history with its output. + const answered: UIMessage = { + ...first, + parts: first.parts.map((part) => + part.type === 'tool-ask' && part.state === 'input-available' + ? { ...part, state: 'output-available', output: 'Fahrenheit' } + : part, + ), + } + const second = await turn([user, answered]) + expect(second.parts).toMatchObject([ + { type: 'text', text: 'Answer: Fahrenheit' }, + ]) + }) + + it('sends an AI SDK tool error back as an error result', () => { + const [assistant] = convertMessagesToModelMessages( + fromAISDKMessages([ + { + id: 'a-1', + role: 'assistant', + parts: [ + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + state: 'output-error', + input: {}, + errorText: 'offline', + }, + ], + }, + ]), + ).slice(1) + expect(assistant).toMatchObject({ + role: 'tool', + toolCallId: 'c-1', + error: 'offline', + }) + }) +}) + +describe('AI SDK server, TanStack client', () => { + const firstTurn: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'start-step' }, + { type: 'reasoning-start', id: '0' }, + { type: 'reasoning-delta', id: '0', delta: 'Hmm.' }, + { + type: 'reasoning-end', + id: '0', + providerMetadata: { anthropic: { signature: 'sig' } }, + }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: 'Pinging.' }, + { type: 'text-end', id: '1' }, + { type: 'tool-input-start', toolCallId: 'c-1', toolName: 'ping' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '{}' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + }, + { type: 'finish-step' }, + { type: 'finish', finishReason: 'tool-calls' }, + ] + + it('emits valid AG-UI events', async () => { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(firstTurn), { + threadId: 't', + runId: 'r', + })) { + out.push(event) + } + for (const event of out) EventSchemas.parse(event) + expect(out.map((e) => e.type)).toEqual([ + 'RUN_STARTED', + 'REASONING_MESSAGE_START', + 'REASONING_MESSAGE_CONTENT', + 'REASONING_ENCRYPTED_VALUE', + 'REASONING_MESSAGE_END', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'TOOL_CALL_START', + 'TOOL_CALL_ARGS', + 'TOOL_CALL_END', + 'RUN_FINISHED', + ]) + }) + + it('runs a client tool and sends its output and reasoning back', async () => { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve( + chunkStream( + requests.length === 1 + ? firstTurn + : [ + { type: 'start' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Got pong.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ], + ), + ) + }, + reconnectToStream: () => Promise.resolve(null), + } + const execute = vi.fn(() => ({ reply: 'pong' })) + const ping = toolDefinition({ + name: 'ping', + description: 'Reply with pong', + inputSchema: z.object({}), + }).client(execute) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ping], + }) + + await client.sendMessage('ping me') + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(execute).toHaveBeenCalledTimes(1) + const sent = requests[1]!.find((m) => m.role === 'assistant') + expect(sent?.parts).toMatchObject([ + { + type: 'reasoning', + text: 'Hmm.', + providerMetadata: { anthropic: { signature: 'sig' } }, + }, + { type: 'text', text: 'Pinging.' }, + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + state: 'output-available', + output: { reply: 'pong' }, + }, + ]) + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Got pong.'), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tsconfig.json b/packages/ai-ui-message-stream/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-ui-message-stream/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-ui-message-stream/vite.config.ts b/packages/ai-ui-message-stream/vite.config.ts new file mode 100644 index 0000000000..11f5b20b70 --- /dev/null +++ b/packages/ai-ui-message-stream/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2f1b2686..1eb3fb4014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3703,6 +3703,31 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-ui-message-stream: + dependencies: + fast-json-patch: + specifier: ^3.1.1 + version: 3.1.1 + devDependencies: + '@ag-ui/core': + specifier: 1.0.0 + version: 1.0.0(zod@4.6.5) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-client': + specifier: workspace:* + version: link:../ai-client + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.11) + ai: + specifier: 7.0.148 + version: 7.0.148(zod@4.6.5) + zod: + specifier: ^4.2.0 + version: 4.6.5 + packages/ai-utils: devDependencies: '@types/node': @@ -3745,7 +3770,7 @@ importers: devDependencies: '@google/genai': specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -3954,6 +3979,12 @@ importers: testing/e2e: dependencies: + '@ai-sdk/openai': + specifier: 4.0.99 + version: 4.0.99(zod@4.3.6) + '@ai-sdk/react': + specifier: 4.0.152 + version: 4.0.152(react@19.2.3)(zod@4.3.6) '@anthropic-ai/sdk': specifier: ^0.135.0 version: 0.135.0(zod@4.3.6) @@ -4080,6 +4111,9 @@ importers: '@tanstack/ai-typesafe': specifier: workspace:* version: link:../../packages/ai-typesafe + '@tanstack/ai-ui-message-stream': + specifier: workspace:* + version: link:../../packages/ai-ui-message-stream '@tanstack/ai-vercel-gateway': specifier: workspace:* version: link:../../packages/ai-vercel-gateway @@ -4104,6 +4138,9 @@ importers: '@tanstack/react-start': specifier: ^1.168.60 version: 1.168.60(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + ai: + specifier: 7.0.148 + version: 7.0.148(zod@4.3.6) arktype: specifier: ^2.1.28 version: 2.2.0 @@ -4351,6 +4388,40 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/gateway@4.0.121': + resolution: {integrity: sha512-cPNXvQ4B5OkmVAm98Dxe1fHdMthyqqqAG0HJx3D92NUa9FpcvkNxdAqiuZdeAhNAodFb9lBrslzzzEwxuVS53A==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mcp@2.0.81': + resolution: {integrity: sha512-2ZqfHLu1K93uPse+QWbcNjkznCtM6UHMoNWjUDpmM9kCr+y9bWcf5VWfNAWi4ziQoncZPJo2qUWeDIXgrqLsKg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@4.0.99': + resolution: {integrity: sha512-XRHUiXgxhVEe0smSp537lzCqMsOVg6jvGTWDbTZeGMTc+iHeOwgHgZMFBvOiNWXZ8n125Jta72m+64PtU1WNJA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.65': + resolution: {integrity: sha512-LCugcsP8oWmdeI3BASf4Fuo1wiC6Qx0YrefFssl7n12s+LV4DNi5RdwWQrtrB4oek3GGNQRN/h/NSwVZM2eHKw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.31': + resolution: {integrity: sha512-3wJnwVTFg8SwOyoZCv+ov7Re2GB+7IQnU5a5MYJACrtiWDDUHgqTBeeesq2MB2ddgojzL8hgWleltwpAgtUqfg==} + engines: {node: '>=22'} + + '@ai-sdk/react@4.0.152': + resolution: {integrity: sha512-2A4bttJJgDmdiCTXO6qM6J28q1C3ucAVAwbWzYuaQ4ZF1PUy7GwelNtMGMf2FoRl8krCeHYAdAhRnJlnfciKpQ==} + engines: {node: '>=22'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alcalzone/ansi-tokenize@0.3.1': resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} engines: {node: '>=18'} @@ -12182,6 +12253,9 @@ packages: '@webgpu/types@0.1.72': resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -12246,6 +12320,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@7.0.148: + resolution: {integrity: sha512-FPgfIAhz/FrlzcvqTN7pGGibahR5rD19NXZWX9WQ1tskTic+CN+jxvubpreyq8uN1maIEHPwvZmil/3IYN8FIw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -15225,6 +15305,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -18034,6 +18117,11 @@ packages: resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} engines: {node: '>=18'} + swr@2.5.1: + resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -18119,6 +18207,10 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -19490,10 +19582,76 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@ag-ui/core@1.0.0(zod@4.6.5)': + optionalDependencies: + zod: 4.6.5 + '@agentclientprotocol/sdk@1.8.0(zod@4.6.5)': dependencies: zod: 4.6.5 + '@ai-sdk/gateway@4.0.121(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + '@vercel/oidc': 3.2.0 + zod: 4.3.6 + + '@ai-sdk/gateway@4.0.121(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/mcp@2.0.81(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + cross-spawn: 7.0.6 + pkce-challenge: 5.0.1 + zod: 4.3.6 + + '@ai-sdk/openai@4.0.99(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@5.0.65(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.3.6 + + '@ai-sdk/provider-utils@5.0.65(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.6.5 + + '@ai-sdk/provider@4.0.31': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@4.0.152(react@19.2.3)(zod@4.3.6)': + dependencies: + '@ai-sdk/mcp': 2.0.81(zod@4.3.6) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + ai: 7.0.148(zod@4.3.6) + react: 19.2.3 + swr: 2.5.1(react@19.2.3) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@alcalzone/ansi-tokenize@0.3.1': dependencies: ansi-styles: 6.2.3 @@ -22720,6 +22878,19 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: graphql: 16.14.2 @@ -23214,7 +23385,7 @@ snapshots: '@modelcontextprotocol/core@2.1.0': dependencies: - zod: 4.3.6 + zod: 4.6.5 '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)': dependencies: @@ -23297,6 +23468,31 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.8) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) + hono: 4.13.8 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/server@2.1.0': dependencies: '@modelcontextprotocol/core': 2.1.0 @@ -29503,6 +29699,8 @@ snapshots: '@webgpu/types@0.1.72': {} + '@workflow/serde@4.1.0': {} + '@workflow/serde@4.1.0-beta.2': {} '@xmldom/xmldom@0.8.13': {} @@ -29551,6 +29749,20 @@ snapshots: agent-base@7.1.4: {} + ai@7.0.148(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 4.0.121(zod@4.3.6) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + zod: 4.3.6 + + ai@7.0.148(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.121(zod@4.6.5) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5) + zod: 4.6.5 + ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -30322,7 +30534,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.29.0 + undici: 7.29.1 whatwg-mimetype: 4.0.0 chokidar@4.0.3: @@ -32957,6 +33169,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -37418,6 +37632,12 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' + swr@2.5.1(react@19.2.3): + dependencies: + dequal: 2.0.3 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + symbol-tree@3.2.4: {} synckit@0.11.13: @@ -37537,6 +37757,8 @@ snapshots: throat@5.0.0: {} + throttleit@2.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} diff --git a/skills/tanstack-ai-migration/SKILL.md b/skills/tanstack-ai-migration/SKILL.md index 165da6dde7..f107ef1519 100644 --- a/skills/tanstack-ai-migration/SKILL.md +++ b/skills/tanstack-ai-migration/SKILL.md @@ -36,6 +36,21 @@ Read the guide, then load the target API's own skill with `npx @tanstack/intent@latest load @tanstack/ai#ai-core`. Do not port from memory: a half-remembered mapping produces code that type-checks in neither SDK. +To port one side at a time, `@tanstack/ai-ui-message-stream` connects the two +SDKs over the AI SDK UI message stream: + +- Server first: keep AI SDK `useChat` and serve it from `chat()` with + `chat({ adapter, ...(await chatParamsFromAISDKRequest(request)) })` and + `toUIMessageStream`. + https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server +- Client first: keep the `streamText` route and pass + `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })` + as the `connection` of TanStack `useChat`. + https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server + +Tool approvals, client tools, activity, subagents and stream resume cross the +bridge. Shared state goes from a TanStack server to an AI SDK client only. + ## Upgrading TanStack AI itself Start with the version the app has, and the current release: diff --git a/testing/e2e/fixtures/ai-sdk/approval.json b/testing/e2e/fixtures/ai-sdk/approval.json new file mode 100644 index 0000000000..37bab7bffc --- /dev/null +++ b/testing/e2e/fixtures/ai-sdk/approval.json @@ -0,0 +1,27 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[ai-sdk-approval] buy the stratocaster", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "name": "addToCart", + "arguments": "{\"guitarId\":\"1\",\"quantity\":1}" + } + ] + } + }, + { + "match": { + "userMessage": "[ai-sdk-approval] buy the stratocaster", + "hasToolResult": true + }, + "response": { + "content": "The Stratocaster is in your cart." + } + } + ] +} diff --git a/testing/e2e/package.json b/testing/e2e/package.json index ca1b4ed91f..d86be524b5 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,8 @@ "postinstall": "playwright install chromium" }, "dependencies": { + "@ai-sdk/openai": "4.0.99", + "@ai-sdk/react": "4.0.152", "@anthropic-ai/sdk": "^0.135.0", "@copilotkit/aimock": "^1.34.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -54,6 +56,7 @@ "@tanstack/ai-skills": "workspace:*", "@tanstack/ai-solid": "workspace:*", "@tanstack/ai-typesafe": "workspace:*", + "@tanstack/ai-ui-message-stream": "workspace:*", "@tanstack/ai-vercel-gateway": "workspace:*", "@tanstack/ai-vertex": "workspace:*", "@tanstack/ai-worldlabs": "workspace:*", @@ -62,6 +65,7 @@ "@tanstack/react-ai-devtools": "workspace:*", "@tanstack/react-router": "^1.170.41", "@tanstack/react-start": "^1.168.60", + "ai": "7.0.148", "arktype": "^2.1.28", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 35cb311a76..de5a1682dc 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -11,6 +11,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ActivityTestRouteImport } from './routes/activity-test' +import { Route as AiSdkClientRouteImport } from './routes/ai-sdk-client' +import { Route as AiSdkResumeRouteImport } from './routes/ai-sdk-resume' +import { Route as AiSdkServerRouteImport } from './routes/ai-sdk-server' import { Route as ByokRouteImport } from './routes/byok' import { Route as CallOwnershipRouteImport } from './routes/call-ownership' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' @@ -52,6 +55,8 @@ import { Route as ProviderIndexRouteImport } from './routes/$provider/index' import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature' import { Route as ApiActivityTestRouteImport } from './routes/api.activity-test' import { Route as ApiAdapterInputModalitiesRouteImport } from './routes/api.adapter-input-modalities' +import { Route as ApiAiSdkClientRouteImport } from './routes/api.ai-sdk-client' +import { Route as ApiAiSdkServerRouteImport } from './routes/api.ai-sdk-server' import { Route as ApiAnthropicAuthWireRouteImport } from './routes/api.anthropic-auth-wire' import { Route as ApiAnthropicBugTestRouteImport } from './routes/api.anthropic-bug-test' import { Route as ApiAnthropicComputerToolsetWireRouteImport } from './routes/api.anthropic-computer-toolset-wire' @@ -199,11 +204,12 @@ import { Route as ApiVoiceRouteImport } from './routes/api.voice' import { Route as ApiWebMcpPageToolsRouteImport } from './routes/api.web-mcp-page-tools' import { Route as ApiWorldRouteImport } from './routes/api.world' import { Route as ApiWrapFetchWireRouteImport } from './routes/api.wrap-fetch-wire' +import { Route as ApiAiSdkResumeSplatRouteImport } from './routes/api.ai-sdk-resume.$' import { Route as ApiAudioStreamRouteImport } from './routes/api.audio.stream' -import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' -import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessHandoffSplatRouteImport } from './routes/api.harness-handoff.$' import { Route as ApiHarnessObservabilitySplatRouteImport } from './routes/api.harness-observability.$' +import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' +import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessWaitsSplatRouteImport } from './routes/api.harness-waits.$' import { Route as ApiImageStreamRouteImport } from './routes/api.image.stream' import { Route as ApiTranscriptionStreamRouteImport } from './routes/api.transcription.stream' @@ -220,6 +226,21 @@ const ActivityTestRoute = ActivityTestRouteImport.update({ path: '/activity-test', getParentRoute: () => rootRouteImport, } as any) +const AiSdkClientRoute = AiSdkClientRouteImport.update({ + id: '/ai-sdk-client', + path: '/ai-sdk-client', + getParentRoute: () => rootRouteImport, +} as any) +const AiSdkResumeRoute = AiSdkResumeRouteImport.update({ + id: '/ai-sdk-resume', + path: '/ai-sdk-resume', + getParentRoute: () => rootRouteImport, +} as any) +const AiSdkServerRoute = AiSdkServerRouteImport.update({ + id: '/ai-sdk-server', + path: '/ai-sdk-server', + getParentRoute: () => rootRouteImport, +} as any) const ByokRoute = ByokRouteImport.update({ id: '/byok', path: '/byok', @@ -429,6 +450,16 @@ const ApiAdapterInputModalitiesRoute = path: '/api/adapter-input-modalities', getParentRoute: () => rootRouteImport, } as any) +const ApiAiSdkClientRoute = ApiAiSdkClientRouteImport.update({ + id: '/api/ai-sdk-client', + path: '/api/ai-sdk-client', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAiSdkServerRoute = ApiAiSdkServerRouteImport.update({ + id: '/api/ai-sdk-server', + path: '/api/ai-sdk-server', + getParentRoute: () => rootRouteImport, +} as any) const ApiAnthropicAuthWireRoute = ApiAnthropicAuthWireRouteImport.update({ id: '/api/anthropic-auth-wire', path: '/api/anthropic-auth-wire', @@ -1207,22 +1238,16 @@ const ApiWrapFetchWireRoute = ApiWrapFetchWireRouteImport.update({ path: '/api/wrap-fetch-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiAiSdkResumeSplatRoute = ApiAiSdkResumeSplatRouteImport.update({ + id: '/api/ai-sdk-resume/$', + path: '/api/ai-sdk-resume/$', + getParentRoute: () => rootRouteImport, +} as any) const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ id: '/stream', path: '/stream', getParentRoute: () => ApiAudioRoute, } as any) -const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ - id: '/api/harness-protocol/$', - path: '/api/harness-protocol/$', - getParentRoute: () => rootRouteImport, -} as any) -const ApiHarnessSecretAnswerSplatRoute = - ApiHarnessSecretAnswerSplatRouteImport.update({ - id: '/api/harness-secret-answer/$', - path: '/api/harness-secret-answer/$', - getParentRoute: () => rootRouteImport, - } as any) const ApiHarnessHandoffSplatRoute = ApiHarnessHandoffSplatRouteImport.update({ id: '/api/harness-handoff/$', path: '/api/harness-handoff/$', @@ -1234,6 +1259,17 @@ const ApiHarnessObservabilitySplatRoute = path: '/api/harness-observability/$', getParentRoute: () => rootRouteImport, } as any) +const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ + id: '/api/harness-protocol/$', + path: '/api/harness-protocol/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHarnessSecretAnswerSplatRoute = + ApiHarnessSecretAnswerSplatRouteImport.update({ + id: '/api/harness-secret-answer/$', + path: '/api/harness-secret-answer/$', + getParentRoute: () => rootRouteImport, + } as any) const ApiHarnessWaitsSplatRoute = ApiHarnessWaitsSplatRouteImport.update({ id: '/api/harness-waits/$', path: '/api/harness-waits/$', @@ -1263,6 +1299,9 @@ const ApiVideoStreamRoute = ApiVideoStreamRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1303,6 +1342,8 @@ export interface FileRoutesByFullPath { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1451,11 +1492,12 @@ export interface FileRoutesByFullPath { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1465,6 +1507,9 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1505,6 +1550,8 @@ export interface FileRoutesByTo { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1653,11 +1700,12 @@ export interface FileRoutesByTo { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1668,6 +1716,9 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1708,6 +1759,8 @@ export interface FileRoutesById { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1856,11 +1909,12 @@ export interface FileRoutesById { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1872,6 +1926,9 @@ export interface FileRouteTypes { fullPaths: | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -1912,6 +1969,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2060,11 +2119,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider/' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2074,6 +2134,9 @@ export interface FileRouteTypes { to: | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -2114,6 +2177,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2262,11 +2327,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2276,6 +2342,9 @@ export interface FileRouteTypes { | '__root__' | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -2316,6 +2385,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2464,11 +2535,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider/' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2479,6 +2551,9 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ActivityTestRoute: typeof ActivityTestRoute + AiSdkClientRoute: typeof AiSdkClientRoute + AiSdkResumeRoute: typeof AiSdkResumeRoute + AiSdkServerRoute: typeof AiSdkServerRoute ByokRoute: typeof ByokRoute CallOwnershipRoute: typeof CallOwnershipRoute ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute @@ -2519,6 +2594,8 @@ export interface RootRouteChildren { ProviderFeatureRoute: typeof ProviderFeatureRoute ApiActivityTestRoute: typeof ApiActivityTestRoute ApiAdapterInputModalitiesRoute: typeof ApiAdapterInputModalitiesRoute + ApiAiSdkClientRoute: typeof ApiAiSdkClientRoute + ApiAiSdkServerRoute: typeof ApiAiSdkServerRoute ApiAnthropicAuthWireRoute: typeof ApiAnthropicAuthWireRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute ApiAnthropicComputerToolsetWireRoute: typeof ApiAnthropicComputerToolsetWireRoute @@ -2667,10 +2744,11 @@ export interface RootRouteChildren { ApiWorldRoute: typeof ApiWorldRoute ApiWrapFetchWireRoute: typeof ApiWrapFetchWireRoute ProviderIndexRoute: typeof ProviderIndexRoute - ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute - ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute + ApiAiSdkResumeSplatRoute: typeof ApiAiSdkResumeSplatRoute ApiHarnessHandoffSplatRoute: typeof ApiHarnessHandoffSplatRoute ApiHarnessObservabilitySplatRoute: typeof ApiHarnessObservabilitySplatRoute + ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute + ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute ApiHarnessWaitsSplatRoute: typeof ApiHarnessWaitsSplatRoute } @@ -2690,6 +2768,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ActivityTestRouteImport parentRoute: typeof rootRouteImport } + '/ai-sdk-client': { + id: '/ai-sdk-client' + path: '/ai-sdk-client' + fullPath: '/ai-sdk-client' + preLoaderRoute: typeof AiSdkClientRouteImport + parentRoute: typeof rootRouteImport + } + '/ai-sdk-resume': { + id: '/ai-sdk-resume' + path: '/ai-sdk-resume' + fullPath: '/ai-sdk-resume' + preLoaderRoute: typeof AiSdkResumeRouteImport + parentRoute: typeof rootRouteImport + } + '/ai-sdk-server': { + id: '/ai-sdk-server' + path: '/ai-sdk-server' + fullPath: '/ai-sdk-server' + preLoaderRoute: typeof AiSdkServerRouteImport + parentRoute: typeof rootRouteImport + } '/byok': { id: '/byok' path: '/byok' @@ -2977,6 +3076,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAdapterInputModalitiesRouteImport parentRoute: typeof rootRouteImport } + '/api/ai-sdk-client': { + id: '/api/ai-sdk-client' + path: '/api/ai-sdk-client' + fullPath: '/api/ai-sdk-client' + preLoaderRoute: typeof ApiAiSdkClientRouteImport + parentRoute: typeof rootRouteImport + } + '/api/ai-sdk-server': { + id: '/api/ai-sdk-server' + path: '/api/ai-sdk-server' + fullPath: '/api/ai-sdk-server' + preLoaderRoute: typeof ApiAiSdkServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/anthropic-auth-wire': { id: '/api/anthropic-auth-wire' path: '/api/anthropic-auth-wire' @@ -4006,6 +4119,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWrapFetchWireRouteImport parentRoute: typeof rootRouteImport } + '/api/ai-sdk-resume/$': { + id: '/api/ai-sdk-resume/$' + path: '/api/ai-sdk-resume/$' + fullPath: '/api/ai-sdk-resume/$' + preLoaderRoute: typeof ApiAiSdkResumeSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/audio/stream': { id: '/api/audio/stream' path: '/stream' @@ -4013,20 +4133,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAudioStreamRouteImport parentRoute: typeof ApiAudioRoute } - '/api/harness-protocol/$': { - id: '/api/harness-protocol/$' - path: '/api/harness-protocol/$' - fullPath: '/api/harness-protocol/$' - preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport - parentRoute: typeof rootRouteImport - } - '/api/harness-secret-answer/$': { - id: '/api/harness-secret-answer/$' - path: '/api/harness-secret-answer/$' - fullPath: '/api/harness-secret-answer/$' - preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport - parentRoute: typeof rootRouteImport - } '/api/harness-handoff/$': { id: '/api/harness-handoff/$' path: '/api/harness-handoff/$' @@ -4041,6 +4147,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHarnessObservabilitySplatRouteImport parentRoute: typeof rootRouteImport } + '/api/harness-protocol/$': { + id: '/api/harness-protocol/$' + path: '/api/harness-protocol/$' + fullPath: '/api/harness-protocol/$' + preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/harness-secret-answer/$': { + id: '/api/harness-secret-answer/$' + path: '/api/harness-secret-answer/$' + fullPath: '/api/harness-secret-answer/$' + preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/harness-waits/$': { id: '/api/harness-waits/$' path: '/api/harness-waits/$' @@ -4140,6 +4260,9 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ActivityTestRoute: ActivityTestRoute, + AiSdkClientRoute: AiSdkClientRoute, + AiSdkResumeRoute: AiSdkResumeRoute, + AiSdkServerRoute: AiSdkServerRoute, ByokRoute: ByokRoute, CallOwnershipRoute: CallOwnershipRoute, ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, @@ -4180,6 +4303,8 @@ const rootRouteChildren: RootRouteChildren = { ProviderFeatureRoute: ProviderFeatureRoute, ApiActivityTestRoute: ApiActivityTestRoute, ApiAdapterInputModalitiesRoute: ApiAdapterInputModalitiesRoute, + ApiAiSdkClientRoute: ApiAiSdkClientRoute, + ApiAiSdkServerRoute: ApiAiSdkServerRoute, ApiAnthropicAuthWireRoute: ApiAnthropicAuthWireRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, ApiAnthropicComputerToolsetWireRoute: ApiAnthropicComputerToolsetWireRoute, @@ -4334,10 +4459,11 @@ const rootRouteChildren: RootRouteChildren = { ApiWorldRoute: ApiWorldRoute, ApiWrapFetchWireRoute: ApiWrapFetchWireRoute, ProviderIndexRoute: ProviderIndexRoute, - ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, - ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, + ApiAiSdkResumeSplatRoute: ApiAiSdkResumeSplatRoute, ApiHarnessHandoffSplatRoute: ApiHarnessHandoffSplatRoute, ApiHarnessObservabilitySplatRoute: ApiHarnessObservabilitySplatRoute, + ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, + ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, ApiHarnessWaitsSplatRoute: ApiHarnessWaitsSplatRoute, } export const routeTree = rootRouteImport diff --git a/testing/e2e/src/routes/ai-sdk-client.tsx b/testing/e2e/src/routes/ai-sdk-client.tsx new file mode 100644 index 0000000000..652b7d8f75 --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-client.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useChat } from '@ai-sdk/react' +import { + DefaultChatTransport, + isToolUIPart, + lastAssistantMessageIsCompleteWithApprovalResponses, +} from 'ai' + +// AI SDK `useChat` talks to the TanStack `chat()` route in +// `api.ai-sdk-client.ts`. `?prompt=` sets the first user message. +const transport = new DefaultChatTransport({ api: '/api/ai-sdk-client' }) + +export const Route = createFileRoute('/ai-sdk-client')({ + validateSearch: (search: Record) => ({ + prompt: typeof search.prompt === 'string' ? search.prompt : undefined, + }), + component: AISDKClientPage, +}) + +function AISDKClientPage() { + const { prompt = 'Weather in Oslo?' } = Route.useSearch() + const { messages, sendMessage, addToolApprovalResponse } = useChat({ + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + }) + + const parts = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.parts) + const text = parts.flatMap((part) => + part.type === 'text' ? [part.text] : [], + ) + const toolParts = parts.filter(isToolUIPart) + const toolOutputs = toolParts.flatMap((part) => + part.state === 'output-available' ? [String(part.output)] : [], + ) + + useEffect(() => { + void sendMessage({ text: prompt }) + // Send once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {toolParts.map((part) => + part.state === 'approval-requested' ? ( + + ) : null, + )} +
{toolOutputs.join(',')}
+
{text.join('')}
+
+ ) +} diff --git a/testing/e2e/src/routes/ai-sdk-resume.tsx b/testing/e2e/src/routes/ai-sdk-resume.tsx new file mode 100644 index 0000000000..d4588c44d4 --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-resume.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' + +// AI SDK `useChat({ resume: true })` against the resumable TanStack route in +// `api.ai-sdk-resume.$.ts`. `?chat=` is the chat id, so a reload rejoins it. +const transport = new DefaultChatTransport({ api: '/api/ai-sdk-resume/chat' }) + +export const Route = createFileRoute('/ai-sdk-resume')({ + validateSearch: (search: Record) => ({ + chat: typeof search.chat === 'string' ? search.chat : 'ai-sdk-resume', + }), + component: AISDKResumePage, +}) + +function AISDKResumePage() { + const { chat } = Route.useSearch() + const { messages, sendMessage } = useChat({ + id: chat, + transport, + resume: true, + }) + + const assistant = messages.filter((message) => message.role === 'assistant') + + return ( +
+ +
{assistant.length}
+
+ {assistant + .flatMap((message) => message.parts) + .flatMap((part) => (part.type === 'text' ? [part.text] : [])) + .join('')} +
+
+ ) +} diff --git a/testing/e2e/src/routes/ai-sdk-server.tsx b/testing/e2e/src/routes/ai-sdk-server.tsx new file mode 100644 index 0000000000..09890f4c2d --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-server.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { toolDefinition } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +// The route's tool, so its approval is a bound `tool-approval` interrupt. +const addToCart = toolDefinition({ + name: 'addToCart', + description: 'Add a guitar to the cart', + inputSchema: z.object({ guitarId: z.string(), quantity: z.number() }), + needsApproval: true, +}) + +const tools = [addToCart] as const + +// TanStack `useChat` talks to the AI SDK `streamText` route in +// `api.ai-sdk-server.ts`. `?prompt=` sets the first user message. +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/ai-sdk-server' }), + { tools }, +) + +export const Route = createFileRoute('/ai-sdk-server')({ + validateSearch: (search: Record) => ({ + prompt: typeof search.prompt === 'string' ? search.prompt : undefined, + }), + component: AISDKServerPage, +}) + +function AISDKServerPage() { + const { prompt = '[chat] recommend a guitar' } = Route.useSearch() + const { messages, sendMessage, interrupts } = useChat({ + threadId: 'ai-sdk-server', + connection, + tools, + }) + + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join('') + const toolResults = messages + .flatMap((message) => message.parts) + .flatMap((part) => (part.type === 'tool-result' ? [part.content] : [])) + const activities = messages.flatMap((message) => + message.role === 'activity' + ? message.parts.flatMap((part) => + part.type === 'activity' ? [JSON.stringify(part.content)] : [], + ) + : [], + ) + + useEffect(() => { + void sendMessage(prompt) + // Send once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + )} + {activities.map((content, index) => ( +
+ {content} +
+ ))} +
{toolResults.join(',')}
+
{assistantText}
+
+ ) +} diff --git a/testing/e2e/src/routes/api.ai-sdk-client.ts b/testing/e2e/src/routes/api.ai-sdk-client.ts new file mode 100644 index 0000000000..4bf994d80d --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-client.ts @@ -0,0 +1,75 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, toolDefinition } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' +import { z } from 'zod' +import type { FakeResponseStep } from '@tanstack/ai/testing' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(({ city }) => `sunny in ${city}`) + +const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, +}).server(({ path }) => `deleted ${path}`) + +const answerWithToolResult: FakeResponseStep = ({ request }) => ({ + text: `Tool said: ${String(request.messages.at(-1)?.content)}`, +}) + +// A TanStack `chat()` route that AI SDK `useChat` reads. No network: +// `fakeText` calls a server tool, then answers with the tool result. A user +// message that starts with "Delete" calls `deleteFile`, which needs approval; +// the approval answer comes back as a second request that resumes the run. +export const Route = createFileRoute('/api/ai-sdk-client')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await chatParamsFromAISDKRequest(request) + const lastUser = params.messages + .filter((m) => m.role === 'user') + .at(-1) + ?.parts.flatMap((p) => (p.type === 'text' ? [p.content] : [])) + .join('') + + // A fake per request: pick the script from the request. + const fake = fakeText() + fake.setResponses( + params.resume + ? [answerWithToolResult] + : lastUser?.startsWith('Delete') + ? [ + { + toolCalls: [ + { name: 'deleteFile', input: { path: 'a.txt' } }, + ], + }, + ] + : [ + { + toolCalls: [ + { name: 'getWeather', input: { city: 'Oslo' } }, + ], + }, + answerWithToolResult, + ], + ) + + return createUIMessageStreamResponse({ + stream: toUIMessageStream( + chat({ adapter: fake, tools: [getWeather, deleteFile], ...params }), + ), + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/api.ai-sdk-resume.$.ts b/testing/e2e/src/routes/api.ai-sdk-resume.$.ts new file mode 100644 index 0000000000..13404fc88a --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-resume.$.ts @@ -0,0 +1,45 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, memoryStream } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { + chatParamsFromAISDKRequest, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '@tanstack/ai-ui-message-stream' + +const RESUME_REPLY = + 'River stones rest under clear cold water while the slow current carries leaves toward the quiet village mill.' + +// The active run of each chat. The e2e app is one process. +const activeRuns = new Map() + +// A resumable TanStack `chat()` route for AI SDK `useChat({ resume: true })`. +// - `POST /api/ai-sdk-resume/chat`: a slow `fakeText` reply, written to a +// `memoryStream` log, so a page reload lands mid-reply. +// - `GET /api/ai-sdk-resume/chat//stream`: replays the chat's run. +export const Route = createFileRoute('/api/ai-sdk-resume/$')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await chatParamsFromAISDKRequest(request) + const runId = crypto.randomUUID() + activeRuns.set(params.threadId, runId) + + const fake = fakeText({ tokensPerSecond: 10 }) + fake.setResponses([{ text: RESUME_REPLY }]) + + return toResumableUIMessageStreamResponse( + chat({ adapter: fake, runId, ...params }), + memoryStream({ runId }), + ) + }, + GET: ({ request }) => { + const chatId = new URL(request.url).pathname.split('/').at(-2) ?? '' + const runId = activeRuns.get(chatId) + return resumeUIMessageStreamResponse( + runId === undefined ? null : memoryStream({ runId }), + ) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/api.ai-sdk-server.ts b/testing/e2e/src/routes/api.ai-sdk-server.ts new file mode 100644 index 0000000000..42e67a43d6 --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-server.ts @@ -0,0 +1,71 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createOpenAI } from '@ai-sdk/openai' +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + streamText, + tool, + validateUIMessages, +} from 'ai' +import { z } from 'zod' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' + +// An AI SDK `streamText` route that TanStack `useChat` reads through +// `fromChatTransport`. The model is aimock's OpenAI chat completions API. +// The user message `[ai-sdk-data]` skips the model: the route writes the same +// `data-search` part twice, with one id. +export const Route = createFileRoute('/api/ai-sdk-server')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: { messages: unknown } = await request.json() + const messages = await validateUIMessages({ messages: body.messages }) + + if (JSON.stringify(messages).includes('[ai-sdk-data]')) { + return createUIMessageStreamResponse({ + stream: createUIMessageStream({ + execute({ writer }) { + const id = 'search-1' + writer.write({ + type: 'data-search', + id, + data: { status: 'running' }, + }) + writer.write({ + type: 'data-search', + id, + data: { status: 'done', hits: 3 }, + }) + writer.write({ type: 'text-start', id: 't' }) + writer.write({ type: 'text-delta', id: 't', delta: 'Found 3.' }) + writer.write({ type: 'text-end', id: 't' }) + }, + }), + }) + } + + const openai = createOpenAI({ + apiKey: 'sk-e2e-test-dummy-key', + baseURL: `${LLMOCK_DEFAULT_BASE}/v1`, + }) + return streamText({ + model: openai.chat('gpt-4o'), + messages: await convertToModelMessages(messages), + tools: { + addToCart: tool({ + description: 'Add a guitar to the cart', + inputSchema: z.object({ + guitarId: z.string(), + quantity: z.number(), + }), + needsApproval: true, + execute: ({ guitarId }) => ({ added: guitarId }), + }), + }, + }).toUIMessageStreamResponse() + }, + }, + }, +}) diff --git a/testing/e2e/tests/ui-message-stream.spec.ts b/testing/e2e/tests/ui-message-stream.spec.ts new file mode 100644 index 0000000000..5502e19305 --- /dev/null +++ b/testing/e2e/tests/ui-message-stream.spec.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' + +/** + * `@tanstack/ai-ui-message-stream` connects TanStack AI and the AI SDK UI + * message stream in both directions. + */ +test.describe('AI SDK UI message stream', () => { + test('AI SDK useChat reads a TanStack chat() route', async ({ page }) => { + await page.goto('/ai-sdk-client') + + await expect(page.getByTestId('tool-outputs')).toHaveText('sunny in Oslo') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'Tool said: sunny in Oslo', + ) + }) + + test('AI SDK useChat approves a TanStack server tool', async ({ page }) => { + await page.goto( + `/ai-sdk-client?${new URLSearchParams({ prompt: 'Delete a.txt' })}`, + ) + + await page.getByTestId('approve-button').click() + + await expect(page.getByTestId('tool-outputs')).toHaveText('deleted a.txt') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'Tool said: deleted a.txt', + ) + }) + + test('AI SDK useChat resumes a TanStack run after a reload', async ({ + page, + }) => { + const reply = + 'River stones rest under clear cold water while the slow current carries leaves toward the quiet village mill.' + // On mount, `resume: true` asks for the active run. Wait for that 204 + // before sending, so the send does not race it. + const resumeCheck = page.waitForResponse((r) => r.url().endsWith('/stream')) + await page.goto(`/ai-sdk-resume?chat=resume-${randomUUID()}`) + await resumeCheck + await page.getByTestId('send-button').click() + await expect(page.getByTestId('assistant-text')).toContainText('River') + + await page.reload() + + await expect(page.getByTestId('assistant-text')).toHaveText(reply) + await expect(page.getByTestId('assistant-count')).toHaveText('1') + }) + + test('TanStack useChat reads an AI SDK streamText route', async ({ + page, + }) => { + await page.goto('/ai-sdk-server') + + await expect(page.getByTestId('assistant-text')).toContainText( + 'Fender Stratocaster', + ) + }) + + test('TanStack useChat approves an AI SDK tool', async ({ page }) => { + await page.goto( + `/ai-sdk-server?${new URLSearchParams({ prompt: '[ai-sdk-approval] buy the stratocaster' })}`, + ) + + await page.getByTestId('approve-button').click() + + await expect(page.getByTestId('tool-results')).toHaveText('{"added":"1"}') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'The Stratocaster is in your cart.', + ) + }) + + test('TanStack useChat shows an AI SDK data part as one activity', async ({ + page, + }) => { + await page.goto( + `/ai-sdk-server?${new URLSearchParams({ prompt: '[ai-sdk-data]' })}`, + ) + + await expect(page.getByTestId('assistant-text')).toHaveText('Found 3.') + await expect(page.getByTestId('activity-content')).toHaveText([ + '{"status":"done","hits":3}', + ]) + }) +}) From 8dd6fe4d3445518a54d162c1fe7324fe5ae41b12 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:35:08 +0000 Subject: [PATCH 2/7] ci: apply automated fixes --- packages/ai-ui-message-stream/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md index 3db7787123..6640413a6b 100644 --- a/packages/ai-ui-message-stream/README.md +++ b/packages/ai-ui-message-stream/README.md @@ -76,15 +76,15 @@ export function Chat() { ## API -| Export | Direction | -| --- | --- | -| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | -| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | -| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | -| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | -| `fromChatTransport(transport, { threadId, tools })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | -| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | -| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | +| Export | Direction | +| -------------------------------------------------------- | ---------------------------------------------------------------------- | +| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | +| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | +| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | +| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | +| `fromChatTransport(transport, { threadId, tools })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | +| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | +| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | Text, reasoning, tools, tool approvals, activity, subagents and stream resume cross in both directions where the other side has them. Shared state goes from a TanStack server to an AI SDK client only, because the AI SDK client does not send state. From 23eb9236afb0b400e1c788c3ba220931fad3fe56 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:35:07 +1100 Subject: [PATCH 3/7] docs(ai-ui-message-stream): add a compatibility table and mark the bridge as a stop gap Add a reference page that lists, per feature, event and message part, what crosses the bridge as is, what is mapped or grouped, and what is dropped. Say on every page that the bridge is a stop gap while porting. --- .changeset/ai-ui-message-stream.md | 2 +- docs/config.json | 5 + .../ai-sdk-client-tanstack-server.md | 5 + docs/migration/ai-sdk-compatibility.md | 131 ++++++++++++++++++ docs/migration/migration-from-vercel-ai.md | 3 +- .../tanstack-client-ai-sdk-server.md | 7 +- packages/ai-ui-message-stream/README.md | 2 + packages/ai-ui-message-stream/package.json | 2 +- skills/tanstack-ai-migration/SKILL.md | 3 +- 9 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 docs/migration/ai-sdk-compatibility.md diff --git a/.changeset/ai-ui-message-stream.md b/.changeset/ai-ui-message-stream.md index d2bf2a9104..9d7074aced 100644 --- a/.changeset/ai-ui-message-stream.md +++ b/.changeset/ai-ui-message-stream.md @@ -2,7 +2,7 @@ '@tanstack/ai-ui-message-stream': minor --- -Add `@tanstack/ai-ui-message-stream`, which connects TanStack AI to the Vercel AI SDK UI message stream. +Add `@tanstack/ai-ui-message-stream`, a stop gap for porting an app from the Vercel AI SDK one side at a time. It connects TanStack AI to the AI SDK UI message stream. - Serve AI SDK `useChat` from `chat()` with `chatParamsFromAISDKRequest` and `toUIMessageStream`. Use `toResumableUIMessageStreamResponse` and `resumeUIMessageStreamResponse` for `useChat({ resume: true })`. - Point TanStack `useChat` at an AI SDK route with `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })`. diff --git a/docs/config.json b/docs/config.json index b59d33f743..c88cfa36e3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1968,6 +1968,11 @@ "to": "migration/tanstack-client-ai-sdk-server", "addedAt": "2026-10-23" }, + { + "label": "AI SDK bridge compatibility", + "to": "migration/ai-sdk-compatibility", + "addedAt": "2026-10-23" + }, { "label": "AG-UI Client Compliance", "to": "migration/ag-ui-compliance", diff --git a/docs/migration/ai-sdk-client-tanstack-server.md b/docs/migration/ai-sdk-client-tanstack-server.md index 838e3d6ada..cc20922e14 100644 --- a/docs/migration/ai-sdk-client-tanstack-server.md +++ b/docs/migration/ai-sdk-client-tanstack-server.md @@ -19,6 +19,9 @@ Serve the AI SDK `useChat` hook from TanStack AI `chat()`. Read the request with Use this page to move the server first. To move the client first, see [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). +> [!WARNING] +> This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the server works, port the client to TanStack `useChat` with the [migration guide](./migration-from-vercel-ai), then remove the bridge. + ## 1. Install the packages @@ -252,6 +255,8 @@ export async function GET(request: Request) { - The `reason` text of an approval answer. - A `stop()` on the client stops the reading, not the run. To stop the run, pass an `abortController` to `chat()`. +For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility). + ## Done Your AI SDK `useChat` UI streams replies, tools, approvals, activity and subagents from `chat()`, and it continues a reply after a reload. diff --git a/docs/migration/ai-sdk-compatibility.md b/docs/migration/ai-sdk-compatibility.md new file mode 100644 index 0000000000..fa675b9bc3 --- /dev/null +++ b/docs/migration/ai-sdk-compatibility.md @@ -0,0 +1,131 @@ +--- +title: AI SDK bridge compatibility +id: ai-sdk-compatibility +order: 5 +description: "What crosses between TanStack AI and the Vercel AI SDK UI message stream in @tanstack/ai-ui-message-stream: what works as is, what is mapped to another shape, and what is dropped." +keywords: + - tanstack ai + - vercel ai sdk + - ui message stream + - ag-ui + - compatibility + - migration +--- + +Use these tables to see what `@tanstack/ai-ui-message-stream` does with each feature, event and message part. The guides are [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server) and [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). + +> [!WARNING] +> The bridge is a stop gap while you port one side of an app. The rows marked **Dropped** and **One way** are why. Finish the port with the [migration guide](./migration-from-vercel-ai), then remove the bridge. + +Each row has one of these results: + +| Result | Meaning | +| --- | --- | +| **Same** | It arrives with the same meaning and data. | +| **Mapped** | It arrives as a different shape that carries the same data. | +| **Grouped** | It is joined with other items into one item, or is split from one. | +| **One way** | It crosses in one direction only. | +| **Dropped** | It does not cross. | + +## Features + +| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | +| --- | --- | --- | +| Text | Same | Same | +| Reasoning | Same. The signature goes back to the provider on the next turn. | Same. The provider metadata goes back to the route unchanged. | +| Server tools | Same | Same | +| Client tools | Same | Same. Pass the tools to `useChat`. | +| Tool approvals | Mapped to `tool-approval-request` and back to `resume` | Mapped to a `tool-approval` interrupt. Pass the tools to `fromChatTransport` too. | +| Tool errors and denials | Same | Same | +| Files and images from the user | Mapped to content parts | Mapped to file parts | +| Activity | Mapped to a `data-` part | Mapped from a `data-` part with an `id` | +| Subagents | Grouped into one `data-subagent` card per child | Dropped (the AI SDK has no subagents) | +| Shared state | One way, to a `data-state` part | Dropped (the AI SDK has no state) | +| Custom events | Mapped to a transient `data-` chunk | Mapped from a transient `data-` chunk | +| Stream resume | Same, with a durability log | Same. A reload needs `threadId` on `fromChatTransport`. | +| Sources and generated files | Dropped (`chat()` sends none) | Dropped | +| Finish reason | Mapped | Mapped. A `tool-calls` finish is dropped (see below). | +| Token usage | Dropped | Dropped (the AI SDK stream has none) | + +## AI SDK client, TanStack server + +### Reply: `chat()` events to AI SDK chunks + +| AG-UI event from `chat()` | AI SDK chunk | Result | +| --- | --- | --- | +| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | Same. The first one also sends `start` with the message id. | +| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | Same | +| `REASONING_ENCRYPTED_VALUE` | `providerMetadata.tanstack.signature` on `reasoning-end` | Mapped. Redacted thinking adds `redacted: true`. | +| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | Same | +| `TOOL_CALL_END` | `tool-input-available` with the parsed input | Mapped | +| `TOOL_CALL_RESULT` | `tool-output-available` | Same. JSON output is parsed. | +| `TOOL_CALL_RESULT` with an error | `tool-output-error` | Mapped | +| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | Mapped | +| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, then `message-metadata` with `tanstack.runId` | Mapped | +| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | Mapped | +| `RUN_FINISHED` | `finish` with the finish reason | Mapped | +| `RUN_ERROR` | `error` | Mapped | +| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | Mapped. A delta is applied, then the full content is sent. | +| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | One way | +| `SUBAGENT_STARTED` / `FINISHED` / `ERROR`, and child text | `data-subagent` with id `subagentRunId` | Grouped. One card holds the name, status, text and error. | +| Child tool calls, reasoning and activity | | Dropped. The card shows the child text only. | +| Approval inside a subagent | `interruptIds` on the subagent card | Grouped | +| `CUSTOM` | Transient `data-` | Mapped. It reaches `onData`, not the message. | +| `RUN_STARTED`, `STEP_STARTED`, `STEP_FINISHED`, `MESSAGES_SNAPSHOT`, `RAW` | | Dropped | +| `*_CHUNK` shorthand events | | Dropped. `chat()` does not send them. | + +### Request: AI SDK message parts to `chat()` messages + +| AI SDK part | TanStack part | Result | +| --- | --- | --- | +| `text` | `text` | Same | +| `reasoning` | `thinking` with the signature | Same | +| `file` | `image`, `audio`, `video` or `document`, by media type | Mapped. A data URL becomes a data source. | +| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | Grouped. The result follows its call. | +| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | Grouped | +| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | Mapped | +| The `reason` of an approval answer | | Dropped. `resume` has no field for it. | +| `system` message | | Dropped. `chat()` reads system prompts from `systemPrompts`. | +| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | Dropped | +| Message `metadata` | | Dropped. Only `tanstack.runId` is read, as `parentRunId`. | + +## TanStack client, AI SDK server + +### Reply: AI SDK chunks to TanStack events + +| AI SDK chunk | AG-UI event | Result | +| --- | --- | --- | +| `start` | `RUN_STARTED` | Mapped. The message id is the AG-UI message id. | +| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | Grouped. All text parts go into one assistant message. | +| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | Same. Each reasoning part gets its own id. | +| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | Mapped. It goes back to the route unchanged. | +| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | Same | +| `tool-input-available` | `TOOL_CALL_END` | Same. It sends `TOOL_CALL_START` and `TOOL_CALL_ARGS` first if they did not come. | +| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | Mapped | +| `tool-output-available` | `TOOL_CALL_RESULT` | Same | +| `tool-output-available` with `preliminary: true` | | Dropped. Only the final output is sent. | +| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | Mapped | +| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | +| `finish` | `RUN_FINISHED` with the finish reason | Mapped | +| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | +| `error` | `RUN_ERROR` | Mapped | +| `abort` | `RUN_FINISHED` with a `cancelled` outcome | Mapped | +| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | Mapped. The same id replaces the activity. | +| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | Mapped | +| `start-step`, `finish-step`, `reset-step` | | Dropped | +| `source-url`, `source-document`, `file`, `reasoning-file`, `custom` | | Dropped | +| `message-metadata`, `messageMetadata` on `start` and `finish` | | Dropped | +| `tool-approval-response` | | Dropped | + +### Request: TanStack messages to AI SDK message parts + +| TanStack part | AI SDK part | Result | +| --- | --- | --- | +| `text` | `text` | Same | +| `thinking` | `reasoning` with the stored provider metadata | Same | +| `image`, `audio`, `video`, `document` | `file` | Mapped. A data source becomes a data URL. | +| `tool-call` plus its `tool-result` | One `dynamic-tool` part | Grouped | +| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded` | Mapped | +| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | Mapped | +| `activity` messages | | Dropped | +| `structured-output`, `subagent`, `ui-resource` | | Dropped | diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index c11edf4f7e..92e105c9cc 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -25,10 +25,11 @@ Move a Vercel AI SDK app (`ai` and `@ai-sdk/*`) to TanStack AI by swapping the p Do the steps below in order. The "Before" samples are AI SDK v5 and v6 code. Where v4 is different, the page says so. For every other API, see [Vercel AI SDK: full API mapping](./migration-from-vercel-ai-advanced). -To move one side at a time, connect the two SDKs over the AI SDK UI message stream: +To move one side at a time, connect the two SDKs over the AI SDK UI message stream. This bridge is a stop gap while you port. Remove it when both sides use TanStack AI: - Server first: [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). - Client first: [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). +- What crosses and what does not: [AI SDK bridge compatibility](./ai-sdk-compatibility). ## Port a chat app diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md index 5fcd3b3dbb..f819dab13b 100644 --- a/docs/migration/tanstack-client-ai-sdk-server.md +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -17,7 +17,10 @@ keywords: Point TanStack AI `useChat` at a Vercel AI SDK route. Wrap an AI SDK `ChatTransport` with `fromChatTransport`, and pass it as the `connection`. Your `streamText` route does not change. -Use this page to move the client first, for example to use `useChat` in a framework that the AI SDK does not cover. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). +Use this page to move the client first. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). + +> [!WARNING] +> This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the client works, port the route to `chat()` with the [migration guide](./migration-from-vercel-ai), then remove the bridge. ## 1. Install the packages @@ -183,6 +186,8 @@ Use one connection for each thread. The rejoin after a reload needs a transport - AG-UI shared state. The AI SDK has no state. - Sources, generated files and message metadata. +For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility). + ## Done TanStack `useChat` streams replies, runs tools, asks for approvals, and continues after a reload against your AI SDK route. diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md index 6640413a6b..13d3b49ea3 100644 --- a/packages/ai-ui-message-stream/README.md +++ b/packages/ai-ui-message-stream/README.md @@ -25,6 +25,8 @@ Connect TanStack AI to the Vercel AI SDK UI message stream, the wire format of t - Serve AI SDK `useChat` from TanStack AI `chat()`. - Call an AI SDK `streamText` route from TanStack AI `useChat`. +This package is a stop gap while you port an app from the AI SDK one side at a time. Some features change shape or are dropped on the way. See the [compatibility table](https://tanstack.com/ai/latest/docs/migration/ai-sdk-compatibility). When both sides use TanStack AI, remove it. + ## Installation ```bash diff --git a/packages/ai-ui-message-stream/package.json b/packages/ai-ui-message-stream/package.json index 9a7e092e1c..8f377b7261 100644 --- a/packages/ai-ui-message-stream/package.json +++ b/packages/ai-ui-message-stream/package.json @@ -1,7 +1,7 @@ { "name": "@tanstack/ai-ui-message-stream", "version": "0.0.1", - "description": "Connect TanStack AI to the Vercel AI SDK UI message stream: serve AI SDK useChat from chat(), or call an AI SDK route from TanStack useChat.", + "description": "A stop gap for porting from the Vercel AI SDK one side at a time: serve AI SDK useChat from chat(), or call an AI SDK route from TanStack useChat, over the AI SDK UI message stream.", "author": "", "license": "MIT", "repository": { diff --git a/skills/tanstack-ai-migration/SKILL.md b/skills/tanstack-ai-migration/SKILL.md index f107ef1519..839df34e61 100644 --- a/skills/tanstack-ai-migration/SKILL.md +++ b/skills/tanstack-ai-migration/SKILL.md @@ -37,7 +37,8 @@ Read the guide, then load the target API's own skill with code that type-checks in neither SDK. To port one side at a time, `@tanstack/ai-ui-message-stream` connects the two -SDKs over the AI SDK UI message stream: +SDKs over the AI SDK UI message stream. It is a stop gap while porting, not a +place to stay: finish the port of the other side, then remove it. - Server first: keep AI SDK `useChat` and serve it from `chat()` with `chat({ adapter, ...(await chatParamsFromAISDKRequest(request)) })` and From d1cd9d9aaf7a20e6e7c2b20664c39f152a7eb130 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:01 +1100 Subject: [PATCH 4/7] docs(ai-ui-message-stream): add status emojis to the compatibility table --- docs/migration/ai-sdk-compatibility.md | 182 ++++++++++++------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/docs/migration/ai-sdk-compatibility.md b/docs/migration/ai-sdk-compatibility.md index fa675b9bc3..8885812bdb 100644 --- a/docs/migration/ai-sdk-compatibility.md +++ b/docs/migration/ai-sdk-compatibility.md @@ -15,117 +15,117 @@ keywords: Use these tables to see what `@tanstack/ai-ui-message-stream` does with each feature, event and message part. The guides are [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server) and [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). > [!WARNING] -> The bridge is a stop gap while you port one side of an app. The rows marked **Dropped** and **One way** are why. Finish the port with the [migration guide](./migration-from-vercel-ai), then remove the bridge. +> The bridge is a stop gap while you port one side of an app. The rows marked āŒ **Dropped** and āž”ļø **One way** are why. Finish the port with the [migration guide](./migration-from-vercel-ai), then remove the bridge. Each row has one of these results: | Result | Meaning | | --- | --- | -| **Same** | It arrives with the same meaning and data. | -| **Mapped** | It arrives as a different shape that carries the same data. | -| **Grouped** | It is joined with other items into one item, or is split from one. | -| **One way** | It crosses in one direction only. | -| **Dropped** | It does not cross. | +| āœ… **Same** | It arrives with the same meaning and data. | +| šŸ”„ **Mapped** | It arrives as a different shape that carries the same data. | +| 🧩 **Grouped** | It is joined with other items into one item, or is split from one. | +| āž”ļø **One way** | It crosses in one direction only. | +| āŒ **Dropped** | It does not cross. | -## Features +## šŸ“‹ Features | Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | | --- | --- | --- | -| Text | Same | Same | -| Reasoning | Same. The signature goes back to the provider on the next turn. | Same. The provider metadata goes back to the route unchanged. | -| Server tools | Same | Same | -| Client tools | Same | Same. Pass the tools to `useChat`. | -| Tool approvals | Mapped to `tool-approval-request` and back to `resume` | Mapped to a `tool-approval` interrupt. Pass the tools to `fromChatTransport` too. | -| Tool errors and denials | Same | Same | -| Files and images from the user | Mapped to content parts | Mapped to file parts | -| Activity | Mapped to a `data-` part | Mapped from a `data-` part with an `id` | -| Subagents | Grouped into one `data-subagent` card per child | Dropped (the AI SDK has no subagents) | -| Shared state | One way, to a `data-state` part | Dropped (the AI SDK has no state) | -| Custom events | Mapped to a transient `data-` chunk | Mapped from a transient `data-` chunk | -| Stream resume | Same, with a durability log | Same. A reload needs `threadId` on `fromChatTransport`. | -| Sources and generated files | Dropped (`chat()` sends none) | Dropped | -| Finish reason | Mapped | Mapped. A `tool-calls` finish is dropped (see below). | -| Token usage | Dropped | Dropped (the AI SDK stream has none) | - -## AI SDK client, TanStack server - -### Reply: `chat()` events to AI SDK chunks +| Text | āœ… Same | āœ… Same | +| Reasoning | āœ… Same. The signature goes back to the provider on the next turn. | āœ… Same. The provider metadata goes back to the route unchanged. | +| Server tools | āœ… Same | āœ… Same | +| Client tools | āœ… Same | āœ… Same. Pass the tools to `useChat`. | +| Tool approvals | šŸ”„ Mapped to `tool-approval-request` and back to `resume` | šŸ”„ Mapped to a `tool-approval` interrupt. Pass the tools to `fromChatTransport` too. | +| Tool errors and denials | āœ… Same | āœ… Same | +| Files and images from the user | šŸ”„ Mapped to content parts | šŸ”„ Mapped to file parts | +| Activity | šŸ”„ Mapped to a `data-` part | šŸ”„ Mapped from a `data-` part with an `id` | +| Subagents | 🧩 Grouped into one `data-subagent` card per child | āŒ Dropped (the AI SDK has no subagents) | +| Shared state | āž”ļø One way, to a `data-state` part | āŒ Dropped (the AI SDK has no state) | +| Custom events | šŸ”„ Mapped to a transient `data-` chunk | šŸ”„ Mapped from a transient `data-` chunk | +| Stream resume | āœ… Same, with a durability log | āœ… Same. A reload needs `threadId` on `fromChatTransport`. | +| Sources and generated files | āŒ Dropped (`chat()` sends none) | āŒ Dropped | +| Finish reason | šŸ”„ Mapped | šŸ”„ Mapped. A `tool-calls` finish is dropped (see below). | +| Token usage | āŒ Dropped | āŒ Dropped (the AI SDK stream has none) | + +## ā–² AI SDK client, TanStack server + +### ā¬‡ļø Reply: `chat()` events to AI SDK chunks | AG-UI event from `chat()` | AI SDK chunk | Result | | --- | --- | --- | -| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | Same. The first one also sends `start` with the message id. | -| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | Same | -| `REASONING_ENCRYPTED_VALUE` | `providerMetadata.tanstack.signature` on `reasoning-end` | Mapped. Redacted thinking adds `redacted: true`. | -| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | Same | -| `TOOL_CALL_END` | `tool-input-available` with the parsed input | Mapped | -| `TOOL_CALL_RESULT` | `tool-output-available` | Same. JSON output is parsed. | -| `TOOL_CALL_RESULT` with an error | `tool-output-error` | Mapped | -| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | Mapped | -| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, then `message-metadata` with `tanstack.runId` | Mapped | -| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | Mapped | -| `RUN_FINISHED` | `finish` with the finish reason | Mapped | -| `RUN_ERROR` | `error` | Mapped | -| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | Mapped. A delta is applied, then the full content is sent. | -| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | One way | -| `SUBAGENT_STARTED` / `FINISHED` / `ERROR`, and child text | `data-subagent` with id `subagentRunId` | Grouped. One card holds the name, status, text and error. | -| Child tool calls, reasoning and activity | | Dropped. The card shows the child text only. | -| Approval inside a subagent | `interruptIds` on the subagent card | Grouped | -| `CUSTOM` | Transient `data-` | Mapped. It reaches `onData`, not the message. | -| `RUN_STARTED`, `STEP_STARTED`, `STEP_FINISHED`, `MESSAGES_SNAPSHOT`, `RAW` | | Dropped | -| `*_CHUNK` shorthand events | | Dropped. `chat()` does not send them. | - -### Request: AI SDK message parts to `chat()` messages +| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | āœ… Same. The first one also sends `start` with the message id. | +| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | āœ… Same | +| `REASONING_ENCRYPTED_VALUE` | `providerMetadata.tanstack.signature` on `reasoning-end` | šŸ”„ Mapped. Redacted thinking adds `redacted: true`. | +| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | āœ… Same | +| `TOOL_CALL_END` | `tool-input-available` with the parsed input | šŸ”„ Mapped | +| `TOOL_CALL_RESULT` | `tool-output-available` | āœ… Same. JSON output is parsed. | +| `TOOL_CALL_RESULT` with an error | `tool-output-error` | šŸ”„ Mapped | +| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | šŸ”„ Mapped | +| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, then `message-metadata` with `tanstack.runId` | šŸ”„ Mapped | +| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | šŸ”„ Mapped | +| `RUN_FINISHED` | `finish` with the finish reason | šŸ”„ Mapped | +| `RUN_ERROR` | `error` | šŸ”„ Mapped | +| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | šŸ”„ Mapped. A delta is applied, then the full content is sent. | +| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | āž”ļø One way | +| `SUBAGENT_STARTED` / `FINISHED` / `ERROR`, and child text | `data-subagent` with id `subagentRunId` | 🧩 Grouped. One card holds the name, status, text and error. | +| Child tool calls, reasoning and activity | | āŒ Dropped. The card shows the child text only. | +| Approval inside a subagent | `interruptIds` on the subagent card | 🧩 Grouped | +| `CUSTOM` | Transient `data-` | šŸ”„ Mapped. It reaches `onData`, not the message. | +| `RUN_STARTED`, `STEP_STARTED`, `STEP_FINISHED`, `MESSAGES_SNAPSHOT`, `RAW` | | āŒ Dropped | +| `*_CHUNK` shorthand events | | āŒ Dropped. `chat()` does not send them. | + +### ā¬†ļø Request: AI SDK message parts to `chat()` messages | AI SDK part | TanStack part | Result | | --- | --- | --- | -| `text` | `text` | Same | -| `reasoning` | `thinking` with the signature | Same | -| `file` | `image`, `audio`, `video` or `document`, by media type | Mapped. A data URL becomes a data source. | -| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | Grouped. The result follows its call. | -| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | Grouped | -| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | Mapped | -| The `reason` of an approval answer | | Dropped. `resume` has no field for it. | -| `system` message | | Dropped. `chat()` reads system prompts from `systemPrompts`. | -| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | Dropped | -| Message `metadata` | | Dropped. Only `tanstack.runId` is read, as `parentRunId`. | +| `text` | `text` | āœ… Same | +| `reasoning` | `thinking` with the signature | āœ… Same | +| `file` | `image`, `audio`, `video` or `document`, by media type | šŸ”„ Mapped. A data URL becomes a data source. | +| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | 🧩 Grouped. The result follows its call. | +| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | 🧩 Grouped | +| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | šŸ”„ Mapped | +| The `reason` of an approval answer | | āŒ Dropped. `resume` has no field for it. | +| `system` message | | āŒ Dropped. `chat()` reads system prompts from `systemPrompts`. | +| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | āŒ Dropped | +| Message `metadata` | | āŒ Dropped. Only `tanstack.runId` is read, as `parentRunId`. | -## TanStack client, AI SDK server +## 🌓 TanStack client, AI SDK server -### Reply: AI SDK chunks to TanStack events +### ā¬‡ļø Reply: AI SDK chunks to TanStack events | AI SDK chunk | AG-UI event | Result | | --- | --- | --- | -| `start` | `RUN_STARTED` | Mapped. The message id is the AG-UI message id. | -| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | Grouped. All text parts go into one assistant message. | -| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | Same. Each reasoning part gets its own id. | -| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | Mapped. It goes back to the route unchanged. | -| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | Same | -| `tool-input-available` | `TOOL_CALL_END` | Same. It sends `TOOL_CALL_START` and `TOOL_CALL_ARGS` first if they did not come. | -| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | Mapped | -| `tool-output-available` | `TOOL_CALL_RESULT` | Same | -| `tool-output-available` with `preliminary: true` | | Dropped. Only the final output is sent. | -| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | Mapped | -| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | -| `finish` | `RUN_FINISHED` with the finish reason | Mapped | -| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | -| `error` | `RUN_ERROR` | Mapped | -| `abort` | `RUN_FINISHED` with a `cancelled` outcome | Mapped | -| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | Mapped. The same id replaces the activity. | -| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | Mapped | -| `start-step`, `finish-step`, `reset-step` | | Dropped | -| `source-url`, `source-document`, `file`, `reasoning-file`, `custom` | | Dropped | -| `message-metadata`, `messageMetadata` on `start` and `finish` | | Dropped | -| `tool-approval-response` | | Dropped | - -### Request: TanStack messages to AI SDK message parts +| `start` | `RUN_STARTED` | šŸ”„ Mapped. The message id is the AG-UI message id. | +| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | +| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | āœ… Same. Each reasoning part gets its own id. | +| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | šŸ”„ Mapped. It goes back to the route unchanged. | +| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | āœ… Same | +| `tool-input-available` | `TOOL_CALL_END` | āœ… Same. It sends `TOOL_CALL_START` and `TOOL_CALL_ARGS` first if they did not come. | +| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-output-available` | `TOOL_CALL_RESULT` | āœ… Same | +| `tool-output-available` with `preliminary: true` | | āŒ Dropped. Only the final output is sent. | +| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | šŸ”„ Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | +| `finish` | `RUN_FINISHED` with the finish reason | šŸ”„ Mapped | +| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | šŸ”„ Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | +| `error` | `RUN_ERROR` | šŸ”„ Mapped | +| `abort` | `RUN_FINISHED` with a `cancelled` outcome | šŸ”„ Mapped | +| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | šŸ”„ Mapped. The same id replaces the activity. | +| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | šŸ”„ Mapped | +| `start-step`, `finish-step`, `reset-step` | | āŒ Dropped | +| `source-url`, `source-document`, `file`, `reasoning-file`, `custom` | | āŒ Dropped | +| `message-metadata`, `messageMetadata` on `start` and `finish` | | āŒ Dropped | +| `tool-approval-response` | | āŒ Dropped | + +### ā¬†ļø Request: TanStack messages to AI SDK message parts | TanStack part | AI SDK part | Result | | --- | --- | --- | -| `text` | `text` | Same | -| `thinking` | `reasoning` with the stored provider metadata | Same | -| `image`, `audio`, `video`, `document` | `file` | Mapped. A data source becomes a data URL. | -| `tool-call` plus its `tool-result` | One `dynamic-tool` part | Grouped | -| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded` | Mapped | -| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | Mapped | -| `activity` messages | | Dropped | -| `structured-output`, `subagent`, `ui-resource` | | Dropped | +| `text` | `text` | āœ… Same | +| `thinking` | `reasoning` with the stored provider metadata | āœ… Same | +| `image`, `audio`, `video`, `document` | `file` | šŸ”„ Mapped. A data source becomes a data URL. | +| `tool-call` plus its `tool-result` | One `dynamic-tool` part | 🧩 Grouped | +| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded` | šŸ”„ Mapped | +| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | šŸ”„ Mapped | +| `activity` messages | | āŒ Dropped | +| `structured-output`, `subagent`, `ui-resource` | | āŒ Dropped | From fdbc83f47d7b870814ce976b0564bbf2fd249966 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:03:50 +1100 Subject: [PATCH 5/7] fix(ai-ui-message-stream): carry provider metadata, steps and provider-executed tools An audit of both wires found data the bridge dropped that providers need: - Tool-call provider metadata (Gemini thoughtSignature, OpenAI item ids) now round-trips through callProviderMetadata in both directions. - Provider-executed tools (web search) keep their flag, so neither client runs them, and their sources become source-url chunks. - Steps map to start-step / STEP_* events, and the history sent to an AI SDK route gets step-start parts, so a tool loop keeps its order. - Model, response ids and usage go into message metadata, and come back for OpenAI Responses replay. - The approval signature, descriptor and automatic approvals are kept. - System messages are no longer sent to an AI SDK route, and a finished structured output goes back as text. TOOL_CALL_END now waits for an approval or output of the same call, so late metadata never follows TOOL_CALL_END. The compatibility table is rewritten from the new mappings and aligned. --- docs/migration/ai-sdk-compatibility.md | 214 +++++---- .../tanstack-client-ai-sdk-server.md | 3 +- .../ai-ui-message-stream/src/data-parts.ts | 2 +- packages/ai-ui-message-stream/src/messages.ts | 179 +++++++- packages/ai-ui-message-stream/src/stream.ts | 329 ++++++++++++- .../tests/approvals.test.ts | 9 +- .../tests/fidelity-client.test.ts | 434 ++++++++++++++++++ .../tests/fidelity-server.test.ts | 292 ++++++++++++ .../tests/ui-message-stream.test.ts | 61 +++ 9 files changed, 1388 insertions(+), 135 deletions(-) create mode 100644 packages/ai-ui-message-stream/tests/fidelity-client.test.ts create mode 100644 packages/ai-ui-message-stream/tests/fidelity-server.test.ts diff --git a/docs/migration/ai-sdk-compatibility.md b/docs/migration/ai-sdk-compatibility.md index 8885812bdb..6964deeedc 100644 --- a/docs/migration/ai-sdk-compatibility.md +++ b/docs/migration/ai-sdk-compatibility.md @@ -2,7 +2,7 @@ title: AI SDK bridge compatibility id: ai-sdk-compatibility order: 5 -description: "What crosses between TanStack AI and the Vercel AI SDK UI message stream in @tanstack/ai-ui-message-stream: what works as is, what is mapped to another shape, and what is dropped." +description: 'What crosses between TanStack AI and the Vercel AI SDK UI message stream in @tanstack/ai-ui-message-stream: what works as is, what is mapped to another shape, and what is dropped.' keywords: - tanstack ai - vercel ai sdk @@ -19,113 +19,145 @@ Use these tables to see what `@tanstack/ai-ui-message-stream` does with each fea Each row has one of these results: -| Result | Meaning | -| --- | --- | -| āœ… **Same** | It arrives with the same meaning and data. | -| šŸ”„ **Mapped** | It arrives as a different shape that carries the same data. | +| Result | Meaning | +| -------------- | ------------------------------------------------------------------ | +| āœ… **Same** | It arrives with the same meaning and data. | +| šŸ”„ **Mapped** | It arrives as a different shape that carries the same data. | | 🧩 **Grouped** | It is joined with other items into one item, or is split from one. | -| āž”ļø **One way** | It crosses in one direction only. | -| āŒ **Dropped** | It does not cross. | +| āž”ļø **One way** | It crosses in one direction only. | +| āŒ **Dropped** | It does not cross. | ## šŸ“‹ Features -| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | -| --- | --- | --- | -| Text | āœ… Same | āœ… Same | -| Reasoning | āœ… Same. The signature goes back to the provider on the next turn. | āœ… Same. The provider metadata goes back to the route unchanged. | -| Server tools | āœ… Same | āœ… Same | -| Client tools | āœ… Same | āœ… Same. Pass the tools to `useChat`. | -| Tool approvals | šŸ”„ Mapped to `tool-approval-request` and back to `resume` | šŸ”„ Mapped to a `tool-approval` interrupt. Pass the tools to `fromChatTransport` too. | -| Tool errors and denials | āœ… Same | āœ… Same | -| Files and images from the user | šŸ”„ Mapped to content parts | šŸ”„ Mapped to file parts | -| Activity | šŸ”„ Mapped to a `data-` part | šŸ”„ Mapped from a `data-` part with an `id` | -| Subagents | 🧩 Grouped into one `data-subagent` card per child | āŒ Dropped (the AI SDK has no subagents) | -| Shared state | āž”ļø One way, to a `data-state` part | āŒ Dropped (the AI SDK has no state) | -| Custom events | šŸ”„ Mapped to a transient `data-` chunk | šŸ”„ Mapped from a transient `data-` chunk | -| Stream resume | āœ… Same, with a durability log | āœ… Same. A reload needs `threadId` on `fromChatTransport`. | -| Sources and generated files | āŒ Dropped (`chat()` sends none) | āŒ Dropped | -| Finish reason | šŸ”„ Mapped | šŸ”„ Mapped. A `tool-calls` finish is dropped (see below). | -| Token usage | āŒ Dropped | āŒ Dropped (the AI SDK stream has none) | +| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | +| -------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Text | āœ… Same | āœ… Same | +| Reasoning and its signature | āœ… Same | āœ… Same | +| Tool-call provider data (Gemini `thoughtSignature`, OpenAI item ids) | šŸ”„ Mapped to `callProviderMetadata` and back | šŸ”„ Mapped to tool-call `metadata` and back | +| Server tools | āœ… Same | āœ… Same | +| Provider-executed tools (web search) | šŸ”„ Mapped to `providerExecuted` | šŸ”„ Mapped to the TanStack provider-executed flag | +| Client tools | āœ… Same | āœ… Same. Pass the tools to `useChat`. | +| Tool approvals | šŸ”„ Mapped to `tool-approval-request` and back to `resume` | šŸ”„ Mapped to a `tool-approval` interrupt. The approval `signature` goes back to the route. | +| Tool errors and denials | āœ… Same | āœ… Same | +| Steps of a tool loop | šŸ”„ Mapped to `start-step` / `finish-step` | šŸ”„ Mapped to `STEP_*` events, and to `step-start` parts in the history | +| Files and images from the user | šŸ”„ Mapped to content parts | šŸ”„ Mapped to file parts | +| Web search sources | šŸ”„ Mapped to `source-url` chunks | šŸ”„ Mapped to custom events | +| Model, response ids and token usage | šŸ”„ Mapped to message metadata under `tanstack` | āŒ Dropped (the AI SDK stream has none) | +| Message metadata of the app | āœ… Same | šŸ”„ Mapped to `metadata.aiSdk` and back | +| Activity | šŸ”„ Mapped to a `data-` part | šŸ”„ Mapped from a `data-` part with an `id` | +| Subagents | 🧩 Grouped into one `data-subagent` card per child | āŒ Dropped (the AI SDK has no subagents) | +| Shared state | āž”ļø One way, to a `data-state` part | āŒ Dropped (the AI SDK has no state) | +| Structured output | āœ… Same (the JSON is text) | šŸ”„ Mapped to a text part in the history | +| Custom events | šŸ”„ Mapped to a transient `data-` chunk | šŸ”„ Mapped from a transient `data-` chunk | +| Stream resume | āœ… Same, with a durability log | āœ… Same. A reload needs `threadId` on `fromChatTransport`. | +| System messages | āŒ Dropped. The server owns its system prompts. | āŒ Dropped. `streamText` rejects them. | +| Generated files | āŒ Dropped (`chat()` sends none) | šŸ”„ Mapped to custom events | ## ā–² AI SDK client, TanStack server ### ā¬‡ļø Reply: `chat()` events to AI SDK chunks -| AG-UI event from `chat()` | AI SDK chunk | Result | -| --- | --- | --- | -| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | āœ… Same. The first one also sends `start` with the message id. | -| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | āœ… Same | -| `REASONING_ENCRYPTED_VALUE` | `providerMetadata.tanstack.signature` on `reasoning-end` | šŸ”„ Mapped. Redacted thinking adds `redacted: true`. | -| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | āœ… Same | -| `TOOL_CALL_END` | `tool-input-available` with the parsed input | šŸ”„ Mapped | -| `TOOL_CALL_RESULT` | `tool-output-available` | āœ… Same. JSON output is parsed. | -| `TOOL_CALL_RESULT` with an error | `tool-output-error` | šŸ”„ Mapped | -| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | šŸ”„ Mapped | -| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, then `message-metadata` with `tanstack.runId` | šŸ”„ Mapped | -| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | šŸ”„ Mapped | -| `RUN_FINISHED` | `finish` with the finish reason | šŸ”„ Mapped | -| `RUN_ERROR` | `error` | šŸ”„ Mapped | -| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | šŸ”„ Mapped. A delta is applied, then the full content is sent. | -| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | āž”ļø One way | -| `SUBAGENT_STARTED` / `FINISHED` / `ERROR`, and child text | `data-subagent` with id `subagentRunId` | 🧩 Grouped. One card holds the name, status, text and error. | -| Child tool calls, reasoning and activity | | āŒ Dropped. The card shows the child text only. | -| Approval inside a subagent | `interruptIds` on the subagent card | 🧩 Grouped | -| `CUSTOM` | Transient `data-` | šŸ”„ Mapped. It reaches `onData`, not the message. | -| `RUN_STARTED`, `STEP_STARTED`, `STEP_FINISHED`, `MESSAGES_SNAPSHOT`, `RAW` | | āŒ Dropped | -| `*_CHUNK` shorthand events | | āŒ Dropped. `chat()` does not send them. | +| AG-UI event from `chat()` | AI SDK chunk | Result | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `RUN_STARTED` | `start` with the message id of the first text or tool call | šŸ”„ Mapped | +| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | āœ… Same | +| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | āœ… Same | +| `REASONING_ENCRYPTED_VALUE`, message | `providerMetadata.tanstack.signature` on `reasoning-end` | šŸ”„ Mapped. Redacted thinking adds `redacted: true`. | +| `REASONING_ENCRYPTED_VALUE`, tool call | `thoughtSignature` in the tool call's `providerMetadata.tanstack` | šŸ”„ Mapped | +| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | āœ… Same | +| `TOOL_CALL_START.metadata` | `providerMetadata.tanstack` on the tool chunks (`callProviderMetadata`) | šŸ”„ Mapped | +| `TOOL_CALL_END` | `tool-input-available` with the parsed input | šŸ”„ Mapped | +| Provider-executed call | `providerExecuted: true`, and a `tool-output-available` with its sources | šŸ”„ Mapped | +| Sources of a provider-executed call | `source-url` | šŸ”„ Mapped | +| `TOOL_CALL_RESULT` | `tool-output-available` | āœ… Same. JSON output is parsed. | +| `TOOL_CALL_RESULT` with an error | `tool-output-error` | šŸ”„ Mapped | +| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | šŸ”„ Mapped | +| A new model call in the run | `start-step`, and `finish-step` at its end | šŸ”„ Mapped | +| `RUN_FINISHED` metadata (model, `responseId`, `responseItems`, `source`) | `message-metadata` under `tanstack` | šŸ”„ Mapped | +| `RUN_FINISHED.usage` | `message-metadata` as `tanstack.tokenUsage`, one entry per model call | šŸ”„ Mapped | +| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, and `tanstack.runId` in the message metadata | šŸ”„ Mapped | +| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | šŸ”„ Mapped | +| `RUN_FINISHED` | `finish` with the finish reason | šŸ”„ Mapped | +| `RUN_ERROR` | `error` | šŸ”„ Mapped | +| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | šŸ”„ Mapped. A delta is applied, then the full content is sent. | +| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | āž”ļø One way | +| `SUBAGENT_*` events and child text | `data-subagent` with id `subagentRunId` | 🧩 Grouped. One card holds the name, status, text and error. | +| Child tool calls, reasoning and activity | | āŒ Dropped. The card shows the child text only. | +| Approval inside a subagent | `interruptIds` on the subagent card | 🧩 Grouped | +| `CUSTOM` | Transient `data-` | šŸ”„ Mapped. It reaches `onData`, not the message. | +| `MESSAGES_SNAPSHOT`, `RAW` | | āŒ Dropped | +| `STEP_*` and `*_CHUNK` shorthand events | | āŒ Dropped. `chat()` does not send them. | ### ā¬†ļø Request: AI SDK message parts to `chat()` messages -| AI SDK part | TanStack part | Result | -| --- | --- | --- | -| `text` | `text` | āœ… Same | -| `reasoning` | `thinking` with the signature | āœ… Same | -| `file` | `image`, `audio`, `video` or `document`, by media type | šŸ”„ Mapped. A data URL becomes a data source. | -| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | 🧩 Grouped. The result follows its call. | -| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | 🧩 Grouped | -| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | šŸ”„ Mapped | -| The `reason` of an approval answer | | āŒ Dropped. `resume` has no field for it. | -| `system` message | | āŒ Dropped. `chat()` reads system prompts from `systemPrompts`. | -| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | āŒ Dropped | -| Message `metadata` | | āŒ Dropped. Only `tanstack.runId` is read, as `parentRunId`. | +| AI SDK part | TanStack part | Result | +| ----------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- | +| `text` | `text` | āœ… Same | +| `reasoning` | `thinking` with the signature | āœ… Same | +| `file` | `image`, `audio`, `video` or `document`, by media type | šŸ”„ Mapped. A data URL becomes a data source. | +| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | 🧩 Grouped. The result follows its call. | +| `callProviderMetadata.tanstack` | `tool-call` `metadata` | šŸ”„ Mapped | +| Tool part with `providerExecuted` | `tool-call` with no `tool-result` | šŸ”„ Mapped. `chat()` does not run it again. | +| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | 🧩 Grouped | +| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | šŸ”„ Mapped | +| The `reason` of an approval answer | | āŒ Dropped. `resume` has no field for it. | +| Message `metadata` | `metadata` | āœ… Same. With more than one step, `tanstack.responseItems` is removed. | +| `system` message | | āŒ Dropped. Pass `systemPrompts` to `chat()` on the server. | +| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | āŒ Dropped | ## 🌓 TanStack client, AI SDK server ### ā¬‡ļø Reply: AI SDK chunks to TanStack events -| AI SDK chunk | AG-UI event | Result | -| --- | --- | --- | -| `start` | `RUN_STARTED` | šŸ”„ Mapped. The message id is the AG-UI message id. | -| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | -| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | āœ… Same. Each reasoning part gets its own id. | -| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | šŸ”„ Mapped. It goes back to the route unchanged. | -| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | āœ… Same | -| `tool-input-available` | `TOOL_CALL_END` | āœ… Same. It sends `TOOL_CALL_START` and `TOOL_CALL_ARGS` first if they did not come. | -| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | šŸ”„ Mapped | -| `tool-output-available` | `TOOL_CALL_RESULT` | āœ… Same | -| `tool-output-available` with `preliminary: true` | | āŒ Dropped. Only the final output is sent. | -| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | šŸ”„ Mapped | -| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | šŸ”„ Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | -| `finish` | `RUN_FINISHED` with the finish reason | šŸ”„ Mapped | -| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | šŸ”„ Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | -| `error` | `RUN_ERROR` | šŸ”„ Mapped | -| `abort` | `RUN_FINISHED` with a `cancelled` outcome | šŸ”„ Mapped | -| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | šŸ”„ Mapped. The same id replaces the activity. | -| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | šŸ”„ Mapped | -| `start-step`, `finish-step`, `reset-step` | | āŒ Dropped | -| `source-url`, `source-document`, `file`, `reasoning-file`, `custom` | | āŒ Dropped | -| `message-metadata`, `messageMetadata` on `start` and `finish` | | āŒ Dropped | -| `tool-approval-response` | | āŒ Dropped | +| AI SDK chunk | AG-UI event | Result | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `start` | `RUN_STARTED` | šŸ”„ Mapped. The message id is the AG-UI message id. | +| `messageMetadata` on `start`, `message-metadata`, `finish` | `metadata.aiSdk` on the message, at the end of the run | šŸ”„ Mapped | +| `start-step` / `finish-step` | `STEP_STARTED` / `STEP_FINISHED` | āœ… Same | +| `reset-step` | | āŒ Dropped | +| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | +| `providerMetadata` on text chunks | | āŒ Dropped. TanStack keeps no metadata per text part. | +| `text-end.citations` | `CUSTOM` named `citations` | šŸ”„ Mapped | +| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | āœ… Same. Each reasoning part gets its own id. | +| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | šŸ”„ Mapped. It goes back to the route unchanged. | +| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | āœ… Same | +| `providerMetadata` on tool input chunks | tool-call `metadata.aiSdk.callProviderMetadata` | šŸ”„ Mapped | +| `providerExecuted` | tool-call `metadata.providerExecuted` | šŸ”„ Mapped. The client does not run the call. | +| `inputEncoding: 'text'` | One `TOOL_CALL_ARGS` with the final input | šŸ”„ Mapped | +| `tool-input-available` | `TOOL_CALL_END`, after any approval or output for the same call | āœ… Same | +| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-output-available` | `TOOL_CALL_RESULT` | āœ… Same | +| `providerMetadata` on tool output chunks | tool-call `metadata.aiSdk.resultProviderMetadata` | šŸ”„ Mapped when it comes right after the call. Later, it is dropped. | +| `tool-output-available` with `preliminary: true` | | āŒ Dropped. Only the final output is sent. | +| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | šŸ”„ Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | +| `signature`, `approvalDescriptor`, `inputSchemaInput`, `reason` of an approval request | tool-call `metadata.aiSdk.approval` | šŸ”„ Mapped | +| `tool-approval-request` with `isAutomatic` | | āŒ Dropped. The route already decided, and the output or denial follows. | +| `tool-approval-response` | | āŒ Dropped | +| `finish` | `RUN_FINISHED` with the finish reason | šŸ”„ Mapped | +| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | šŸ”„ Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | +| `error` | `RUN_ERROR` | šŸ”„ Mapped | +| `abort` | `RUN_FINISHED` with a `cancelled` outcome | šŸ”„ Mapped | +| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | šŸ”„ Mapped. The same id replaces the activity. | +| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | šŸ”„ Mapped | +| `source-url`, `source-document`, `file`, `reasoning-file` | `CUSTOM` with the same name | šŸ”„ Mapped | +| `custom` | | āŒ Dropped | ### ā¬†ļø Request: TanStack messages to AI SDK message parts -| TanStack part | AI SDK part | Result | -| --- | --- | --- | -| `text` | `text` | āœ… Same | -| `thinking` | `reasoning` with the stored provider metadata | āœ… Same | -| `image`, `audio`, `video`, `document` | `file` | šŸ”„ Mapped. A data source becomes a data URL. | -| `tool-call` plus its `tool-result` | One `dynamic-tool` part | 🧩 Grouped | -| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded` | šŸ”„ Mapped | -| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | šŸ”„ Mapped | -| `activity` messages | | āŒ Dropped | -| `structured-output`, `subagent`, `ui-resource` | | āŒ Dropped | +| TanStack part | AI SDK part | Result | +| ------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `text` | `text` | āœ… Same | +| `thinking` | `reasoning` with the stored provider metadata | āœ… Same | +| `image`, `audio`, `video`, `document` | `file` | šŸ”„ Mapped. A data source becomes a data URL. | +| `tool-call` plus its `tool-result` | One `dynamic-tool` part | 🧩 Grouped | +| tool-call `metadata.aiSdk` | `callProviderMetadata`, `resultProviderMetadata` | šŸ”„ Mapped | +| Provider-executed `tool-call` | `dynamic-tool` with `providerExecuted: true` | šŸ”„ Mapped | +| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded`, and the stored `signature` | šŸ”„ Mapped | +| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | šŸ”„ Mapped | +| A tool result followed by more of the reply | `step-start` before the next step | šŸ”„ Mapped | +| `structured-output`, complete | `text` with the JSON | šŸ”„ Mapped | +| Message `metadata.aiSdk` | Message `metadata` | šŸ”„ Mapped | +| `system` messages | | āŒ Dropped. `streamText` rejects them unless `allowSystemInMessages` is set. | +| `activity` messages | | āŒ Dropped | +| `subagent`, `ui-resource` | | āŒ Dropped | diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md index f819dab13b..897b926524 100644 --- a/docs/migration/tanstack-client-ai-sdk-server.md +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -184,7 +184,8 @@ Use one connection for each thread. The rejoin after a reload needs a transport ## What does not go through - AG-UI shared state. The AI SDK has no state. -- Sources, generated files and message metadata. +- Provider metadata on text parts, for example OpenAI item ids on text. TanStack keeps no metadata per text part. +- Sources, citations and generated files arrive as custom events, not as message parts. For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility). diff --git a/packages/ai-ui-message-stream/src/data-parts.ts b/packages/ai-ui-message-stream/src/data-parts.ts index 8d2ec2c244..eecc8c2b80 100644 --- a/packages/ai-ui-message-stream/src/data-parts.ts +++ b/packages/ai-ui-message-stream/src/data-parts.ts @@ -20,7 +20,7 @@ export interface SubagentData extends Omit< /** An AI SDK `data-` chunk. */ export type DataChunk = Extract -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/ai-ui-message-stream/src/messages.ts b/packages/ai-ui-message-stream/src/messages.ts index c518bd7613..8bd651f94f 100644 --- a/packages/ai-ui-message-stream/src/messages.ts +++ b/packages/ai-ui-message-stream/src/messages.ts @@ -1,8 +1,10 @@ +import { isProviderExecutedToolCall } from '@tanstack/ai' import { getToolName, isToolUIPart } from 'ai' import type { ContentPart, ContentPartSource, MessagePart, + ProviderExecutedToolMetadata, ToolCallPart, ToolResultPart, UIMessage, @@ -71,7 +73,15 @@ function toolParts( : part.state === 'output-error' || part.state === 'output-denied' ? 'error' : 'input-complete', + // `toUIMessageStream` sends the TanStack tool-call metadata (Gemini + // `thoughtSignature`, `providerExecuted`) as `providerMetadata.tanstack`. + ...(part.callProviderMetadata?.tanstack !== undefined && { + metadata: part.callProviderMetadata.tanstack, + }), } + // The provider ran the call and replays its result from the metadata, so + // it has no tool result. + if (isProviderExecutedToolCall(call)) return [call] // A result goes right after its call, so a text after the tool replays // after the result. if (part.state === 'output-available') { @@ -135,19 +145,41 @@ function fromAISDKPart(part: AISDKPart): Array { * Convert AI SDK `UIMessage`s (the `messages` body that AI SDK `useChat` * posts) into TanStack `UIMessage`s for `chat({ messages })`. * - * Text, files, reasoning and tool calls with their results are kept. A client - * tool's output and a denied tool call go back to the model as tool results. + * Text, files, reasoning, tool calls with their results and metadata, and the + * message metadata are kept. A client tool's output and a denied tool call + * go back to the model as tool results. A provider-executed call has none. * A call that waits for approval, or has an answer, stays pending: * `chatParamsFromAISDKRequest` sends the answer to `chat()` as `resume`. */ export function fromAISDKMessages( messages: ReadonlyArray, ): Array { - return messages.map((message) => ({ - id: message.id, - role: message.role, - parts: message.parts.flatMap(fromAISDKPart), - })) + return messages.map((message) => { + const metadata = messageMetadata(message) + return { + id: message.id, + role: message.role, + parts: message.parts.flatMap(fromAISDKPart), + ...(metadata !== undefined && { metadata }), + } + }) +} + +/** + * The message metadata, with `tanstack` from `toUIMessageStream`. The OpenAI + * Responses adapter replays `tanstack.responseItems`, which name the items + * of one model call. A message of more steps drops them and replays as text. + */ +function messageMetadata( + message: AISDKUIMessage, +): Record | undefined { + if (!isRecord(message.metadata)) return undefined + const tanstack = message.metadata.tanstack + const steps = message.parts.filter((part) => part.type === 'step-start') + if (!isRecord(tanstack) || !('responseItems' in tanstack) || steps.length < 2) + return message.metadata + const { responseItems: _dropped, ...rest } = tanstack + return { ...message.metadata, tanstack: rest } } function sourceToUrl(source: ContentPartSource): string { @@ -161,25 +193,102 @@ function toolOutput(content: ToolResultPart['content']): unknown { return parseJson(content) ?? content } +/** The AI SDK approval fields that the TanStack tool-call part has no place for. */ +export type AISDKApprovalFields = Omit< + Extract['approval'], + 'id' | 'approved' | 'reason' +> + +/** + * `ToolCallPart.metadata` as `fromUIMessageStream` sets it. `providerExecuted` + * is the TanStack convention. `aiSdk` keeps the AI SDK tool part fields that + * `toAISDKMessages` sends back. + */ +export interface AISDKToolCallMetadata extends ProviderExecutedToolMetadata { + aiSdk?: { + callProviderMetadata?: ProviderMetadata + resultProviderMetadata?: ProviderMetadata + approval?: AISDKApprovalFields + } +} + +function isApprovalFields(value: unknown): value is AISDKApprovalFields { + return ( + isRecord(value) && + (value.signature === undefined || typeof value.signature === 'string') && + (value.requestReason === undefined || + typeof value.requestReason === 'string') && + (value.isAutomatic === undefined || typeof value.isAutomatic === 'boolean') + ) +} + +/** The AI SDK fields kept on a tool call, read back from its metadata. */ +function aiSdkToolFields(part: ToolCallPart) { + const aiSdk = + isRecord(part.metadata) && isRecord(part.metadata.aiSdk) + ? part.metadata.aiSdk + : {} + return { + call: isProviderMetadata(aiSdk.callProviderMetadata) + ? aiSdk.callProviderMetadata + : undefined, + result: isProviderMetadata(aiSdk.resultProviderMetadata) + ? aiSdk.resultProviderMetadata + : undefined, + approval: isApprovalFields(aiSdk.approval) ? aiSdk.approval : {}, + } +} + function toAISDKParts(message: UIMessage): Array { const results = new Map() + const providerExecuted = new Set() for (const part of message.parts) { if (part.type === 'tool-result') results.set(part.toolCallId, part) + if (part.type === 'tool-call' && isProviderExecutedToolCall(part)) + providerExecuted.add(part.id) } + // A tool result ends a step, as in `chat()`: the parts after it are the + // model's next answer. `convertToModelMessages` splits at `step-start`. + // A provider runs its own tools inside one step. + // ponytail: the TanStack message does not keep the AI SDK step bounds, so a + // server tool's result that streamed before a later call of the same step + // splits that step in two. Keep the step index on the parts if that matters. const parts: Array = [] + let stepEnded = false + function push(part: AISDKPart) { + if (stepEnded) parts.push({ type: 'step-start' }) + stepEnded = false + parts.push(part) + } + for (const part of message.parts) { // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- tool results join their calls; TanStack UI-only parts are dropped by default. switch (part.type) { case 'text': - parts.push({ type: 'text', text: part.content }) + push({ type: 'text', text: part.content }) + break + case 'structured-output': { + // Same rule as `chat()`: a complete answer goes back as its JSON text. + if (part.status !== 'complete') break + const text = + part.raw !== '' + ? part.raw + : part.data === undefined + ? '' + : JSON.stringify(part.data) + if (text !== '') push({ type: 'text', text }) + break + } + case 'tool-result': + if (!providerExecuted.has(part.toolCallId)) stepEnded = true break case 'thinking': { // `fromUIMessageStream` stores the AI SDK provider metadata as the // signature. Send it back as it came. const providerMetadata = part.signature === undefined ? undefined : parseJson(part.signature) - parts.push({ + push({ type: 'reasoning', text: part.content, ...(isProviderMetadata(providerMetadata) && { providerMetadata }), @@ -190,7 +299,7 @@ function toAISDKParts(message: UIMessage): Array { case 'audio': case 'video': case 'document': - parts.push({ + push({ type: 'file', mediaType: part.source.mimeType ?? @@ -201,21 +310,32 @@ function toAISDKParts(message: UIMessage): Array { case 'tool-call': { const input = part.input ?? parseJson(part.arguments) ?? {} const result = results.get(part.id) + const fields = aiSdkToolFields(part) const base = { type: 'dynamic-tool' as const, toolName: part.name, toolCallId: part.id, input, + ...(providerExecuted.has(part.id) && { providerExecuted: true }), + ...(fields.call && { callProviderMetadata: fields.call }), + } + const resultMetadata = fields.result && { + resultProviderMetadata: fields.result, } if (result?.outcome === 'denied' && part.approval) { - parts.push({ + push({ ...base, state: 'output-denied', - approval: { id: part.approval.id, approved: false }, + approval: { + ...fields.approval, + id: part.approval.id, + approved: false, + }, }) } else if (result?.state === 'error') { - parts.push({ + push({ ...base, + ...resultMetadata, state: 'output-error', errorText: result.error ?? @@ -224,41 +344,45 @@ function toAISDKParts(message: UIMessage): Array { : JSON.stringify(result.content)), }) } else if (result !== undefined) { - parts.push({ + push({ ...base, + ...resultMetadata, state: 'output-available', output: toolOutput(result.content), }) } else if (part.output !== undefined) { - parts.push({ + push({ ...base, + ...resultMetadata, state: 'output-available', output: part.output, }) } else if (part.approval?.approved !== undefined) { - // The AI SDK route runs or denies the tool from this answer. - parts.push({ + // The AI SDK route runs or denies the tool from this answer. A + // route with `experimental_toolApprovalSecret` checks the signature. + push({ ...base, state: 'approval-responded', approval: { + ...fields.approval, id: part.approval.id, approved: part.approval.approved, }, }) } else if (part.approval) { - parts.push({ + push({ ...base, state: 'approval-requested', - approval: { id: part.approval.id }, + approval: { ...fields.approval, id: part.approval.id }, }) } else { - parts.push({ ...base, state: 'input-available' }) + push({ ...base, state: 'input-available' }) } break } default: - // Results are joined with their calls above. Activity, subagent, - // structured-output and UI-resource parts are TanStack UI only. + // Results are joined with their calls above. Activity, subagent and + // UI-resource parts are TanStack UI only. break } } @@ -268,17 +392,24 @@ function toAISDKParts(message: UIMessage): Array { /** * Convert TanStack chat messages into AI SDK `UIMessage`s, the history an AI * SDK route reads with `convertToModelMessages`. Tool calls become - * `dynamic-tool` parts. + * `dynamic-tool` parts, and a `step-start` part follows each tool result. + * System messages are left out: `streamText` rejects them in `messages`. The + * AI SDK message metadata that `fromUIMessageStream` kept goes back as the + * message `metadata`. */ export function toAISDKMessages( messages: ReadonlyArray, ): Array { const out: Array = [] for (const message of messages) { - if (message.role === 'activity') continue + if (message.role === 'activity' || message.role === 'system') continue + const metadata = isRecord(message.metadata) + ? message.metadata.aiSdk + : undefined out.push({ id: message.id, role: message.role, + ...(metadata !== undefined && { metadata }), parts: toAISDKParts(message), }) } diff --git a/packages/ai-ui-message-stream/src/stream.ts b/packages/ai-ui-message-stream/src/stream.ts index 2fb6a97db7..e9f8d41b28 100644 --- a/packages/ai-ui-message-stream/src/stream.ts +++ b/packages/ai-ui-message-stream/src/stream.ts @@ -2,6 +2,7 @@ import { EventType, INTERRUPT_BINDING_VERSION, RUN_ACCEPTED_EVENT, + getProviderExecutedMetadata, hashSchemaInput, normalizeApprovalSchema, readInterruptBinding, @@ -10,11 +11,14 @@ import { import type { AnyClientTool, Interrupt, + RunFinishedEvent, StreamChunk, + ToolCallStartEvent, ToolResultOutcome, } from '@tanstack/ai' import type { FinishReason, ProviderMetadata, UIMessageChunk } from 'ai' -import { DataParts, dataChunkEvent, isDataChunk } from './data-parts' +import { DataParts, dataChunkEvent, isDataChunk, isRecord } from './data-parts' +import type { AISDKApprovalFields, AISDKToolCallMetadata } from './messages' const TO_AI_SDK_FINISH: Partial> = { stop: 'stop', @@ -66,11 +70,39 @@ function parseOutput(content: string | ReadonlyArray): unknown { return parseJson(content) ?? content } +/** A tool call that `toUIMessageStream` has seen. */ +interface ToolCallState { + name: string + args: string + /** `TOOL_CALL_START.metadata`, the TanStack `ToolCallPart.metadata`. */ + metadata?: ToolCallStartEvent['metadata'] +} + +/** + * The tool call's TanStack metadata as AI SDK call provider metadata, under + * `tanstack`. `fromAISDKMessages` reads it back. + */ +function toolCallFields(tool: ToolCallState): { + providerMetadata?: ProviderMetadata + providerExecuted?: boolean +} { + if (tool.metadata === undefined) return {} + return { + providerMetadata: { tanstack: tool.metadata }, + ...(getProviderExecutedMetadata(tool) && { providerExecuted: true }), + } +} + /** * Convert the AG-UI event stream of `chat()` into an AI SDK UI message * stream. Serve it with the AI SDK's `createUIMessageStreamResponse`, and the * AI SDK `useChat` reads it like a `streamText` route. * + * Each model call is one step. Its `RUN_FINISHED` metadata (model, response + * ids, finish reason) and usage go into the message metadata. A tool call's + * metadata goes into `providerMetadata.tanstack`. A provider-executed call + * gets a `providerExecuted` output and a `source-url` part for each source. + * * A tool approval becomes a `tool-approval-request` chunk. Read the answer * with `chatParamsFromAISDKRequest`. The message metadata gets the paused * TanStack run id as `{ tanstack: { runId } }`, so the answer resumes that @@ -86,9 +118,12 @@ export function toUIMessageStream( let started = false let errored = false let finishReason: FinishReason | undefined - const tools = new Map() + const tools = new Map() const signatures = new Map() const dataParts = new DataParts() + // Each model call of chat() is one AI SDK step. It ends at RUN_FINISHED. + let inStep = false + const usage: Array> = [] function* start(messageId?: string): Generator { if (started) return @@ -98,6 +133,18 @@ export function toUIMessageStream( : { type: 'start', messageId } } + function* startStep(): Generator { + if (inStep) return + inStep = true + yield { type: 'start-step' } + } + + function* finishStep(): Generator { + if (!inStep) return + inStep = false + yield { type: 'finish-step' } + } + for await (const event of stream) { // A subagent's events go to its card, not to this message. if ('subagentRunId' in event && event.subagentRunId !== undefined) { @@ -113,6 +160,7 @@ export function toUIMessageStream( switch (event.type) { case EventType.TEXT_MESSAGE_START: yield* start(event.messageId) + yield* startStep() yield { type: 'text-start', id: event.messageId } break case EventType.TEXT_MESSAGE_CONTENT: @@ -124,6 +172,7 @@ export function toUIMessageStream( case EventType.REASONING_MESSAGE_START: yield* start() + yield* startStep() yield { type: 'reasoning-start', id: event.messageId } break case EventType.REASONING_MESSAGE_CONTENT: @@ -140,6 +189,18 @@ export function toUIMessageStream( if (event.subtype === 'message') { signatures.set(event.entityId, event.encryptedValue) } + // A tool call's signature (Gemini `thoughtSignature`) joins its + // metadata, as the TanStack client stores it. It goes out on + // `tool-input-available`. + if (event.subtype === 'tool-call') { + const tool = tools.get(event.entityId) + if (tool) { + tool.metadata = { + ...tool.metadata, + thoughtSignature: event.encryptedValue, + } + } + } break case EventType.REASONING_MESSAGE_END: { const signature = signatures.get(event.messageId) @@ -162,15 +223,23 @@ export function toUIMessageStream( break } - case EventType.TOOL_CALL_START: + case EventType.TOOL_CALL_START: { yield* start(event.parentMessageId) - tools.set(event.toolCallId, { name: event.toolCallName, args: '' }) + yield* startStep() + const tool: ToolCallState = { + name: event.toolCallName, + args: '', + metadata: event.metadata, + } + tools.set(event.toolCallId, tool) yield { type: 'tool-input-start', toolCallId: event.toolCallId, toolName: event.toolCallName, + ...toolCallFields(tool), } break + } case EventType.TOOL_CALL_ARGS: { const tool = tools.get(event.toolCallId) if (tool) tool.args += event.delta @@ -192,6 +261,26 @@ export function toUIMessageStream( toolCallId: event.toolCallId, toolName: tool.name, input, + ...toolCallFields(tool), + } + // The provider ran this call and sends no TOOL_CALL_RESULT. The AI + // SDK client marks it done with a provider-executed output. + const executed = getProviderExecutedMetadata(tool) + if (executed) { + yield { + type: 'tool-output-available', + toolCallId: event.toolCallId, + output: executed.sources ?? null, + providerExecuted: true, + } + for (const [index, source] of (executed.sources ?? []).entries()) { + yield { + type: 'source-url', + sourceId: `${event.toolCallId}-source-${index}`, + url: source.url, + ...(source.title !== undefined && { title: source.title }), + } + } } break } @@ -241,6 +330,29 @@ export function toUIMessageStream( break case EventType.RUN_FINISHED: { + yield* finishStep() + // The model call's metadata (model, source, response ids) goes on + // the message, as the TanStack client keeps it. AI SDK merges each + // `message-metadata`. `tanstack.tokenUsage` lists each model call's + // AG-UI usage, under the TanStack key so it cannot clash with an + // app's own metadata. + if (event.usage !== undefined) usage.push(event.usage) + if (event.metadata !== undefined || usage.length > 0) { + const tanstack = event.metadata?.tanstack + yield* start() + yield { + type: 'message-metadata', + messageMetadata: { + ...event.metadata, + ...(usage.length > 0 && { + tanstack: { + ...(isRecord(tanstack) && tanstack), + tokenUsage: [...usage], + }, + }), + }, + } + } const reason = tanstackField(event.metadata, 'finishReason') if (typeof reason === 'string') finishReason = TO_AI_SDK_FINISH[reason] @@ -287,6 +399,7 @@ export function toUIMessageStream( if (errored) return yield* start() + yield* finishStep() yield finishReason === undefined ? { type: 'finish' } : { type: 'finish', finishReason } @@ -326,10 +439,18 @@ export interface FromUIMessageStreamOptions { * `resolveInterrupt(true)` or `client.resolveInterrupts(true)`. For another * tool, it is a generic interrupt: answer it with * `resolveInterrupt({ approved })`. The next request sends the tool part as - * `approval-responded`. Sources, files - * and step boundaries are skipped. A `data-` part with an id and object - * data becomes an activity of type ``. Other data chunks become CUSTOM - * events named `data-`. + * `approval-responded`. An approval the route made itself (`isAutomatic`) + * asks nothing. + * + * A tool call keeps its AI SDK provider metadata, approval signature and + * `providerExecuted` flag in its metadata, and the message keeps the AI SDK + * message metadata as `metadata.aiSdk`, so `toAISDKMessages` sends them back. + * A provider-executed call is not run on the client. Steps become + * STEP_STARTED / STEP_FINISHED. Sources, files and citations become CUSTOM + * events named `source-url`, `source-document`, `file`, `reasoning-file` and + * `citations`. A `data-` part with an id and object data becomes an + * activity of type ``. Other data chunks become CUSTOM events named + * `data-`. */ export async function* fromUIMessageStream( stream: ReadableStream, @@ -341,9 +462,42 @@ export async function* fromUIMessageStream( // part gets its own AG-UI message id. const reasoningIds = new Map() let reasoningCount = 0 - const tools = new Map() + let stepCount = 0 + // `text`: the input streams as raw text, not JSON, so only the final input + // becomes the arguments. + const tools = new Map< + string, + { hasArgs: boolean; text: boolean; ended?: boolean } + >() + // TOOL_CALL_END waits for the next chunk. An approval request or an output + // for the same call can still add metadata, and AG-UI allows no + // TOOL_CALL_ARGS after TOOL_CALL_END. + const pendingEnds = new Set() + const toolMetadata = new Map() const inputs = new Map() const approvals: Array = [] + // ponytail: shallow merge, where the AI SDK merges nested objects too. + let messageMetadata: unknown + + function mergeMessageMetadata(value: unknown) { + if (value === undefined) return + messageMetadata = + isRecord(messageMetadata) && isRecord(value) + ? { ...messageMetadata, ...value } + : value + } + + /** RUN_FINISHED metadata. The client merges it into the run's messages. */ + function runMetadata( + tanstack?: Record, + ): Record | undefined { + if (tanstack === undefined && messageMetadata === undefined) + return undefined + return { + ...(tanstack && { tanstack }), + ...(messageMetadata !== undefined && { aiSdk: messageMetadata }), + } + } function* finished( metadata?: Record, @@ -360,6 +514,76 @@ export async function* fromUIMessageStream( } } + /** + * Add AI SDK fields to a tool call's metadata. The TanStack client keeps + * the metadata of TOOL_CALL_START and TOOL_CALL_ARGS on the tool-call + * part, so a started call gets the update on an empty TOOL_CALL_ARGS. The + * client replaces the `aiSdk` key, so each update carries all of it. + */ + function* updateToolMetadata( + toolCallId: string, + providerExecuted: boolean | undefined, + aiSdk: NonNullable, + ): Generator { + const current = toolMetadata.get(toolCallId) + const next: AISDKToolCallMetadata = { + ...current, + ...(providerExecuted === true && { providerExecuted: true }), + aiSdk: { ...current?.aiSdk, ...aiSdk }, + } + toolMetadata.set(toolCallId, next) + // A started call takes the update until it ends. After the end the + // update stays off the stream. + const tool = tools.get(toolCallId) + if (tool && !tool.ended) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: '', + metadata: next, + } + } + } + + /** The call metadata of a tool chunk, when it has any. */ + function* callMetadata(chunk: { + toolCallId: string + providerExecuted?: boolean + providerMetadata?: ProviderMetadata + }): Generator { + if (chunk.providerExecuted !== true && chunk.providerMetadata === undefined) + return + yield* updateToolMetadata( + chunk.toolCallId, + chunk.providerExecuted, + chunk.providerMetadata === undefined + ? {} + : { callProviderMetadata: chunk.providerMetadata }, + ) + } + + /** The result metadata of a tool chunk, for a call started in this stream. */ + function* resultMetadata(chunk: { + toolCallId: string + providerMetadata?: ProviderMetadata + }): Generator { + if (chunk.providerMetadata === undefined || !tools.has(chunk.toolCallId)) + return + yield* updateToolMetadata(chunk.toolCallId, undefined, { + resultProviderMetadata: chunk.providerMetadata, + }) + } + + /** End one waiting call, or every waiting call except `keep`. */ + function* endTools(only?: string, keep?: string): Generator { + for (const toolCallId of only === undefined ? [...pendingEnds] : [only]) { + if (toolCallId === keep || !pendingEnds.delete(toolCallId)) continue + const tool = tools.get(toolCallId) + if (tool) tool.ended = true + yield { type: EventType.TOOL_CALL_END, toolCallId } + } + } + function* ensureToolStarted( toolCallId: string, toolName: string, @@ -367,13 +591,15 @@ export async function* fromUIMessageStream( ): Generator { let tool = tools.get(toolCallId) if (!tool) { - tool = { started: true, hasArgs: false } + tool = { hasArgs: false, text: false } tools.set(toolCallId, tool) + const metadata = toolMetadata.get(toolCallId) yield { type: EventType.TOOL_CALL_START, toolCallId, toolCallName: toolName, parentMessageId: messageId, + ...(metadata && { metadata }), } } if (!tool.hasArgs) { @@ -389,12 +615,37 @@ export async function* fromUIMessageStream( yield { type: EventType.RUN_STARTED, threadId, runId } for await (const chunk of stream) { - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, files, steps and approvals are skipped; default maps data parts. + yield* endTools( + undefined, + 'toolCallId' in chunk ? chunk.toolCallId : undefined, + ) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- reset-step and custom parts are skipped; default maps data parts. switch (chunk.type) { case 'start': if (chunk.messageId !== undefined) messageId = chunk.messageId + mergeMessageMetadata(chunk.messageMetadata) + break + case 'message-metadata': + mergeMessageMetadata(chunk.messageMetadata) + break + + case 'start-step': + yield { type: EventType.STEP_STARTED, stepName: `step-${++stepCount}` } + break + case 'finish-step': + yield { type: EventType.STEP_FINISHED, stepName: `step-${stepCount}` } break + // No TanStack part holds these. The app reads them in `onCustomEvent`. + case 'source-url': + case 'source-document': + case 'file': + case 'reasoning-file': { + const { type, ...value } = chunk + yield { type: EventType.CUSTOM, name: type, value } + break + } + // Every AI SDK text part belongs to the one assistant message. The // TanStack client starts a new text segment on each START. case 'text-start': @@ -413,6 +664,13 @@ export async function* fromUIMessageStream( break case 'text-end': yield { type: EventType.TEXT_MESSAGE_END, messageId } + if (chunk.citations !== undefined) { + yield { + type: EventType.CUSTOM, + name: 'citations', + value: chunk.citations, + } + } break case 'reasoning-start': { @@ -453,17 +711,25 @@ export async function* fromUIMessageStream( break } - case 'tool-input-start': - tools.set(chunk.toolCallId, { started: true, hasArgs: false }) + case 'tool-input-start': { + yield* callMetadata(chunk) + const metadata = toolMetadata.get(chunk.toolCallId) + tools.set(chunk.toolCallId, { + hasArgs: false, + text: chunk.inputEncoding === 'text', + }) yield { type: EventType.TOOL_CALL_START, toolCallId: chunk.toolCallId, toolCallName: chunk.toolName, parentMessageId: messageId, + ...(metadata && { metadata }), } break + } case 'tool-input-delta': { const tool = tools.get(chunk.toolCallId) + if (tool?.text) break if (tool) tool.hasArgs = true yield { type: EventType.TOOL_CALL_ARGS, @@ -477,10 +743,28 @@ export async function* fromUIMessageStream( toolName: chunk.toolName, input: chunk.input, }) + yield* callMetadata(chunk) yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) - yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId } + pendingEnds.add(chunk.toolCallId) break case 'tool-approval-request': { + // The route approved or denied the call itself. The output or the + // denial follows, so there is nothing to ask. + if (chunk.isAutomatic === true) break + // The tool part keeps the fields the AI SDK route checks on the answer. + const approval: AISDKApprovalFields = { + ...(chunk.approvalDescriptor != null && { + descriptor: chunk.approvalDescriptor, + }), + ...(Object.hasOwn(chunk, 'inputSchemaInput') && { + inputSchemaInput: chunk.inputSchemaInput, + }), + ...(chunk.reason != null && { requestReason: chunk.reason }), + ...(chunk.signature != null && { signature: chunk.signature }), + } + if (Object.keys(approval).length > 0) + yield* updateToolMetadata(chunk.toolCallId, undefined, { approval }) + yield* endTools(chunk.toolCallId) // `kind: 'approval'` shows the approval on the tool call. const call = inputs.get(chunk.toolCallId) const descriptor: Interrupt = { @@ -531,6 +815,7 @@ export async function* fromUIMessageStream( break } case 'tool-output-denied': + yield* endTools(chunk.toolCallId) yield toolError( chunk.toolCallId, 'The user denied this tool call', @@ -538,6 +823,7 @@ export async function* fromUIMessageStream( ) break case 'tool-input-error': + yield* callMetadata(chunk) yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId } yield toolError(chunk.toolCallId, chunk.errorText) @@ -545,6 +831,8 @@ export async function* fromUIMessageStream( case 'tool-output-available': // A preliminary output is a progress update. Only the last one is the result. if (chunk.preliminary === true) break + yield* resultMetadata(chunk) + yield* endTools(chunk.toolCallId) yield { type: EventType.TOOL_CALL_RESULT, messageId: `${chunk.toolCallId}-result`, @@ -557,6 +845,8 @@ export async function* fromUIMessageStream( } break case 'tool-output-error': + yield* resultMetadata(chunk) + yield* endTools(chunk.toolCallId) yield toolError(chunk.toolCallId, chunk.errorText) break @@ -564,23 +854,27 @@ export async function* fromUIMessageStream( ended = true yield { type: EventType.RUN_ERROR, message: chunk.errorText } break - case 'abort': + case 'abort': { ended = true + const metadata = runMetadata() yield { type: EventType.RUN_FINISHED, threadId, runId, outcome: { type: 'cancelled' }, + ...(metadata && { metadata }), } break + } case 'finish': { ended = true + mergeMessageMetadata(chunk.messageMetadata) const finishReason = chunk.finishReason === undefined ? undefined : FROM_AI_SDK_FINISH[chunk.finishReason] yield* finished( - finishReason == null ? undefined : { tanstack: { finishReason } }, + runMetadata(finishReason == null ? undefined : { finishReason }), ) break } @@ -592,7 +886,8 @@ export async function* fromUIMessageStream( if (ended) return } - yield* finished() + yield* endTools() + yield* finished(runMetadata()) } function toolError( diff --git a/packages/ai-ui-message-stream/tests/approvals.test.ts b/packages/ai-ui-message-stream/tests/approvals.test.ts index 025a3bf67e..71aecca01b 100644 --- a/packages/ai-ui-message-stream/tests/approvals.test.ts +++ b/packages/ai-ui-message-stream/tests/approvals.test.ts @@ -74,7 +74,9 @@ describe('TanStack server, AI SDK client: approvals', () => { })) first = message // The message keeps the paused TanStack run id. - expect(first?.metadata).toEqual({ tanstack: { runId: runs[0]?.runId } }) + expect(first?.metadata).toMatchObject({ + tanstack: { runId: runs[0]?.runId }, + }) const requested = first?.parts.find((p) => p.type === 'tool-deleteFile') expect(requested).toMatchObject({ state: 'approval-requested', @@ -135,11 +137,13 @@ describe('TanStack server, AI SDK client: approvals', () => { const { execute, second } = await run(true) expect(execute).toHaveBeenCalledTimes(1) expect(second.parts).toMatchObject([ + { type: 'step-start' }, { type: 'tool-deleteFile', state: 'output-available', output: { deleted: true }, }, + { type: 'step-start' }, { type: 'text', text: 'Result: {"deleted":true}' }, ]) }) @@ -148,11 +152,13 @@ describe('TanStack server, AI SDK client: approvals', () => { const { execute, second } = await run(false) expect(execute).not.toHaveBeenCalled() expect(second.parts).toMatchObject([ + { type: 'step-start' }, { type: 'tool-deleteFile', state: 'output-denied', approval: { approved: false }, }, + { type: 'step-start' }, { type: 'text', text: expect.stringContaining('Result:') }, ]) }) @@ -212,6 +218,7 @@ describe('TanStack server, AI SDK client: approvals', () => { })) message = m expect(message?.parts).toMatchObject([ + { type: 'step-start' }, { type: 'tool-researcher', toolCallId: 'call-1' }, { type: 'data-subagent', diff --git a/packages/ai-ui-message-stream/tests/fidelity-client.test.ts b/packages/ai-ui-message-stream/tests/fidelity-client.test.ts new file mode 100644 index 0000000000..f5c777c971 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/fidelity-client.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { convertToModelMessages } from 'ai' +import { z } from 'zod' +import { fromChatTransport, fromUIMessageStream, toAISDKMessages } from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +/** A transport that answers request N with `turns[N]`. */ +function fakeTransport(turns: Array>) { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve(chunkStream(turns[requests.length - 1] ?? [])) + }, + reconnectToStream: () => Promise.resolve(null), + } + return { requests, transport } +} + +async function collect(list: Array) { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(list), { + threadId: 't', + runId: 'r', + })) + out.push(event) + return out +} + +describe('AI SDK server, TanStack client: fidelity', () => { + it('keeps provider metadata, provider-executed calls, steps and message metadata', async () => { + const { requests, transport } = fakeTransport([ + [ + { type: 'start', messageId: 'a-1', messageMetadata: { model: 'm-1' } }, + { type: 'start-step' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Searching.' }, + { type: 'text-end', id: '0' }, + { + type: 'tool-input-start', + toolCallId: 'ws-1', + toolName: 'web_search', + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-input-available', + toolCallId: 'ws-1', + toolName: 'web_search', + input: { query: 'tanstack' }, + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-output-available', + toolCallId: 'ws-1', + output: { results: [] }, + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_result' } }, + }, + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'ping', + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + // Google sends the signature with the finished call. + providerMetadata: { google: { thoughtSignature: 'ts-1' } }, + }, + { type: 'finish-step' }, + { + type: 'finish', + finishReason: 'tool-calls', + messageMetadata: { usage: 5 }, + }, + ], + [ + { type: 'start', messageId: 'a-1' }, + { type: 'start-step' }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: 'Done.' }, + { type: 'text-end', id: '1' }, + { type: 'finish-step' }, + { type: 'finish', finishReason: 'stop' }, + ], + [{ type: 'start' }, { type: 'finish', finishReason: 'stop' }], + ]) + const ping = vi.fn(() => ({ pong: true })) + const webSearch = vi.fn(() => ({ results: ['wrong'] })) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ + toolDefinition({ + name: 'ping', + description: 'Ping', + inputSchema: z.object({}), + }).client(ping), + toolDefinition({ + name: 'web_search', + description: 'Search', + inputSchema: z.object({ query: z.string() }), + }).client(webSearch), + ], + }) + + await client.sendMessage('Search, then ping') + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Done.'), + ) + expect(ping).toHaveBeenCalledTimes(1) + // The provider ran the search. + expect(webSearch).not.toHaveBeenCalled() + const assistant = client.getMessages().find((m) => m.role === 'assistant') + expect(assistant?.metadata).toMatchObject({ + aiSdk: { model: 'm-1', usage: 5 }, + }) + expect( + assistant?.parts.find((p) => p.type === 'tool-call' && p.id === 'ws-1'), + ).toMatchObject({ metadata: { providerExecuted: true } }) + + // Request 2 sends the client tool output back. + expect(requests[1]!.find((m) => m.role === 'assistant')?.parts).toEqual([ + { type: 'text', text: 'Searching.' }, + { + type: 'dynamic-tool', + toolName: 'web_search', + toolCallId: 'ws-1', + input: { query: 'tanstack' }, + providerExecuted: true, + callProviderMetadata: { openai: { itemId: 'ws_call' } }, + resultProviderMetadata: { openai: { itemId: 'ws_result' } }, + state: 'output-available', + output: { results: [] }, + }, + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + input: {}, + callProviderMetadata: { google: { thoughtSignature: 'ts-1' } }, + state: 'output-available', + output: { pong: true }, + }, + ]) + + // Request 3: the answer after the tool result is the next step. + await client.sendMessage('Thanks') + await vi.waitFor(() => expect(requests).toHaveLength(3)) + const sent = requests[2]!.find((m) => m.role === 'assistant') + expect(sent?.metadata).toEqual({ model: 'm-1', usage: 5 }) + expect(sent?.parts.map((p) => p.type)).toEqual([ + 'text', + 'dynamic-tool', + 'dynamic-tool', + 'step-start', + 'text', + ]) + expect(await convertToModelMessages(requests[2]!)).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Search, then ping' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { + type: 'tool-call', + toolCallId: 'ws-1', + toolName: 'web_search', + input: { query: 'tanstack' }, + providerExecuted: true, + providerOptions: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-result', + toolCallId: 'ws-1', + toolName: 'web_search', + output: { type: 'json', value: { results: [] } }, + providerOptions: { openai: { itemId: 'ws_result' } }, + }, + { + type: 'tool-call', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + providerExecuted: undefined, + providerOptions: { google: { thoughtSignature: 'ts-1' } }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c-1', + toolName: 'ping', + output: { type: 'json', value: { pong: true } }, + providerOptions: { google: { thoughtSignature: 'ts-1' } }, + }, + ], + }, + { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }, + { role: 'user', content: [{ type: 'text', text: 'Thanks' }] }, + ]) + }) + + it('sends the approval signature back and skips automatic approvals', async () => { + const { requests, transport } = fakeTransport([ + [ + { type: 'start', messageId: 'a-1' }, + { + type: 'tool-input-available', + toolCallId: 'c-auto', + toolName: 'readFile', + input: { path: 'a.txt' }, + }, + // The route approved this call itself and ran it. + { + type: 'tool-approval-request', + approvalId: 'ap-auto', + toolCallId: 'c-auto', + isAutomatic: true, + }, + { + type: 'tool-approval-response', + approvalId: 'ap-auto', + approved: true, + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'deleteFile', + input: { path: 'a.txt' }, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + reason: 'Deletes data', + signature: 'sig-1', + }, + { + type: 'tool-output-available', + toolCallId: 'c-auto', + output: 'hello', + }, + { type: 'finish', finishReason: 'tool-calls' }, + ], + [{ type: 'start' }, { type: 'finish', finishReason: 'stop' }], + ]) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + }) + + await client.sendMessage('Read and delete a.txt') + const interrupts = client.getInterrupts() + expect(interrupts.map((i) => i.id)).toEqual(['ap-1']) + const [interrupt] = interrupts + if (interrupt?.kind !== 'generic') throw new Error('expected generic') + interrupt.resolveInterrupt({ approved: true }) + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + const sent = requests[1]!.find((m) => m.role === 'assistant') + expect(sent?.parts).toEqual([ + { + type: 'dynamic-tool', + toolName: 'readFile', + toolCallId: 'c-auto', + input: { path: 'a.txt' }, + state: 'output-available', + output: 'hello', + }, + { + type: 'dynamic-tool', + toolName: 'deleteFile', + toolCallId: 'c-1', + input: { path: 'a.txt' }, + state: 'approval-responded', + approval: { + id: 'ap-1', + approved: true, + requestReason: 'Deletes data', + signature: 'sig-1', + }, + }, + ]) + expect(await convertToModelMessages(requests[1]!)).toContainEqual( + expect.objectContaining({ + role: 'assistant', + content: expect.arrayContaining([ + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + isAutomatic: undefined, + reason: 'Deletes data', + signature: 'sig-1', + }, + ]), + }), + ) + }) + + it('sends a text-encoded tool input as JSON once', async () => { + const out = await collect([ + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'patch', + inputEncoding: 'text', + }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '*** ' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: 'diff' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'patch', + input: '*** diff', + }, + ]) + expect( + out.flatMap((e) => + e.type === EventType.TOOL_CALL_ARGS ? [e.delta] : [], + ), + ).toEqual(['"*** diff"']) + }) + + it('sends sources, files and citations as CUSTOM events', async () => { + const out = await collect([ + { type: 'source-url', sourceId: 's-1', url: 'https://tanstack.com' }, + { + type: 'source-document', + sourceId: 's-2', + mediaType: 'application/pdf', + title: 'Spec', + }, + { + type: 'file', + url: 'data:image/png;base64,AA==', + mediaType: 'image/png', + }, + { type: 'text-start', id: '0' }, + { + type: 'text-end', + id: '0', + citations: [ + { + source: { + type: 'source', + sourceType: 'url', + id: 's-1', + url: 'https://tanstack.com', + }, + citedText: 'TanStack', + }, + ], + }, + ]) + expect( + out.flatMap((e) => + e.type === EventType.CUSTOM ? [{ name: e.name, value: e.value }] : [], + ), + ).toEqual([ + { + name: 'source-url', + value: { sourceId: 's-1', url: 'https://tanstack.com' }, + }, + { + name: 'source-document', + value: { sourceId: 's-2', mediaType: 'application/pdf', title: 'Spec' }, + }, + { + name: 'file', + value: { url: 'data:image/png;base64,AA==', mediaType: 'image/png' }, + }, + { + name: 'citations', + value: [expect.objectContaining({ citedText: 'TanStack' })], + }, + ]) + }) +}) + +describe('toAISDKMessages', () => { + it('drops system messages and sends a structured answer as text', () => { + expect( + toAISDKMessages([ + { + id: 's', + role: 'system', + parts: [{ type: 'text', content: 'Be brief.' }], + }, + { + id: 'a', + role: 'assistant', + parts: [ + { + type: 'structured-output', + status: 'complete', + raw: '{"ok":true}', + data: { ok: true }, + }, + ], + }, + ]), + ).toEqual([ + { + id: 'a', + role: 'assistant', + parts: [{ type: 'text', text: '{"ok":true}' }], + }, + ]) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/fidelity-server.test.ts b/packages/ai-ui-message-stream/tests/fidelity-server.test.ts new file mode 100644 index 0000000000..8c2cba7f39 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/fidelity-server.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, convertMessagesToModelMessages } from '@tanstack/ai' +import { toolDefinition } from '@tanstack/ai/client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { fromAISDKMessages, toUIMessageStream } from '../src' +import type { + AdapterYieldChunk, + AnyTextAdapter, + ModelMessage, + StreamChunk, +} from '@tanstack/ai' +import type { UIMessage } from 'ai' + +type Step = ( + ids: { runId: string; threadId: string; messageId: string }, + messages: Array, +) => Array + +/** A text adapter that plays one step per model call and keeps each request. */ +function scriptedAdapter(steps: Array) { + const requests: Array> = [] + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'scripted', + model: 'scripted', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: {}, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + async *chatStream(options) { + requests.push(options.messages) + const step = steps[requests.length - 1] + if (!step) throw new Error('No more steps') + const runId = options.runId ?? 'run' + const threadId = options.threadId ?? 'thread' + yield { type: EventType.RUN_STARTED, runId, threadId, model: 'scripted' } + yield* step( + { runId, threadId, messageId: `${runId}-${requests.length}` }, + options.messages, + ) + }, + structuredOutput: () => Promise.resolve({ data: {}, rawText: '{}' }), + } + return { adapter, requests } +} + +// Gemini: a grounded search the provider ran, then a function call that +// carries a thought signature the next request must send back. +const toolStep: Step = ({ runId, threadId, messageId }) => [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'search-1', + toolCallName: 'google_search', + parentMessageId: messageId, + metadata: { + providerExecuted: true, + sources: [{ url: 'https://weather.example', title: 'Weather' }], + gemini: { groundingMetadata: { queries: ['nyc weather'] } }, + }, + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'search-1', input: {} }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + parentMessageId: messageId, + metadata: { thoughtSignature: 'sig-gemini' }, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"city":"NYC"}', + }, + { + type: EventType.TOOL_CALL_END, + toolCallId: 'call-1', + input: { city: 'NYC' }, + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + model: 'scripted', + finishReason: 'tool_calls', + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + }, +] + +function textStep(text: string): Step { + return ({ runId, threadId, messageId }) => [ + { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + model: 'scripted', + }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: text }, + { type: EventType.TEXT_MESSAGE_END, messageId }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + model: 'scripted', + finishReason: 'stop', + responseId: `resp-${text}`, + usage: { promptTokens: 4, completionTokens: 5, totalTokens: 9 }, + metadata: { + tanstack: { + responseItems: [{ id: `msg-${text}` }], + reasoningItem: true, + }, + }, + }, + ] +} + +const weather = toolDefinition({ + name: 'weather', + description: 'Weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(() => ({ temp: 72 })) + +const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather in NYC?' }], +} + +async function aiSdkMessage( + stream: AsyncIterable, +): Promise { + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ + stream: toUIMessageStream(stream), + })) + last = message + return last! +} + +describe('TanStack server, AI SDK client: fidelity', () => { + it('keeps tool-call metadata, provider-executed calls, sources, steps and message metadata', async () => { + const { adapter, requests } = scriptedAdapter([ + toolStep, + textStep('Sunny.'), + textStep('Again.'), + ]) + const first = await aiSdkMessage( + chat({ + adapter, + threadId: 'chat-1', + messages: fromAISDKMessages([user]), + tools: [weather], + }), + ) + + expect(first.parts).toMatchObject([ + { type: 'step-start' }, + { + type: 'tool-google_search', + state: 'output-available', + providerExecuted: true, + output: [{ url: 'https://weather.example', title: 'Weather' }], + callProviderMetadata: { tanstack: { providerExecuted: true } }, + }, + { + type: 'source-url', + sourceId: 'search-1-source-0', + url: 'https://weather.example', + title: 'Weather', + }, + { + type: 'tool-weather', + state: 'output-available', + output: { temp: 72 }, + callProviderMetadata: { tanstack: { thoughtSignature: 'sig-gemini' } }, + }, + { type: 'step-start' }, + { type: 'text', text: 'Sunny.' }, + ]) + expect(first.metadata).toMatchObject({ + tanstack: { + model: 'scripted', + responseId: 'resp-Sunny.', + finishReason: 'stop', + responseItems: [{ id: 'msg-Sunny.' }], + reasoningItem: true, + tokenUsage: [{ totalTokens: 3 }, { totalTokens: 9 }], + }, + }) + + // The AI SDK client posts the history back. + const history = fromAISDKMessages([ + user, + first, + { id: 'u-2', role: 'user', parts: [{ type: 'text', text: 'Again?' }] }, + ]) + const assistant = convertMessagesToModelMessages(history).slice(1, -1) + expect(assistant).toMatchObject([ + { + role: 'assistant', + toolCalls: [ + { + id: 'search-1', + metadata: { providerExecuted: true, gemini: {} }, + }, + { + id: 'call-1', + function: { name: 'weather', arguments: '{"city":"NYC"}' }, + metadata: { thoughtSignature: 'sig-gemini' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call-1', content: '{"temp":72}' }, + { role: 'assistant', content: 'Sunny.' }, + ]) + // The response items name one model call, so a two-step message drops them. + expect(assistant[0]?.metadata?.tanstack).not.toHaveProperty('responseItems') + expect(assistant[0]?.metadata?.tanstack).toMatchObject({ + reasoningItem: true, + }) + + // chat() replays it: the signature goes to the provider, and the + // provider-executed call is not a pending call. + const second = await aiSdkMessage( + chat({ + adapter, + threadId: 'chat-1', + messages: history, + tools: [weather], + }), + ) + expect(second.parts).toMatchObject([ + { type: 'step-start' }, + { type: 'text', text: 'Again.' }, + ]) + const replayed = requests[2]! + expect(replayed.filter((m) => m.role === 'tool')).toHaveLength(1) + expect( + replayed.flatMap((m) => m.toolCalls ?? []).map((c) => c.metadata), + ).toMatchObject([ + { providerExecuted: true }, + { thoughtSignature: 'sig-gemini' }, + ]) + }) + + it('keeps the response items of a one-step message', async () => { + const { adapter } = scriptedAdapter([textStep('Hi.')]) + const message = await aiSdkMessage( + chat({ adapter, threadId: 't', messages: fromAISDKMessages([user]) }), + ) + const [, assistant] = fromAISDKMessages([user, message]) + expect(assistant?.metadata).toMatchObject({ + tanstack: { responseItems: [{ id: 'msg-Hi.' }], responseId: 'resp-Hi.' }, + }) + }) + + it('adds a tool-call signature that comes as a REASONING_ENCRYPTED_VALUE', async () => { + async function* events(): AsyncGenerator { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + } + yield { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'tool-call', + entityId: 'call-1', + encryptedValue: 'sig-late', + } + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{}', + } + yield { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' } + } + const message = await aiSdkMessage(events()) + const [assistant] = fromAISDKMessages([message]) + expect(assistant?.parts).toMatchObject([ + { + type: 'tool-call', + id: 'call-1', + metadata: { thoughtSignature: 'sig-late' }, + }, + ]) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts index 8dece75597..0e96d1047c 100644 --- a/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts +++ b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts @@ -109,6 +109,7 @@ describe('TanStack server, AI SDK client', () => { } expect(assistant?.parts).toMatchObject([ + { type: 'step-start' }, { type: 'reasoning', text: 'Plan.', @@ -196,8 +197,10 @@ describe('TanStack server, AI SDK client', () => { } const first = await turn([user]) expect(first.parts).toMatchObject([ + { type: 'step-start' }, { type: 'reasoning', text: 'Plan.' }, { type: 'tool-weather', state: 'output-available', output: { temp: 72 } }, + { type: 'step-start' }, { type: 'tool-ask', state: 'input-available', input: { q: 'Units?' } }, ]) @@ -212,6 +215,7 @@ describe('TanStack server, AI SDK client', () => { } const second = await turn([user, answered]) expect(second.parts).toMatchObject([ + { type: 'step-start' }, { type: 'text', text: 'Answer: Fahrenheit' }, ]) }) @@ -280,6 +284,7 @@ describe('AI SDK server, TanStack client', () => { for (const event of out) EventSchemas.parse(event) expect(out.map((e) => e.type)).toEqual([ 'RUN_STARTED', + 'STEP_STARTED', 'REASONING_MESSAGE_START', 'REASONING_MESSAGE_CONTENT', 'REASONING_ENCRYPTED_VALUE', @@ -290,10 +295,66 @@ describe('AI SDK server, TanStack client', () => { 'TOOL_CALL_START', 'TOOL_CALL_ARGS', 'TOOL_CALL_END', + 'STEP_FINISHED', 'RUN_FINISHED', ]) }) + it('keeps the AG-UI tool call order when metadata comes late', async () => { + const out: Array = [] + for await (const event of fromUIMessageStream( + chunkStream([ + { type: 'start', messageId: 'a-1' }, + { type: 'tool-input-start', toolCallId: 'c-1', toolName: 'pay' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '{}' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'pay', + input: {}, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + signature: 'sig', + }, + { + type: 'tool-input-available', + toolCallId: 'c-2', + toolName: 'search', + input: {}, + providerExecuted: true, + }, + { + type: 'tool-output-available', + toolCallId: 'c-2', + output: 'found', + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws-1' } }, + }, + { type: 'finish' }, + ]), + { threadId: 't', runId: 'r' }, + )) { + out.push(event) + } + for (const event of out) EventSchemas.parse(event) + + // Per call: START, ARGS..., END, then RESULT. No ARGS after END. + for (const id of ['c-1', 'c-2']) { + const types = out + .filter((e) => 'toolCallId' in e && e.toolCallId === id) + .map((e): string => e.type) + const end = types.indexOf('TOOL_CALL_END') + expect(types[0]).toBe('TOOL_CALL_START') + expect(end).toBeGreaterThan(0) + expect(types.lastIndexOf('TOOL_CALL_ARGS')).toBeLessThan(end) + const result = types.indexOf('TOOL_CALL_RESULT') + if (result !== -1) expect(result).toBeGreaterThan(end) + } + }) + it('runs a client tool and sends its output and reasoning back', async () => { const requests: Array> = [] const transport: ChatTransport = { From 7487a603041a5fa0b7e588f8e8a65ea72e4c5d1d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:24:10 +1100 Subject: [PATCH 6/7] feat(ai-ui-message-stream): client tools, structured output and reconnect for AI SDK routes TanStack useChat gets more from an AI SDK route: - fromChatTransport sends the client's tool declarations in the request body. clientToolsFromAISDKBody(body, serverTools) turns them into AI SDK tools without execute on the route, so client and WebMCP tools need no copy there. Server tools win a name clash. - With outputSchema, an Output.object answer becomes a typed structured-output part with partial objects and a checked final value. - A dropped stream reconnects with createReconnectTracker, the backoff of the TanStack HTTP connections, set with the reconnect option. @tanstack/ai-client now exports createReconnectTracker. Tests prove client persistence, the message queue, devtools and loading saved AI SDK chats over the bridge. --- .changeset/ai-ui-message-stream-client.md | 12 + docs/migration/ai-sdk-compatibility.md | 114 ++++---- .../tanstack-client-ai-sdk-server.md | 109 +++++++- packages/ai-client/src/index.ts | 2 + packages/ai-ui-message-stream/README.md | 19 +- packages/ai-ui-message-stream/package.json | 5 - .../ai-ui-message-stream/src/client-tools.ts | 60 ++++ packages/ai-ui-message-stream/src/index.ts | 91 +++++-- packages/ai-ui-message-stream/src/stream.ts | 68 ++++- .../tests/client-extras.test.ts | 257 ++++++++++++++++++ .../tests/client-tools.test.ts | 135 +++++++++ .../tests/reconnect.test.ts | 67 +++++ skills/tanstack-ai-migration/SKILL.md | 4 +- 13 files changed, 842 insertions(+), 101 deletions(-) create mode 100644 .changeset/ai-ui-message-stream-client.md create mode 100644 packages/ai-ui-message-stream/src/client-tools.ts create mode 100644 packages/ai-ui-message-stream/tests/client-extras.test.ts create mode 100644 packages/ai-ui-message-stream/tests/client-tools.test.ts create mode 100644 packages/ai-ui-message-stream/tests/reconnect.test.ts diff --git a/.changeset/ai-ui-message-stream-client.md b/.changeset/ai-ui-message-stream-client.md new file mode 100644 index 0000000000..a12909be45 --- /dev/null +++ b/.changeset/ai-ui-message-stream-client.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-ui-message-stream': minor +'@tanstack/ai-client': patch +--- + +TanStack `useChat` gets more from an AI SDK route through `fromChatTransport`: + +- Client tools go to the route in the request body. Read them on the route with `clientToolsFromAISDKBody(body, serverTools)`, so the route needs no copy. +- With `outputSchema`, an `Output.object` answer is a typed `structured-output` part with partial objects. +- A dropped stream reconnects with the backoff of the TanStack HTTP connections. Set it with the `reconnect` option. + +`@tanstack/ai-client` exports `createReconnectTracker` and its `ReconnectTracker` type. diff --git a/docs/migration/ai-sdk-compatibility.md b/docs/migration/ai-sdk-compatibility.md index 6964deeedc..f4d689fa95 100644 --- a/docs/migration/ai-sdk-compatibility.md +++ b/docs/migration/ai-sdk-compatibility.md @@ -29,29 +29,29 @@ Each row has one of these results: ## šŸ“‹ Features -| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | -| -------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Text | āœ… Same | āœ… Same | -| Reasoning and its signature | āœ… Same | āœ… Same | -| Tool-call provider data (Gemini `thoughtSignature`, OpenAI item ids) | šŸ”„ Mapped to `callProviderMetadata` and back | šŸ”„ Mapped to tool-call `metadata` and back | -| Server tools | āœ… Same | āœ… Same | -| Provider-executed tools (web search) | šŸ”„ Mapped to `providerExecuted` | šŸ”„ Mapped to the TanStack provider-executed flag | -| Client tools | āœ… Same | āœ… Same. Pass the tools to `useChat`. | -| Tool approvals | šŸ”„ Mapped to `tool-approval-request` and back to `resume` | šŸ”„ Mapped to a `tool-approval` interrupt. The approval `signature` goes back to the route. | -| Tool errors and denials | āœ… Same | āœ… Same | -| Steps of a tool loop | šŸ”„ Mapped to `start-step` / `finish-step` | šŸ”„ Mapped to `STEP_*` events, and to `step-start` parts in the history | -| Files and images from the user | šŸ”„ Mapped to content parts | šŸ”„ Mapped to file parts | -| Web search sources | šŸ”„ Mapped to `source-url` chunks | šŸ”„ Mapped to custom events | -| Model, response ids and token usage | šŸ”„ Mapped to message metadata under `tanstack` | āŒ Dropped (the AI SDK stream has none) | -| Message metadata of the app | āœ… Same | šŸ”„ Mapped to `metadata.aiSdk` and back | -| Activity | šŸ”„ Mapped to a `data-` part | šŸ”„ Mapped from a `data-` part with an `id` | -| Subagents | 🧩 Grouped into one `data-subagent` card per child | āŒ Dropped (the AI SDK has no subagents) | -| Shared state | āž”ļø One way, to a `data-state` part | āŒ Dropped (the AI SDK has no state) | -| Structured output | āœ… Same (the JSON is text) | šŸ”„ Mapped to a text part in the history | -| Custom events | šŸ”„ Mapped to a transient `data-` chunk | šŸ”„ Mapped from a transient `data-` chunk | -| Stream resume | āœ… Same, with a durability log | āœ… Same. A reload needs `threadId` on `fromChatTransport`. | -| System messages | āŒ Dropped. The server owns its system prompts. | āŒ Dropped. `streamText` rejects them. | -| Generated files | āŒ Dropped (`chat()` sends none) | šŸ”„ Mapped to custom events | +| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | +| -------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Text | āœ… Same | āœ… Same | +| Reasoning and its signature | āœ… Same | āœ… Same | +| Tool-call provider data (Gemini `thoughtSignature`, OpenAI item ids) | šŸ”„ Mapped to `callProviderMetadata` and back | šŸ”„ Mapped to tool-call `metadata` and back | +| Server tools | āœ… Same | āœ… Same | +| Provider-executed tools (web search) | šŸ”„ Mapped to `providerExecuted` | šŸ”„ Mapped to the TanStack provider-executed flag | +| Client tools | āœ… Same | šŸ”„ Mapped. Their schemas go to the route in the body. Read them with `clientToolsFromAISDKBody`. | +| Tool approvals | šŸ”„ Mapped to `tool-approval-request` and back to `resume` | šŸ”„ Mapped to a `tool-approval` interrupt. The approval `signature` goes back to the route. | +| Tool errors and denials | āœ… Same | āœ… Same | +| Steps of a tool loop | šŸ”„ Mapped to `start-step` / `finish-step` | šŸ”„ Mapped to `STEP_*` events, and to `step-start` parts in the history | +| Files and images from the user | šŸ”„ Mapped to content parts | šŸ”„ Mapped to file parts | +| Web search sources | šŸ”„ Mapped to `source-url` chunks | šŸ”„ Mapped to custom events | +| Model, response ids and token usage | šŸ”„ Mapped to message metadata under `tanstack` | āŒ Dropped (the AI SDK stream has none) | +| Message metadata of the app | āœ… Same | šŸ”„ Mapped to `metadata.aiSdk` and back | +| Activity | šŸ”„ Mapped to a `data-` part | šŸ”„ Mapped from a `data-` part with an `id` | +| Subagents | 🧩 Grouped into one `data-subagent` card per child | āŒ Dropped (the AI SDK has no subagents) | +| Shared state | āž”ļø One way, to a `data-state` part | āŒ Dropped (the AI SDK has no state) | +| Structured output | āœ… Same (the JSON is text) | šŸ”„ Mapped to a typed `structured-output` part with `outputSchema`, and back to text in the history | +| Custom events | šŸ”„ Mapped to a transient `data-` chunk | šŸ”„ Mapped from a transient `data-` chunk | +| Stream resume | āœ… Same, with a durability log | āœ… Same. A reload needs `threadId` on `fromChatTransport`. | +| System messages | āŒ Dropped. The server owns its system prompts. | āŒ Dropped. `streamText` rejects them. | +| Generated files | āŒ Dropped (`chat()` sends none) | šŸ”„ Mapped to custom events | ## ā–² AI SDK client, TanStack server @@ -109,39 +109,40 @@ Each row has one of these results: ### ā¬‡ļø Reply: AI SDK chunks to TanStack events -| AI SDK chunk | AG-UI event | Result | -| -------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `start` | `RUN_STARTED` | šŸ”„ Mapped. The message id is the AG-UI message id. | -| `messageMetadata` on `start`, `message-metadata`, `finish` | `metadata.aiSdk` on the message, at the end of the run | šŸ”„ Mapped | -| `start-step` / `finish-step` | `STEP_STARTED` / `STEP_FINISHED` | āœ… Same | -| `reset-step` | | āŒ Dropped | -| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | -| `providerMetadata` on text chunks | | āŒ Dropped. TanStack keeps no metadata per text part. | -| `text-end.citations` | `CUSTOM` named `citations` | šŸ”„ Mapped | -| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | āœ… Same. Each reasoning part gets its own id. | -| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | šŸ”„ Mapped. It goes back to the route unchanged. | -| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | āœ… Same | -| `providerMetadata` on tool input chunks | tool-call `metadata.aiSdk.callProviderMetadata` | šŸ”„ Mapped | -| `providerExecuted` | tool-call `metadata.providerExecuted` | šŸ”„ Mapped. The client does not run the call. | -| `inputEncoding: 'text'` | One `TOOL_CALL_ARGS` with the final input | šŸ”„ Mapped | -| `tool-input-available` | `TOOL_CALL_END`, after any approval or output for the same call | āœ… Same | -| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | šŸ”„ Mapped | -| `tool-output-available` | `TOOL_CALL_RESULT` | āœ… Same | -| `providerMetadata` on tool output chunks | tool-call `metadata.aiSdk.resultProviderMetadata` | šŸ”„ Mapped when it comes right after the call. Later, it is dropped. | -| `tool-output-available` with `preliminary: true` | | āŒ Dropped. Only the final output is sent. | -| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | šŸ”„ Mapped | -| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | šŸ”„ Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | -| `signature`, `approvalDescriptor`, `inputSchemaInput`, `reason` of an approval request | tool-call `metadata.aiSdk.approval` | šŸ”„ Mapped | -| `tool-approval-request` with `isAutomatic` | | āŒ Dropped. The route already decided, and the output or denial follows. | -| `tool-approval-response` | | āŒ Dropped | -| `finish` | `RUN_FINISHED` with the finish reason | šŸ”„ Mapped | -| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | šŸ”„ Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | -| `error` | `RUN_ERROR` | šŸ”„ Mapped | -| `abort` | `RUN_FINISHED` with a `cancelled` outcome | šŸ”„ Mapped | -| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | šŸ”„ Mapped. The same id replaces the activity. | -| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | šŸ”„ Mapped | -| `source-url`, `source-document`, `file`, `reasoning-file` | `CUSTOM` with the same name | šŸ”„ Mapped | -| `custom` | | āŒ Dropped | +| AI SDK chunk | AG-UI event | Result | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `start` | `RUN_STARTED` | šŸ”„ Mapped. The message id is the AG-UI message id. | +| `messageMetadata` on `start`, `message-metadata`, `finish` | `metadata.aiSdk` on the message, at the end of the run | šŸ”„ Mapped | +| `start-step` / `finish-step` | `STEP_STARTED` / `STEP_FINISHED` | āœ… Same | +| `reset-step` | | āŒ Dropped | +| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | +| Text of an `Output.object` answer, with `outputSchema` | `structured-output.start`, the text, then `structured-output.complete` with the checked object | šŸ”„ Mapped. A schema failure ends the run with `RUN_ERROR`. | +| `providerMetadata` on text chunks | | āŒ Dropped. TanStack keeps no metadata per text part. | +| `text-end.citations` | `CUSTOM` named `citations` | šŸ”„ Mapped | +| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | āœ… Same. Each reasoning part gets its own id. | +| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | šŸ”„ Mapped. It goes back to the route unchanged. | +| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | āœ… Same | +| `providerMetadata` on tool input chunks | tool-call `metadata.aiSdk.callProviderMetadata` | šŸ”„ Mapped | +| `providerExecuted` | tool-call `metadata.providerExecuted` | šŸ”„ Mapped. The client does not run the call. | +| `inputEncoding: 'text'` | One `TOOL_CALL_ARGS` with the final input | šŸ”„ Mapped | +| `tool-input-available` | `TOOL_CALL_END`, after any approval or output for the same call | āœ… Same | +| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-output-available` | `TOOL_CALL_RESULT` | āœ… Same | +| `providerMetadata` on tool output chunks | tool-call `metadata.aiSdk.resultProviderMetadata` | šŸ”„ Mapped when it comes right after the call. Later, it is dropped. | +| `tool-output-available` with `preliminary: true` | | āŒ Dropped. Only the final output is sent. | +| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | šŸ”„ Mapped | +| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | šŸ”„ Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | +| `signature`, `approvalDescriptor`, `inputSchemaInput`, `reason` of an approval request | tool-call `metadata.aiSdk.approval` | šŸ”„ Mapped | +| `tool-approval-request` with `isAutomatic` | | āŒ Dropped. The route already decided, and the output or denial follows. | +| `tool-approval-response` | | āŒ Dropped | +| `finish` | `RUN_FINISHED` with the finish reason | šŸ”„ Mapped | +| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | šŸ”„ Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | +| `error` | `RUN_ERROR` | šŸ”„ Mapped | +| `abort` | `RUN_FINISHED` with a `cancelled` outcome | šŸ”„ Mapped | +| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | šŸ”„ Mapped. The same id replaces the activity. | +| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | šŸ”„ Mapped | +| `source-url`, `source-document`, `file`, `reasoning-file` | `CUSTOM` with the same name | šŸ”„ Mapped | +| `custom` | | āŒ Dropped | ### ā¬†ļø Request: TanStack messages to AI SDK message parts @@ -155,6 +156,7 @@ Each row has one of these results: | Provider-executed `tool-call` | `dynamic-tool` with `providerExecuted: true` | šŸ”„ Mapped | | `tool-call` with an approval answer | `dynamic-tool` with `approval-responded`, and the stored `signature` | šŸ”„ Mapped | | `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | šŸ”„ Mapped | +| Client tool declarations of the run | `clientTools` in the request body | šŸ”„ Mapped | | A tool result followed by more of the reply | `step-start` before the next step | šŸ”„ Mapped | | `structured-output`, complete | `text` with the JSON | šŸ”„ Mapped | | Message `metadata.aiSdk` | Message `metadata` | šŸ”„ Mapped | diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md index 897b926524..4930d5e8af 100644 --- a/docs/migration/tanstack-client-ai-sdk-server.md +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -19,6 +19,14 @@ Point TanStack AI `useChat` at a Vercel AI SDK route. Wrap an AI SDK `ChatTransp Use this page to move the client first. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). +Against your AI SDK route, TanStack `useChat` gives you: + +- Client persistence and resume after a reload, with no server code. +- A [message queue](../chat/queueing) while a reply streams. +- Client tools that the route reads from the request, with no copy on the route. +- Typed structured output, with partial objects while it streams. +- Typed approvals, TanStack devtools, and hooks for React, Solid, Vue, Svelte, Preact, Angular and more. + > [!WARNING] > This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the client works, port the route to `chat()` with the [migration guide](./migration-from-vercel-ai), then remove the bridge. @@ -96,7 +104,38 @@ export async function POST(request: Request) { ## 4. Run client tools and approve tool calls -Define the tools of the route on the client too, with the same names. Pass them to `useChat` and to `fromChatTransport`: +Define client tools on the client only. The connection sends their names, descriptions and JSON Schemas to the route in the request body. Read them on the route with `clientToolsFromAISDKBody`, together with the server tools: + +```typescript +import { openai } from '@ai-sdk/openai' +import { clientToolsFromAISDKBody } from '@tanstack/ai-ui-message-stream' +import { convertToModelMessages, streamText, tool } from 'ai' +import { z } from 'zod' + +const getWeather = tool({ + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ city, temperature: 72 }), +}) + +export async function POST(request: Request) { + const body = await request.json() + + const result = streamText({ + model: openai('gpt-6.1-sol'), + messages: await convertToModelMessages(body.messages), + tools: clientToolsFromAISDKBody(body, { getWeather }), + }) + + return result.toUIMessageStreamResponse() +} +``` + +- A server tool wins when a client tool has the same name. +- A client tool has no `execute` on the route, so `streamText` stops at the call. +- Browser tools from [WebMCP](../tools/webmcp) reach the route the same way. + +Pass the tools to `useChat`, and to `fromChatTransport` for approvals: - **Client tool:** give it a `.client()` implementation. When the route stops at the call, the client runs the tool and sends the output back. - **Tool approval:** a tool that needs approval on the route shows up in `interrupts`. Resolve it with `interrupt.resolveInterrupt(true)` or `false`. The answer goes back to the route, and `streamText` runs or skips the tool. @@ -148,13 +187,75 @@ export function Chat() { } ``` -## 5. Show data parts as activity +## 5. Get typed structured output + +When the route streams an object with `Output.object`, pass the same schema to `useChat` and to `fromChatTransport`. The answer is a `structured-output` part, with partial objects while it streams and the checked `data` at the end: + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +const recipe = z.object({ name: z.string(), steps: z.array(z.string()) }) + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/recipe' }), + { outputSchema: recipe }, +) + +export function Recipe() { + const { messages } = useChat({ + connection, + threadId: 'recipe', + outputSchema: recipe, + }) + + return messages.flatMap((message) => + message.parts.map((part, i) => + part.type === 'structured-output' ? ( +

{part.partial?.name}

+ ) : null, + ), + ) +} +``` + +If the text is not valid JSON for the schema, the run ends with an error. + +## 6. Load a saved chat + +If your app saves AI SDK messages, convert them with `fromAISDKMessages` and pass them as `initialMessages`: + +```tsx +import { useChat } from '@tanstack/ai-react' +import { + fromAISDKMessages, + fromChatTransport, +} from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import type { UIMessage } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function SavedChat({ saved }: { saved: Array }) { + return useChat({ + connection, + threadId: 'support-chat', + initialMessages: fromAISDKMessages(saved), + }) +} +``` + +## 7. Show data parts as activity A data part with an `id` (`writer.write({ type: 'data-weather', id, data })` on the route) arrives as an activity message of type `weather`. A new value for the same id replaces it. A transient data part, or one with no `id`, arrives as a custom event named `data-`. -## 6. Resume a stream +## 8. Resume a stream -A dropped connection needs nothing. The connection calls `reconnectToStream` on the transport, and the reply continues. +A dropped connection needs nothing. The connection calls `reconnectToStream` on the transport, and the reply continues. It waits longer between tries that get no new data, as the TanStack HTTP connections do. To change the waits and the limit, pass `reconnect` to `fromChatTransport`, for example `{ maxAttempts: 10 }`. To continue a reply after a page reload, add client persistence, and pass the `threadId` to `fromChatTransport`. The route must support the AI SDK resume `GET` (`/api/chat//stream`): diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index c4561ca23e..aa41eb719c 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -175,6 +175,8 @@ export { StreamTruncatedError, DurableStreamIncompleteError, StreamReconnectLimitError, + createReconnectTracker, + type ReconnectTracker, type ConnectConnectionAdapter, type ChatHydrateOptions, type ChatHydrationResult, diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md index 13d3b49ea3..e496c4d4b6 100644 --- a/packages/ai-ui-message-stream/README.md +++ b/packages/ai-ui-message-stream/README.md @@ -78,15 +78,16 @@ export function Chat() { ## API -| Export | Direction | -| -------------------------------------------------------- | ---------------------------------------------------------------------- | -| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | -| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | -| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | -| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | -| `fromChatTransport(transport, { threadId, tools })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | -| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | -| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | +| Export | Direction | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | +| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | +| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | +| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | +| `fromChatTransport(transport, { threadId, tools, outputSchema, reconnect })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | +| `clientToolsFromAISDKBody(body, serverTools)` | On an AI SDK route: the client tools of the request, plus the server tools | +| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | +| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | Text, reasoning, tools, tool approvals, activity, subagents and stream resume cross in both directions where the other side has them. Shared state goes from a TanStack server to an AI SDK client only, because the AI SDK client does not send state. diff --git a/packages/ai-ui-message-stream/package.json b/packages/ai-ui-message-stream/package.json index 8f377b7261..c25632ba95 100644 --- a/packages/ai-ui-message-stream/package.json +++ b/packages/ai-ui-message-stream/package.json @@ -49,11 +49,6 @@ "@tanstack/ai-client": "workspace:^", "ai": "^7.0.0" }, - "peerDependenciesMeta": { - "@tanstack/ai-client": { - "optional": true - } - }, "devDependencies": { "@ag-ui/core": "1.0.0", "@tanstack/ai": "workspace:*", diff --git a/packages/ai-ui-message-stream/src/client-tools.ts b/packages/ai-ui-message-stream/src/client-tools.ts new file mode 100644 index 0000000000..bcddace4d3 --- /dev/null +++ b/packages/ai-ui-message-stream/src/client-tools.ts @@ -0,0 +1,60 @@ +import { jsonSchema, tool } from 'ai' +import type { ToolSet } from 'ai' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Add the tools that a TanStack `useChat` client declares (`clientTools` in + * the request body, sent by `fromChatTransport`) to the tools of an AI SDK + * route. A client tool has no `execute`, so `streamText` stops at its call + * and the client runs it. A server tool wins on a name clash. + * + * ```ts + * const body = await request.json() + * const result = streamText({ + * model, + * messages: await convertToModelMessages(body.messages), + * tools: clientToolsFromAISDKBody(body, { weather }), + * }) + * ``` + * + * @throws An error when `clientTools` is present but is not a list of + * `{ name, description, parameters }` with a JSON Schema object. + */ +export function clientToolsFromAISDKBody( + body: unknown, + serverTools: TServerTools, +): TServerTools & ToolSet { + const declared = isRecord(body) ? body.clientTools : undefined + if (declared === undefined) return serverTools + if (!Array.isArray(declared)) { + throw new Error('`clientTools` must be an array.') + } + // `fromEntries` defines own properties, so a `__proto__` name is inert. + const clientTools: ToolSet = Object.fromEntries( + declared.map((entry: unknown) => { + if ( + !isRecord(entry) || + typeof entry.name !== 'string' || + entry.name === '' || + typeof entry.description !== 'string' || + !isRecord(entry.parameters) + ) { + throw new Error( + 'Each client tool must have a `name`, a `description` and JSON Schema `parameters`.', + ) + } + // No `execute`: `streamText` stops at the call and the client runs it. + return [ + entry.name, + tool({ + description: entry.description, + inputSchema: jsonSchema(entry.parameters), + }), + ] + }), + ) + return { ...clientTools, ...serverTools } +} diff --git a/packages/ai-ui-message-stream/src/index.ts b/packages/ai-ui-message-stream/src/index.ts index 536024ffb2..15c5e1ca8e 100644 --- a/packages/ai-ui-message-stream/src/index.ts +++ b/packages/ai-ui-message-stream/src/index.ts @@ -1,6 +1,10 @@ import { modelMessagesToUIMessages } from '@tanstack/ai' import type { ModelMessage, UIMessage } from '@tanstack/ai' -import type { ConnectConnectionAdapter } from '@tanstack/ai-client' +import { createReconnectTracker } from '@tanstack/ai-client' +import type { + ConnectConnectionAdapter, + ReconnectOptions, +} from '@tanstack/ai-client' import type { ChatTransport, UIMessage as AISDKUIMessage, @@ -22,6 +26,7 @@ export { chatParamsFromAISDKRequestBody, } from './request' export type { AISDKChatParams } from './request' +export { clientToolsFromAISDKBody } from './client-tools' export type { SubagentData } from './data-parts' function asUIMessages( @@ -35,25 +40,26 @@ function asUIMessages( ) } -type ReconnectOptions = Parameters< +type ReconnectRequest = Parameters< ChatTransport['reconnectToStream'] >[0] /** * Read `stream`, and when the connection drops, read the rest through - * `transport.reconnectToStream`. A replayed prefix is skipped. + * `transport.reconnectToStream`, with the backoff and limits of the TanStack + * HTTP connections. A replayed prefix is skipped. */ function reconnecting( transport: ChatTransport, stream: ReadableStream, - options: ReconnectOptions, + request: ReconnectRequest, + reconnect: ReconnectOptions | undefined, ): ReadableStream { + const tracker = createReconnectTracker(reconnect) let reader = stream.getReader() let delivered = 0 let skip = 0 - // ponytail: a reconnect that delivers nothing new ends the stream with the - // error. Add attempts and backoff if one retry without progress is too few. - let deliveredAtReconnect: number | undefined + let deliveredAtLastDrop = 0 return new ReadableStream({ async pull(controller) { for (;;) { @@ -69,10 +75,20 @@ function reconnecting( delivered++ return controller.enqueue(value) } catch (error) { - if (options.abortSignal?.aborted) throw error - if (deliveredAtReconnect === delivered) throw error - deliveredAtReconnect = delivered - const resumed = await transport.reconnectToStream(options) + if (request.abortSignal?.aborted) throw error + // Throws StreamReconnectLimitError when the run is stuck. + await tracker.waitBeforeReconnect( + delivered > deliveredAtLastDrop, + request.abortSignal, + ) + deliveredAtLastDrop = delivered + let resumed: ReadableStream | null + try { + resumed = await transport.reconnectToStream(request) + } catch { + // The old reader fails again, so the next pass waits and retries. + continue + } if (!resumed) throw error reader = resumed.getReader() skip = transport.resumeStreamIsReplay === true ? delivered : 0 @@ -93,23 +109,39 @@ function reconnecting( * request. * * When the stream drops, the adapter reconnects with - * `transport.reconnectToStream` and continues the run. Set `threadId` to also + * `transport.reconnectToStream` and continues the run, with the same backoff + * as the TanStack HTTP connections (`reconnect` sets it). Set `threadId` to also * rejoin a run after a page reload (`joinRun`), with a chat `persistence` * adapter on the client. For that, the transport must replay the stream from * its start (`resumeStreamIsReplay`, true for `DefaultChatTransport`). * * Pass the same `tools` as `useChat({ tools })`, so a tool approval is a * `tool-approval` interrupt that `resolveInterrupt(true)` answers. + * + * Pass the same `outputSchema` as `useChat({ outputSchema })` when the route + * uses `Output.object({ schema })`, so the answer is a typed + * `structured-output` part. + * + * The client's tools (JSON Schema) go in the body as `clientTools`. Read them + * on the route with `clientToolsFromAISDKBody`, so the route needs no copy. */ export function fromChatTransport( transport: ChatTransport, { threadId, tools, - }: Pick & { threadId?: string } = {}, + outputSchema, + reconnect, + }: Pick & { + threadId?: string + /** Backoff and limits for reconnects after a dropped stream. */ + reconnect?: ReconnectOptions + } = {}, ): ConnectConnectionAdapter { return { async *connect(messages, data, abortSignal, runContext) { + const { clientTools } = runContext + const body = clientTools?.length ? { ...data, clientTools } : data const stream = await transport.sendMessages({ trigger: 'submit-message', chatId: runContext.threadId, @@ -117,15 +149,20 @@ export function fromChatTransport( messages: toAISDKMessages(asUIMessages(messages)), abortSignal, ...(runContext.headers && { headers: runContext.headers }), - ...(data && { body: data }), + ...(body && { body }), }) yield* fromUIMessageStream( - reconnecting(transport, stream, { - chatId: runContext.threadId, - abortSignal, - ...(runContext.headers && { headers: runContext.headers }), - }), - { ...runContext, tools }, + reconnecting( + transport, + stream, + { + chatId: runContext.threadId, + abortSignal, + ...(runContext.headers && { headers: runContext.headers }), + }, + reconnect, + ), + { ...runContext, tools, outputSchema }, ) }, // A rejoin gets only the run id, and the AI SDK reconnects by chat id. @@ -136,11 +173,15 @@ export function fromChatTransport( const stream = await transport.reconnectToStream(options) // Thrown before the first event, so the client drops its resume pointer. if (!stream) throw new Error(`No active stream for chat ${threadId}`) - yield* fromUIMessageStream(reconnecting(transport, stream, options), { - threadId, - runId, - tools, - }) + yield* fromUIMessageStream( + reconnecting(transport, stream, options, reconnect), + { + threadId, + runId, + tools, + outputSchema, + }, + ) }, }), } diff --git a/packages/ai-ui-message-stream/src/stream.ts b/packages/ai-ui-message-stream/src/stream.ts index e9f8d41b28..7ff6ca5e4e 100644 --- a/packages/ai-ui-message-stream/src/stream.ts +++ b/packages/ai-ui-message-stream/src/stream.ts @@ -6,12 +6,14 @@ import { hashSchemaInput, normalizeApprovalSchema, readInterruptBinding, + validateWithStandardSchema, withInterruptBinding, } from '@tanstack/ai' import type { AnyClientTool, Interrupt, RunFinishedEvent, + SchemaInput, StreamChunk, ToolCallStartEvent, ToolResultOutcome, @@ -427,6 +429,14 @@ export interface FromUIMessageStreamOptions { * one of them with `needsApproval: true` is a `tool-approval` interrupt. */ tools?: ReadonlyArray + /** + * The schema of the route's `Output.object({ schema })`, as passed to + * `useChat({ outputSchema })`. The answer text becomes a `structured-output` + * part with partial objects while it streams. At the end the text is + * checked against the schema: a valid answer is the part's `data`, and an + * invalid one ends the run with a RUN_ERROR. + */ + outputSchema?: SchemaInput } /** @@ -454,7 +464,12 @@ export interface FromUIMessageStreamOptions { */ export async function* fromUIMessageStream( stream: ReadableStream, - { threadId, runId, tools: clientTools }: FromUIMessageStreamOptions, + { + threadId, + runId, + tools: clientTools, + outputSchema, + }: FromUIMessageStreamOptions, ): AsyncGenerator { let messageId = `${runId}-assistant` let ended = false @@ -478,6 +493,40 @@ export async function* fromUIMessageStream( const approvals: Array = [] // ponytail: shallow merge, where the AI SDK merges nested objects too. let messageMetadata: unknown + // The answer text, once text starts with `outputSchema` set. + // ponytail: all text of the stream is the answer, so text in a tool-call + // step joins it. Start the answer per step if a route sends such text. + let structuredRaw: string | undefined + + /** + * `structured-output.complete` with the checked answer, or a RUN_ERROR + * when the text is not JSON or fails the schema. + */ + async function structuredEnd(): Promise { + if (structuredRaw === undefined) return undefined + const object = parseJson(structuredRaw) + const result = + object === undefined + ? undefined + : await validateWithStandardSchema(outputSchema, object) + if (result?.success) { + return { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { object: result.data, raw: structuredRaw, messageId }, + } + } + return { + type: EventType.RUN_ERROR, + code: 'structured-output-validation-failed', + message: + result === undefined + ? 'The structured output is not valid JSON' + : `The structured output does not match the schema: ${result.issues + .map((issue) => issue.message) + .join(', ')}`, + } + } function mergeMessageMetadata(value: unknown) { if (value === undefined) return @@ -649,6 +698,16 @@ export async function* fromUIMessageStream( // Every AI SDK text part belongs to the one assistant message. The // TanStack client starts a new text segment on each START. case 'text-start': + // The TanStack client puts the text after this event in a + // `structured-output` part, as for `chat({ outputSchema })`. + if (outputSchema !== undefined && structuredRaw === undefined) { + structuredRaw = '' + yield { + type: EventType.CUSTOM, + name: 'structured-output.start', + value: { messageId }, + } + } yield { type: EventType.TEXT_MESSAGE_START, messageId, @@ -656,6 +715,7 @@ export async function* fromUIMessageStream( } break case 'text-delta': + if (structuredRaw !== undefined) structuredRaw += chunk.delta yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId, @@ -873,6 +933,9 @@ export async function* fromUIMessageStream( chunk.finishReason === undefined ? undefined : FROM_AI_SDK_FINISH[chunk.finishReason] + const end = await structuredEnd() + if (end) yield end + if (end?.type === EventType.RUN_ERROR) return yield* finished( runMetadata(finishReason == null ? undefined : { finishReason }), ) @@ -887,6 +950,9 @@ export async function* fromUIMessageStream( } yield* endTools() + const end = await structuredEnd() + if (end) yield end + if (end?.type === EventType.RUN_ERROR) return yield* finished(runMetadata()) } diff --git a/packages/ai-ui-message-stream/tests/client-extras.test.ts b/packages/ai-ui-message-stream/tests/client-extras.test.ts new file mode 100644 index 0000000000..ec26bb2704 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/client-extras.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType } from '@tanstack/ai' +import { ChatClient } from '@tanstack/ai-client' +import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import { convertToModelMessages } from 'ai' +import { z } from 'zod' +import { + fromAISDKMessages, + fromChatTransport, + fromUIMessageStream, +} from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +/** A transport that answers request N with `turns[N]`, after `gate` if set. */ +function fakeTransport( + turns: Array>, + gate?: Promise, +) { + const requests: Array> = [] + const transport: ChatTransport = { + async sendMessages({ messages }) { + requests.push(messages) + const turn = turns[requests.length - 1] ?? [] + if (requests.length === 1) await gate + return chunkStream(turn) + }, + reconnectToStream: () => Promise.resolve(null), + } + return { requests, transport } +} + +/** One AI SDK reply with message id `messageId`, streaming `deltas`. */ +function textTurn( + messageId: string, + ...deltas: Array +): Array { + return [ + { type: 'start', messageId }, + { type: 'text-start', id: '0' }, + ...deltas.map( + (delta): UIMessageChunk => ({ + type: 'text-delta', + id: '0', + delta, + }), + ), + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ] +} + +const recipe = z.object({ name: z.string(), steps: z.array(z.string()) }) + +async function collect( + list: Array, + outputSchema?: typeof recipe, +) { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(list), { + threadId: 't', + runId: 'r', + outputSchema, + })) + out.push(event) + for (const event of out) EventSchemas.parse(event) + return out +} + +describe('AI SDK server, TanStack client: client features', () => { + it('shows an Output.object answer as a typed structured-output part', async () => { + const { transport } = fakeTransport([ + textTurn('a-1', '{"name":"Pan', 'cakes","steps":["Mix",', '"Fry"]}'), + ]) + const partials: Array = [] + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport, { outputSchema: recipe }), + onMessagesChange: (messages) => { + const part = messages + .at(-1) + ?.parts.find((p) => p.type === 'structured-output') + if (part?.status === 'streaming') partials.push(part.partial) + }, + }) + + await client.sendMessage('A recipe') + expect(partials).toContainEqual({ name: 'Pan' }) + expect(partials).toContainEqual({ name: 'Pancakes', steps: ['Mix'] }) + const assistant = client.getMessages().at(-1) + expect(assistant?.parts).toEqual([ + { + type: 'structured-output', + status: 'complete', + raw: '{"name":"Pancakes","steps":["Mix","Fry"]}', + data: { name: 'Pancakes', steps: ['Mix', 'Fry'] }, + partial: { name: 'Pancakes', steps: ['Mix', 'Fry'] }, + }, + ]) + }) + + it('wraps the answer text in structured-output events', async () => { + const out = await collect( + textTurn('a-1', '{"name":"A","steps":[]}'), + recipe, + ) + expect( + out.map((e) => (e.type === EventType.CUSTOM ? e.name : e.type)), + ).toEqual([ + EventType.RUN_STARTED, + 'structured-output.start', + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + 'structured-output.complete', + EventType.RUN_FINISHED, + ]) + expect( + out.find( + (e) => + e.type === EventType.CUSTOM && + e.name === 'structured-output.complete', + ), + ).toMatchObject({ + value: { object: { name: 'A', steps: [] }, messageId: 'a-1' }, + }) + }) + + it('ends with a RUN_ERROR when the answer fails the schema', async () => { + for (const text of ['{"name":1}', 'not json']) { + const out = await collect(textTurn('a-1', text), recipe) + expect(out.at(-1)).toMatchObject({ + type: EventType.RUN_ERROR, + code: 'structured-output-validation-failed', + }) + expect(out.some((e) => e.type === EventType.RUN_FINISHED)).toBe(false) + } + }) + + it('keeps plain text without outputSchema', async () => { + const out = await collect(textTurn('a-1', '{"name":"A"}')) + expect(out.some((e) => e.type === EventType.CUSTOM)).toBe(false) + }) + + it('queues a message sent while a reply streams and sends it after', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const { requests, transport } = fakeTransport( + [textTurn('a-1', 'First.'), textTurn('a-2', 'Second.')], + gate, + ) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + }) + + const first = client.sendMessage('one') + await vi.waitFor(() => expect(client.getIsLoading()).toBe(true)) + await client.sendMessage('two') + expect(client.getQueue().map((q) => q.content)).toEqual(['two']) + expect(requests).toHaveLength(1) + + release() + await first + await vi.waitFor(() => expect(requests).toHaveLength(2)) + await vi.waitFor(() => expect(client.getIsLoading()).toBe(false)) + expect(client.getQueue()).toEqual([]) + expect( + requests[1]!.map((m) => + m.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join(''), + ), + ).toEqual(['one', 'First.', 'two']) + }) + + it('emits devtools events for a bridged run', async () => { + const { transport } = fakeTransport([textTurn('a-1', 'Hi.')]) + // The bridge useChat installs, as the framework hooks pass it. + let bridge: ChatDevtoolsBridge | undefined + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + devtoolsBridgeFactory: (options) => + (bridge = createChatDevtoolsBridge(options)), + }) + if (!bridge) throw new Error('expected a devtools bridge') + const messageSent = vi.spyOn(bridge.events, 'messageSent') + const textUpdated = vi.spyOn(bridge.events, 'textUpdated') + const loadingChanged = vi.spyOn(bridge.events, 'loadingChanged') + + await client.sendMessage('Hello') + expect(messageSent).toHaveBeenCalledWith(expect.any(String), 'Hello') + expect(textUpdated).toHaveBeenCalledWith(expect.any(String), 'a-1', 'Hi.') + expect(loadingChanged.mock.calls.map(([loading]) => loading)).toEqual([ + true, + false, + ]) + }) + + it('loads saved AI SDK history and sends it back unchanged', async () => { + const saved: Array = [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', text: 'Weather?' }] }, + { + id: 'a-1', + role: 'assistant', + // As AI SDK useChat saves a two-step answer. + parts: [ + { type: 'step-start' }, + { + type: 'dynamic-tool', + toolName: 'weather', + toolCallId: 'c-1', + input: { city: 'Paris' }, + state: 'output-available', + output: { temp: 20 }, + }, + { type: 'step-start' }, + { type: 'text', text: 'It is 20 degrees.' }, + ], + }, + ] + const { requests, transport } = fakeTransport([textTurn('a-3', 'Yes.')]) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + initialMessages: fromAISDKMessages(saved), + }) + expect(client.getMessages().map((m) => m.id)).toEqual(['u-1', 'a-1']) + + await client.sendMessage('Warm?') + // The leading step-start is dropped. The route's model messages match. + expect(await convertToModelMessages(requests[0]!.slice(0, 2))).toEqual( + await convertToModelMessages(saved), + ) + expect(requests[0]![1]!.parts.map((p) => p.type)).toEqual([ + 'dynamic-tool', + 'step-start', + 'text', + ]) + expect(requests[0]![2]).toMatchObject({ + role: 'user', + parts: [{ type: 'text', text: 'Warm?' }], + }) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/client-tools.test.ts b/packages/ai-ui-message-stream/tests/client-tools.test.ts new file mode 100644 index 0000000000..1cda39a063 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/client-tools.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { convertToModelMessages, streamText, tool } from 'ai' +import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test' +import { z } from 'zod' +import { clientToolsFromAISDKBody, fromChatTransport } from '../src' +import type { ChatTransport, UIMessage } from 'ai' + +const usage = { + inputTokens: { + total: 1, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 1, text: undefined, reasoning: undefined }, +} + +describe('client tools over fromChatTransport', () => { + it('declares the client tools to an AI SDK route, which stops at the call', async () => { + const model = new MockLanguageModelV4({ + doStream: [ + { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getLocation', + input: '{"precise":true}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: undefined }, + usage, + }, + ]), + }, + { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'You are in Sydney.' }, + { type: 'text-end', id: 't' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: undefined }, + usage, + }, + ]), + }, + ], + }) + const bodies: Array = [] + // The AI SDK route, called in process. + const transport: ChatTransport = { + async sendMessages({ messages, body }) { + bodies.push(body) + const result = streamText({ + model, + messages: await convertToModelMessages(messages), + tools: clientToolsFromAISDKBody(body, {}), + }) + return result.toUIMessageStream() + }, + reconnectToStream: () => Promise.resolve(null), + } + const getLocation = vi.fn(() => ({ city: 'Sydney' })) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ + toolDefinition({ + name: 'getLocation', + description: 'Get the user location', + inputSchema: z.object({ precise: z.boolean() }), + }).client(getLocation), + ], + }) + + await client.sendMessage('Where am I?') + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Sydney.'), + ) + + expect(bodies[0]).toMatchObject({ + clientTools: [ + { + name: 'getLocation', + description: 'Get the user location', + parameters: { type: 'object' }, + }, + ], + }) + // The route offered the tool to the model and did not run it. + expect(model.doStreamCalls[0]?.tools).toMatchObject([ + { type: 'function', name: 'getLocation' }, + ]) + expect(getLocation).toHaveBeenCalledWith( + { precise: true }, + expect.anything(), + ) + // The second request carried the client's output to the model. + expect(JSON.stringify(model.doStreamCalls[1]?.prompt)).toContain('Sydney') + }) + + it('lets a server tool win a name clash and rejects a bad declaration', () => { + const server = tool({ + description: 'Server weather', + inputSchema: z.object({}), + execute: () => 'sunny', + }) + const declared = (name: string) => ({ + name, + description: 'Client', + parameters: { type: 'object' }, + }) + const tools = clientToolsFromAISDKBody( + { clientTools: [declared('weather'), declared('__proto__')] }, + { weather: server }, + ) + expect(tools.weather).toBe(server) + expect(Object.keys(tools).sort()).toEqual(['__proto__', 'weather']) + expect(Object.getPrototypeOf(tools)).toBe(Object.prototype) + + expect(clientToolsFromAISDKBody({ id: 'x' }, { weather: server })).toEqual({ + weather: server, + }) + expect(() => clientToolsFromAISDKBody({ clientTools: 'all' }, {})).toThrow() + expect(() => + clientToolsFromAISDKBody({ clientTools: [{ name: 'x' }] }, {}), + ).toThrow() + }) +}) diff --git a/packages/ai-ui-message-stream/tests/reconnect.test.ts b/packages/ai-ui-message-stream/tests/reconnect.test.ts new file mode 100644 index 0000000000..39b0ca6291 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/reconnect.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { StreamReconnectLimitError } from '@tanstack/ai-client' +import { fromChatTransport } from '../src' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +const REPLY: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'One ' }, + { type: 'text-delta', id: 't', delta: 'two ' }, + { type: 'text-delta', id: 't', delta: 'three.' }, + { type: 'text-end', id: 't' }, + { type: 'finish', finishReason: 'stop' }, +] + +/** The reply from the start, failing after `dropAfter` chunks. */ +function replay(dropAfter = Infinity): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index === dropAfter) controller.error(new TypeError('network error')) + else if (index === REPLY.length) controller.close() + else controller.enqueue(REPLY[index++]!) + }, + }) +} + +async function run( + drops: Array, + reconnect: { maxAttempts?: number } = {}, +): Promise<{ text: string; reconnects: number }> { + let reconnects = 0 + const transport: ChatTransport = { + resumeStreamIsReplay: true, + sendMessages: () => Promise.resolve(replay(drops[0])), + reconnectToStream: () => { + reconnects++ + return Promise.resolve(replay(drops[reconnects])) + }, + } + const connection = fromChatTransport(transport, { + reconnect: { initialDelayMs: 1, ...reconnect }, + }) + let text = '' + for await (const event of connection.connect([], undefined, undefined, { + threadId: 'chat-1', + runId: 'run-1', + })) { + if (event.type === 'TEXT_MESSAGE_CONTENT') text += event.delta + } + return { text, reconnects } +} + +describe('fromChatTransport reconnect', () => { + it('reconnects with backoff and skips the replayed prefix', async () => { + // Drops after 3 chunks, then the first replay drops after 4. + const result = await run([3, 4]) + expect(result).toEqual({ text: 'One two three.', reconnects: 2 }) + }) + + it('stops at the limit when reconnects make no progress', async () => { + // Every replay drops before it passes the chunks already delivered. + await expect(run([3, 2, 2, 2], { maxAttempts: 2 })).rejects.toBeInstanceOf( + StreamReconnectLimitError, + ) + }) +}) diff --git a/skills/tanstack-ai-migration/SKILL.md b/skills/tanstack-ai-migration/SKILL.md index 839df34e61..12528d384a 100644 --- a/skills/tanstack-ai-migration/SKILL.md +++ b/skills/tanstack-ai-migration/SKILL.md @@ -46,7 +46,9 @@ place to stay: finish the port of the other side, then remove it. https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server - Client first: keep the `streamText` route and pass `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })` - as the `connection` of TanStack `useChat`. + as the `connection` of TanStack `useChat`. On the route, + `clientToolsFromAISDKBody(body, serverTools)` reads the client's tools, so + they need no copy there. https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server Tool approvals, client tools, activity, subagents and stream resume cross the From a5764368075859af771c3721adc85eedd5b0c229 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:25:17 +1100 Subject: [PATCH 7/7] docs(ai-ui-message-stream): note that text in a tool step breaks structured output --- docs/migration/tanstack-client-ai-sdk-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md index 4930d5e8af..edd0cae07a 100644 --- a/docs/migration/tanstack-client-ai-sdk-server.md +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -221,7 +221,7 @@ export function Recipe() { } ``` -If the text is not valid JSON for the schema, the run ends with an error. +If the text is not valid JSON for the schema, the run ends with an error. All text of the reply counts as the answer, so a route that also writes text in a step that calls tools fails the check. ## 6. Load a saved chat