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/.changeset/ai-ui-message-stream.md b/.changeset/ai-ui-message-stream.md
new file mode 100644
index 0000000000..9d7074aced
--- /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`, 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 })`.
+
+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..c88cfa36e3 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -1951,13 +1951,28 @@
"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": "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
new file mode 100644
index 0000000000..cc20922e14
--- /dev/null
+++ b/docs/migration/ai-sdk-client-tanstack-server.md
@@ -0,0 +1,262 @@
+---
+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).
+
+> [!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
+
+
+
+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 (
+
+ )
+}
+```
+
+## 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()`.
+
+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..f4d689fa95
--- /dev/null
+++ b/docs/migration/ai-sdk-compatibility.md
@@ -0,0 +1,165 @@
+---
+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 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
+
+### ⬇️ Reply: `chat()` events to AI SDK chunks
+
+| 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. |
+| `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. |
+| `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
+
+| 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 |
+| 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 |
+| `system` messages | | ❌ Dropped. `streamText` rejects them unless `allowSystemInMessages` is set. |
+| `activity` messages | | ❌ Dropped |
+| `subagent`, `ui-resource` | | ❌ Dropped |
diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md
index 5bb016b038..92e105c9cc 100644
--- a/docs/migration/migration-from-vercel-ai.md
+++ b/docs/migration/migration-from-vercel-ai.md
@@ -25,6 +25,12 @@ 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. 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
### 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..edd0cae07a
--- /dev/null
+++ b/docs/migration/tanstack-client-ai-sdk-server.md
@@ -0,0 +1,295 @@
+---
+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. 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.
+
+## 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 (
+
+ )
+}
+```
+
+`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 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.
+
+```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. 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. 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
+
+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-`.
+
+## 8. Resume a stream
+
+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`):
+
+```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.
+- 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).
+
+## 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-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
new file mode 100644
index 0000000000..e496c4d4b6
--- /dev/null
+++ b/packages/ai-ui-message-stream/README.md
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+# @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`.
+
+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
+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, 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.
+
+## 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..c25632ba95
--- /dev/null
+++ b/packages/ai-ui-message-stream/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@tanstack/ai-ui-message-stream",
+ "version": "0.0.1",
+ "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": {
+ "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"
+ },
+ "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/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/data-parts.ts b/packages/ai-ui-message-stream/src/data-parts.ts
new file mode 100644
index 0000000000..eecc8c2b80
--- /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
+
+export 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..15c5e1ca8e
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/index.ts
@@ -0,0 +1,188 @@
+import { modelMessagesToUIMessages } from '@tanstack/ai'
+import type { ModelMessage, UIMessage } from '@tanstack/ai'
+import { createReconnectTracker } from '@tanstack/ai-client'
+import type {
+ ConnectConnectionAdapter,
+ ReconnectOptions,
+} 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 { clientToolsFromAISDKBody } from './client-tools'
+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 ReconnectRequest = Parameters<
+ ChatTransport['reconnectToStream']
+>[0]
+
+/**
+ * Read `stream`, and when the connection drops, read the rest through
+ * `transport.reconnectToStream`, with the backoff and limits of the TanStack
+ * HTTP connections. A replayed prefix is skipped.
+ */
+function reconnecting(
+ transport: ChatTransport,
+ stream: ReadableStream,
+ request: ReconnectRequest,
+ reconnect: ReconnectOptions | undefined,
+): ReadableStream {
+ const tracker = createReconnectTracker(reconnect)
+ let reader = stream.getReader()
+ let delivered = 0
+ let skip = 0
+ let deliveredAtLastDrop = 0
+ 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 (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
+ }
+ }
+ },
+ 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, 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,
+ 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,
+ messageId: undefined,
+ messages: toAISDKMessages(asUIMessages(messages)),
+ abortSignal,
+ ...(runContext.headers && { headers: runContext.headers }),
+ ...(body && { body }),
+ })
+ yield* fromUIMessageStream(
+ 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.
+ ...(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, reconnect),
+ {
+ threadId,
+ runId,
+ tools,
+ outputSchema,
+ },
+ )
+ },
+ }),
+ }
+}
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..8bd651f94f
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/messages.ts
@@ -0,0 +1,417 @@
+import { isProviderExecutedToolCall } from '@tanstack/ai'
+import { getToolName, isToolUIPart } from 'ai'
+import type {
+ ContentPart,
+ ContentPartSource,
+ MessagePart,
+ ProviderExecutedToolMetadata,
+ 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',
+ // `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') {
+ 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, 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) => {
+ 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 {
+ 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
+}
+
+/** 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':
+ 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)
+ push({
+ type: 'reasoning',
+ text: part.content,
+ ...(isProviderMetadata(providerMetadata) && { providerMetadata }),
+ })
+ break
+ }
+ case 'image':
+ case 'audio':
+ case 'video':
+ case 'document':
+ 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 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) {
+ push({
+ ...base,
+ state: 'output-denied',
+ approval: {
+ ...fields.approval,
+ id: part.approval.id,
+ approved: false,
+ },
+ })
+ } else if (result?.state === 'error') {
+ push({
+ ...base,
+ ...resultMetadata,
+ state: 'output-error',
+ errorText:
+ result.error ??
+ (typeof result.content === 'string'
+ ? result.content
+ : JSON.stringify(result.content)),
+ })
+ } else if (result !== undefined) {
+ push({
+ ...base,
+ ...resultMetadata,
+ state: 'output-available',
+ output: toolOutput(result.content),
+ })
+ } else if (part.output !== undefined) {
+ 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. 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) {
+ push({
+ ...base,
+ state: 'approval-requested',
+ approval: { ...fields.approval, id: part.approval.id },
+ })
+ } else {
+ push({ ...base, state: 'input-available' })
+ }
+ break
+ }
+ default:
+ // Results are joined with their calls above. Activity, subagent 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, 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' || 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),
+ })
+ }
+ 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..7ff6ca5e4e
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/stream.ts
@@ -0,0 +1,977 @@
+import {
+ EventType,
+ INTERRUPT_BINDING_VERSION,
+ RUN_ACCEPTED_EVENT,
+ getProviderExecutedMetadata,
+ hashSchemaInput,
+ normalizeApprovalSchema,
+ readInterruptBinding,
+ validateWithStandardSchema,
+ withInterruptBinding,
+} from '@tanstack/ai'
+import type {
+ AnyClientTool,
+ Interrupt,
+ RunFinishedEvent,
+ SchemaInput,
+ StreamChunk,
+ ToolCallStartEvent,
+ ToolResultOutcome,
+} from '@tanstack/ai'
+import type { FinishReason, ProviderMetadata, UIMessageChunk } from 'ai'
+import { DataParts, dataChunkEvent, isDataChunk, isRecord } from './data-parts'
+import type { AISDKApprovalFields, AISDKToolCallMetadata } from './messages'
+
+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
+}
+
+/** 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
+ * 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()
+ // 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
+ started = true
+ yield messageId === undefined
+ ? { type: 'start' }
+ : { 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) {
+ 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* startStep()
+ 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* startStep()
+ 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)
+ }
+ // 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)
+ 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)
+ 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
+ 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,
+ ...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
+ }
+ 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: {
+ 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]
+ // 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* finishStep()
+ 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
+ /**
+ * 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
+}
+
+/**
+ * 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`. 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,
+ {
+ threadId,
+ runId,
+ tools: clientTools,
+ outputSchema,
+ }: 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
+ 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
+ // 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
+ 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,
+ ): Generator {
+ yield {
+ type: EventType.RUN_FINISHED,
+ threadId,
+ runId,
+ outcome:
+ approvals.length > 0
+ ? { type: 'interrupt', interrupts: approvals }
+ : { type: 'success' },
+ ...(metadata && { metadata }),
+ }
+ }
+
+ /**
+ * 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,
+ input: unknown,
+ ): Generator {
+ let tool = tools.get(toolCallId)
+ if (!tool) {
+ 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) {
+ 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) {
+ 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':
+ // 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,
+ role: 'assistant',
+ }
+ break
+ case 'text-delta':
+ if (structuredRaw !== undefined) structuredRaw += chunk.delta
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ delta: chunk.delta,
+ }
+ 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': {
+ 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': {
+ 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,
+ toolCallId: chunk.toolCallId,
+ delta: chunk.inputTextDelta,
+ }
+ break
+ }
+ case 'tool-input-available':
+ inputs.set(chunk.toolCallId, {
+ toolName: chunk.toolName,
+ input: chunk.input,
+ })
+ yield* callMetadata(chunk)
+ yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input)
+ 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 = {
+ 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* endTools(chunk.toolCallId)
+ yield toolError(
+ chunk.toolCallId,
+ 'The user denied this tool call',
+ 'denied',
+ )
+ 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)
+ 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* resultMetadata(chunk)
+ yield* endTools(chunk.toolCallId)
+ 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* resultMetadata(chunk)
+ yield* endTools(chunk.toolCallId)
+ yield toolError(chunk.toolCallId, chunk.errorText)
+ break
+
+ case 'error':
+ ended = true
+ yield { type: EventType.RUN_ERROR, message: chunk.errorText }
+ break
+ 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]
+ const end = await structuredEnd()
+ if (end) yield end
+ if (end?.type === EventType.RUN_ERROR) return
+ yield* finished(
+ runMetadata(finishReason == null ? undefined : { finishReason }),
+ )
+ break
+ }
+
+ default:
+ if (isDataChunk(chunk)) yield dataChunkEvent(chunk)
+ break
+ }
+ if (ended) return
+ }
+
+ yield* endTools()
+ const end = await structuredEnd()
+ if (end) yield end
+ if (end?.type === EventType.RUN_ERROR) return
+ yield* finished(runMetadata())
+}
+
+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..71aecca01b
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/approvals.test.ts
@@ -0,0 +1,389 @@
+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).toMatchObject({
+ 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: 'step-start' },
+ {
+ type: 'tool-deleteFile',
+ state: 'output-available',
+ output: { deleted: true },
+ },
+ { type: 'step-start' },
+ { 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: 'step-start' },
+ {
+ type: 'tool-deleteFile',
+ state: 'output-denied',
+ approval: { approved: false },
+ },
+ { type: 'step-start' },
+ { 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: 'step-start' },
+ { 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/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/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/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/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/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..0e96d1047c
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts
@@ -0,0 +1,415 @@
+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: 'step-start' },
+ {
+ 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: '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?' } },
+ ])
+
+ // 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: 'step-start' },
+ { 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',
+ 'STEP_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',
+ '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 = {
+ 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..12528d384a 100644
--- a/skills/tanstack-ai-migration/SKILL.md
+++ b/skills/tanstack-ai-migration/SKILL.md
@@ -36,6 +36,24 @@ 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. 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
+ `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`. 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
+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 (
+