+ )
+}
+```
+
+## 4. Add tools
+
+Server tools run in `chat()`. The client shows each call as a `tool-` part with its input and output.
+
+- **Client tool:** a tool definition with no `.server()` implementation. `chat()` stops at the call, and the AI SDK client runs it in `onToolCall`.
+- **Tool approval:** set `needsApproval: true`. `chat()` stops before the tool runs, and the client shows the part in the `approval-requested` state.
+
+```typescript
+import { chat, toolDefinition } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import {
+ chatParamsFromAISDKRequest,
+ toUIMessageStream,
+} from '@tanstack/ai-ui-message-stream'
+import { createUIMessageStreamResponse } from 'ai'
+import { z } from 'zod'
+
+const getWeather = toolDefinition({
+ name: 'getWeather',
+ description: 'Get the weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+}).server(({ city }) => ({ city, temperature: 72 }))
+
+const bookFlight = toolDefinition({
+ name: 'bookFlight',
+ description: 'Book a flight',
+ inputSchema: z.object({ to: z.string() }),
+ needsApproval: true,
+}).server(({ to }) => ({ booked: to }))
+
+export async function POST(request: Request) {
+ const params = await chatParamsFromAISDKRequest(request)
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ tools: [getWeather, bookFlight],
+ ...params,
+ })
+
+ return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) })
+}
+```
+
+On the client, answer the approval with `addToolApprovalResponse`. Then `sendAutomaticallyWhen` sends the answer to the server, and `chat()` runs or skips the tool.
+
+The server keeps the id of the paused run in the message metadata, under `tanstack.runId`. If you validate metadata with `messageMetadataSchema`, allow that key.
+
+```tsx
+import { useChat } from '@ai-sdk/react'
+import {
+ DefaultChatTransport,
+ lastAssistantMessageIsCompleteWithApprovalResponses,
+} from 'ai'
+
+export function Chat() {
+ const { messages, addToolApprovalResponse } = useChat({
+ transport: new DefaultChatTransport({ api: '/api/chat' }),
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
+ })
+
+ return messages.map((m) =>
+ m.parts.map((part) =>
+ part.type === 'tool-bookFlight' && part.state === 'approval-requested' ? (
+
+ ) : null,
+ ),
+ )
+}
+```
+
+## 5. Show activity, state and subagents
+
+`toUIMessageStream` sends each of these as a data part. The client replaces a data part in place when it gets a new value for the same id.
+
+- **Activity:** a `data-` part with the activity id.
+- **Shared state:** a `data-state` part with the full state.
+- **Subagent:** a `data-subagent` part per child, with its name, status and text. Its data has the type `SubagentData`.
+
+Type the data parts on `useChat` to read them:
+
+```tsx
+import { useChat } from '@ai-sdk/react'
+import { DefaultChatTransport } from 'ai'
+import type { SubagentData } from '@tanstack/ai-ui-message-stream'
+import type { UIMessage } from 'ai'
+
+type ChatMessage = UIMessage
+
+export function Subagents() {
+ const { messages } = useChat({
+ transport: new DefaultChatTransport({ api: '/api/chat' }),
+ })
+
+ return messages.flatMap((m) =>
+ m.parts.map((part) =>
+ part.type === 'data-subagent' ? (
+
+ {part.data.name}: {part.data.status}
+
+ ) : null,
+ ),
+ )
+}
+```
+
+The AI SDK client does not send state back. A change to the state on the client does not reach `chat()`.
+
+## 6. Resume a stream after a reload
+
+AI SDK `useChat({ resume: true })` asks `GET /api/chat//stream` for the active run when the page loads. To answer it, record each run in a durability log, and keep a record of the active run of each chat. This example uses the runs store of [`@tanstack/ai-persistence`](../persistence/overview):
+
+```typescript
+import { chat, memoryStream } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { withPersistence } from '@tanstack/ai-persistence'
+import {
+ chatParamsFromAISDKRequest,
+ resumeUIMessageStreamResponse,
+ toResumableUIMessageStreamResponse,
+} from '@tanstack/ai-ui-message-stream'
+import { persistence } from './persistence'
+
+// POST /api/chat
+export async function POST(request: Request) {
+ const params = await chatParamsFromAISDKRequest(request)
+ const runId = crypto.randomUUID()
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ runId,
+ middleware: [withPersistence(persistence)],
+ ...params,
+ })
+
+ return toResumableUIMessageStreamResponse(stream, memoryStream({ runId }))
+}
+
+// GET /api/chat/:id/stream
+export async function GET(request: Request) {
+ const chatId = new URL(request.url).pathname.split('/').at(-2) ?? ''
+ const run = await persistence.stores.runs.findActiveRun(chatId)
+ return resumeUIMessageStreamResponse(run && memoryStream({ runId: run.runId }))
+}
+```
+
+- `toResumableUIMessageStreamResponse` writes the run to the log. The run continues when the client goes away.
+- `resumeUIMessageStreamResponse` replays the run from its start. With no active run, it returns 204, and the client does nothing.
+- `memoryStream` keeps the log in the memory of one process. For more than one server, use [`@tanstack/ai-durable-stream`](../resumable-streams/overview).
+
+## What does not go through
+
+- A change to shared state on the client. The AI SDK client does not send state.
+- The `reason` text of an approval answer.
+- A `stop()` on the client stops the reading, not the run. To stop the run, pass an `abortController` to `chat()`.
+
+## Done
+
+Your AI SDK `useChat` UI streams replies, tools, approvals, activity and subagents from `chat()`, and it continues a reply after a reload.
diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md
index 5bb016b038..c11edf4f7e 100644
--- a/docs/migration/migration-from-vercel-ai.md
+++ b/docs/migration/migration-from-vercel-ai.md
@@ -25,6 +25,11 @@ Move a Vercel AI SDK app (`ai` and `@ai-sdk/*`) to TanStack AI by swapping the p
Do the steps below in order. The "Before" samples are AI SDK v5 and v6 code. Where v4 is different, the page says so. For every other API, see [Vercel AI SDK: full API mapping](./migration-from-vercel-ai-advanced).
+To move one side at a time, connect the two SDKs over the AI SDK UI message stream:
+
+- Server first: [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server).
+- Client first: [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server).
+
## Port a chat app
### 1. Swap the packages
diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md
new file mode 100644
index 0000000000..5fcd3b3dbb
--- /dev/null
+++ b/docs/migration/tanstack-client-ai-sdk-server.md
@@ -0,0 +1,188 @@
+---
+title: TanStack useChat with an AI SDK server
+id: tanstack-client-ai-sdk-server
+order: 4
+description: "Point TanStack AI useChat at a Vercel AI SDK route. Wrap an AI SDK ChatTransport with fromChatTransport and pass it as the connection. Your AI SDK server does not change."
+keywords:
+ - tanstack ai
+ - vercel ai sdk
+ - useChat
+ - streamText
+ - ui message stream
+ - fromChatTransport
+ - tool approval
+ - resumable streams
+ - migration
+---
+
+Point TanStack AI `useChat` at a Vercel AI SDK route. Wrap an AI SDK `ChatTransport` with `fromChatTransport`, and pass it as the `connection`. Your `streamText` route does not change.
+
+Use this page to move the client first, for example to use `useChat` in a framework that the AI SDK does not cover. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server).
+
+## 1. Install the packages
+
+
+
+react: @tanstack/ai-react @tanstack/ai-ui-message-stream ai
+vue: @tanstack/ai-vue @tanstack/ai-ui-message-stream ai
+solid: @tanstack/ai-solid @tanstack/ai-ui-message-stream ai
+svelte: @tanstack/ai-svelte @tanstack/ai-ui-message-stream ai
+preact: @tanstack/ai-preact @tanstack/ai-ui-message-stream ai
+angular: @tanstack/ai-angular @tanstack/ai-ui-message-stream ai
+octane: @tanstack/ai-octane @tanstack/ai-ui-message-stream ai octane
+vanilla: @tanstack/ai-client @tanstack/ai-ui-message-stream ai
+
+
+
+## 2. Connect useChat to the route
+
+```tsx
+import { useState } from 'react'
+import { useChat } from '@tanstack/ai-react'
+import { fromChatTransport } from '@tanstack/ai-ui-message-stream'
+import { DefaultChatTransport } from 'ai'
+
+const connection = fromChatTransport(
+ new DefaultChatTransport({ api: '/api/chat' }),
+)
+
+export function Chat() {
+ const [input, setInput] = useState('')
+ const { messages, sendMessage } = useChat({
+ connection,
+ threadId: 'support-chat',
+ })
+
+ return (
+
+ )
+}
+```
+
+`DefaultChatTransport` sends the request and reads the stream, as it does for the AI SDK hook. Its options work the same, for example `headers`, `credentials` and `prepareSendMessagesRequest`. The `threadId` goes to the route as the chat `id`.
+
+## 3. Keep the server
+
+Your AI SDK route works as it is:
+
+```typescript
+import { openai } from '@ai-sdk/openai'
+import { convertToModelMessages, streamText } from 'ai'
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const result = streamText({
+ model: openai('gpt-6.1-sol'),
+ messages: await convertToModelMessages(messages),
+ })
+
+ return result.toUIMessageStreamResponse()
+}
+```
+
+## 4. Run client tools and approve tool calls
+
+Define the tools of the route on the client too, with the same names. Pass them to `useChat` and to `fromChatTransport`:
+
+- **Client tool:** give it a `.client()` implementation. When the route stops at the call, the client runs the tool and sends the output back.
+- **Tool approval:** a tool that needs approval on the route shows up in `interrupts`. Resolve it with `interrupt.resolveInterrupt(true)` or `false`. The answer goes back to the route, and `streamText` runs or skips the tool.
+
+```tsx
+import { toolDefinition } from '@tanstack/ai'
+import { useChat } from '@tanstack/ai-react'
+import { fromChatTransport } from '@tanstack/ai-ui-message-stream'
+import { DefaultChatTransport } from 'ai'
+import { z } from 'zod'
+
+const getLocation = toolDefinition({
+ name: 'getLocation',
+ description: 'Get the location of the user',
+ inputSchema: z.object({}),
+}).client(() => ({ city: 'Sydney' }))
+
+const bookFlight = toolDefinition({
+ name: 'bookFlight',
+ description: 'Book a flight',
+ inputSchema: z.object({ to: z.string() }),
+ needsApproval: true,
+})
+
+const tools = [getLocation, bookFlight] as const
+
+const connection = fromChatTransport(
+ new DefaultChatTransport({ api: '/api/chat' }),
+ { tools },
+)
+
+export function Chat() {
+ const { interrupts } = useChat({
+ connection,
+ threadId: 'support-chat',
+ tools,
+ })
+
+ return interrupts.map((interrupt) =>
+ interrupt.kind === 'tool-approval' ? (
+
+ ) : null,
+ )
+}
+```
+
+## 5. Show data parts as activity
+
+A data part with an `id` (`writer.write({ type: 'data-weather', id, data })` on the route) arrives as an activity message of type `weather`. A new value for the same id replaces it. A transient data part, or one with no `id`, arrives as a custom event named `data-`.
+
+## 6. Resume a stream
+
+A dropped connection needs nothing. The connection calls `reconnectToStream` on the transport, and the reply continues.
+
+To continue a reply after a page reload, add client persistence, and pass the `threadId` to `fromChatTransport`. The route must support the AI SDK resume `GET` (`/api/chat//stream`):
+
+```tsx
+import { localStoragePersistence, useChat } from '@tanstack/ai-react'
+import { fromChatTransport } from '@tanstack/ai-ui-message-stream'
+import { DefaultChatTransport } from 'ai'
+
+const threadId = 'support-chat'
+
+const connection = fromChatTransport(
+ new DefaultChatTransport({ api: '/api/chat' }),
+ { threadId },
+)
+
+export function useSupportChat() {
+ return useChat({
+ connection,
+ threadId,
+ persistence: localStoragePersistence(),
+ })
+}
+```
+
+Use one connection for each thread. The rejoin after a reload needs a transport that replays the reply from its start, as `DefaultChatTransport` does.
+
+## What does not go through
+
+- AG-UI shared state. The AI SDK has no state.
+- Sources, generated files and message metadata.
+
+## Done
+
+TanStack `useChat` streams replies, runs tools, asks for approvals, and continues after a reload against your AI SDK route.
diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md
new file mode 100644
index 0000000000..3db7787123
--- /dev/null
+++ b/packages/ai-ui-message-stream/README.md
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+# @tanstack/ai-ui-message-stream
+
+Connect TanStack AI to the Vercel AI SDK UI message stream, the wire format of the AI SDK `useChat` hook.
+
+- Serve AI SDK `useChat` from TanStack AI `chat()`.
+- Call an AI SDK `streamText` route from TanStack AI `useChat`.
+
+## Installation
+
+```bash
+pnpm add @tanstack/ai-ui-message-stream @tanstack/ai ai
+```
+
+## AI SDK client, TanStack AI server
+
+```ts
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import {
+ chatParamsFromAISDKRequest,
+ toUIMessageStream,
+} from '@tanstack/ai-ui-message-stream'
+import { createUIMessageStreamResponse } from 'ai'
+
+export async function POST(request: Request) {
+ const params = await chatParamsFromAISDKRequest(request)
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ ...params,
+ })
+
+ return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) })
+}
+```
+
+## TanStack AI client, AI SDK server
+
+```tsx
+import { useChat } from '@tanstack/ai-react'
+import { fromChatTransport } from '@tanstack/ai-ui-message-stream'
+import { DefaultChatTransport } from 'ai'
+
+const connection = fromChatTransport(
+ new DefaultChatTransport({ api: '/api/chat' }),
+)
+
+export function Chat() {
+ const { messages, sendMessage } = useChat({
+ connection,
+ threadId: 'support-chat',
+ })
+ // render messages
+}
+```
+
+## API
+
+| Export | Direction |
+| --- | --- |
+| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included |
+| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream |
+| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin |
+| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` |
+| `fromChatTransport(transport, { threadId, tools })` | AI SDK `ChatTransport` to a TanStack `useChat` connection |
+| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction |
+| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events |
+
+Text, reasoning, tools, tool approvals, activity, subagents and stream resume cross in both directions where the other side has them. Shared state goes from a TanStack server to an AI SDK client only, because the AI SDK client does not send state.
+
+## Docs
+
+- [AI SDK useChat with a TanStack AI server](https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server)
+- [TanStack useChat with an AI SDK server](https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server)
diff --git a/packages/ai-ui-message-stream/package.json b/packages/ai-ui-message-stream/package.json
new file mode 100644
index 0000000000..9a7e092e1c
--- /dev/null
+++ b/packages/ai-ui-message-stream/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@tanstack/ai-ui-message-stream",
+ "version": "0.0.1",
+ "description": "Connect TanStack AI to the Vercel AI SDK UI message stream: serve AI SDK useChat from chat(), or call an AI SDK route from TanStack useChat.",
+ "author": "",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/ai.git",
+ "directory": "packages/ai-ui-message-stream"
+ },
+ "keywords": [
+ "ai",
+ "tanstack",
+ "ai-sdk",
+ "ag-ui",
+ "ui-message-stream",
+ "usechat"
+ ],
+ "type": "module",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/esm/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "build": "vite build",
+ "clean": "premove ./build ./dist",
+ "lint:fix": "oxlint src --type-aware --fix",
+ "test:build": "publint --strict",
+ "test:oxlint": "oxlint src --type-aware",
+ "test:coverage": "vitest run --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary",
+ "test:lib": "vitest run",
+ "test:lib:dev": "pnpm test:lib --watch",
+ "test:types": "tsc"
+ },
+ "dependencies": {
+ "fast-json-patch": "^3.1.1"
+ },
+ "peerDependencies": {
+ "@tanstack/ai": "workspace:^",
+ "@tanstack/ai-client": "workspace:^",
+ "ai": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/ai-client": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@ag-ui/core": "1.0.0",
+ "@tanstack/ai": "workspace:*",
+ "@tanstack/ai-client": "workspace:*",
+ "@vitest/coverage-v8": "4.1.10",
+ "ai": "7.0.148",
+ "zod": "^4.2.0"
+ }
+}
diff --git a/packages/ai-ui-message-stream/src/data-parts.ts b/packages/ai-ui-message-stream/src/data-parts.ts
new file mode 100644
index 0000000000..8d2ec2c244
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/data-parts.ts
@@ -0,0 +1,198 @@
+import jsonPatch from 'fast-json-patch'
+import { EventType } from '@tanstack/ai'
+import type { StreamChunk, SubagentHandleData } from '@tanstack/ai'
+import type { UIMessageChunk } from 'ai'
+
+type ActivitySnapshot = Extract
+type JsonPatch = Extract['delta']
+
+/**
+ * The data of a `data-subagent` part: the TanStack subagent card, with the
+ * child's text in place of its messages.
+ */
+export interface SubagentData extends Omit<
+ SubagentHandleData,
+ 'messages' | 'parentRunId'
+> {
+ text: string
+}
+
+/** An AI SDK `data-` chunk. */
+export type DataChunk = Extract
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Turns AG-UI activity, state and subagent events into AI SDK data chunks.
+ * Each chunk has an `id`, so the AI SDK client replaces the data part in place
+ * when the same `data-` and `id` come again. Make one per stream.
+ *
+ * - `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA`: `data-`, `id` is the
+ * activity `messageId`, data is the full content.
+ * - `STATE_SNAPSHOT` / `STATE_DELTA`: `data-state`, `id: 'state'`, data is the
+ * full state.
+ * - Events with a `subagentRunId`: `data-subagent`, `id` is the
+ * `subagentRunId`, data is a {@link SubagentData}.
+ */
+export class DataParts {
+ private readonly activities = new Map()
+ private state: unknown = {}
+ private readonly subagents = new Map()
+
+ /** The data chunks for one event. Empty for any other event. */
+ chunks(event: StreamChunk): Array {
+ if ('subagentRunId' in event && event.subagentRunId !== undefined)
+ return this.subagent(event, event.subagentRunId)
+
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only activity and state events have data parts.
+ switch (event.type) {
+ case EventType.ACTIVITY_SNAPSHOT: {
+ const existing = this.activities.get(event.messageId)
+ if (existing && event.replace === false) return []
+ this.activities.set(event.messageId, event)
+ return [activityChunk(event)]
+ }
+ case EventType.ACTIVITY_DELTA: {
+ // Same rules as the TanStack client: no snapshot, another type, a
+ // failed patch, or content that is no longer an object keep the old
+ // content.
+ const existing = this.activities.get(event.messageId)
+ if (existing?.activityType !== event.activityType) {
+ console.warn(
+ `ACTIVITY_DELTA: No '${event.activityType}' activity '${event.messageId}'`,
+ )
+ return []
+ }
+ const content = patched(existing.content, event.patch)
+ if (!isRecord(content)) return []
+ const next = { ...existing, content }
+ this.activities.set(event.messageId, next)
+ return [activityChunk(next)]
+ }
+ case EventType.STATE_SNAPSHOT:
+ this.state = event.snapshot
+ return [{ type: 'data-state', id: 'state', data: this.state }]
+ case EventType.STATE_DELTA: {
+ const state = patched(this.state, event.delta)
+ if (state === undefined) return []
+ this.state = state
+ return [{ type: 'data-state', id: 'state', data: state }]
+ }
+ default:
+ return []
+ }
+ }
+
+ // ponytail: re-sends the whole card on every child text delta, so the wire
+ // carries O(n²) text for a long child. Throttle or send a text-only delta
+ // part if children get long.
+ private subagent(event: StreamChunk, id: string): Array {
+ let card = this.subagents.get(id)
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- the card shows lifecycle and text only.
+ switch (event.type) {
+ case EventType.SUBAGENT_STARTED:
+ if (card) {
+ // A resume continues the same child.
+ card.status = 'running'
+ delete card.interruptIds
+ delete card.error
+ if (event.metadata !== undefined) card.metadata = event.metadata
+ break
+ }
+ card = {
+ id,
+ name: event.name,
+ ...(event.description !== undefined && {
+ description: event.description,
+ }),
+ status: 'running',
+ ...(event.parentSubagentRunId !== undefined && {
+ parentSubagentRunId: event.parentSubagentRunId,
+ }),
+ ...(event.parentToolCallId !== undefined && {
+ parentToolCallId: event.parentToolCallId,
+ }),
+ ...(event.metadata !== undefined && { metadata: event.metadata }),
+ text: '',
+ }
+ this.subagents.set(id, card)
+ break
+ case EventType.TEXT_MESSAGE_START:
+ // Keep the child's text messages apart. Nothing to show yet.
+ if (card && card.text !== '') card.text += '\n\n'
+ return []
+ case EventType.TEXT_MESSAGE_CONTENT:
+ if (!card) return []
+ card.text += event.delta
+ break
+ case EventType.SUBAGENT_FINISHED:
+ if (!card || card.status === 'error') return []
+ if (event.outcome?.type === 'suspended') {
+ card.status = 'suspended'
+ card.interruptIds = event.outcome.interruptIds ?? []
+ } else {
+ card.status = 'finished'
+ }
+ break
+ case EventType.SUBAGENT_ERROR:
+ if (!card) return []
+ card.status = 'error'
+ card.error = {
+ message: event.message,
+ ...(event.code !== undefined && { code: event.code }),
+ }
+ break
+ default:
+ return []
+ }
+ return [{ type: 'data-subagent', id, data: structuredClone(card) }]
+ }
+}
+
+function activityChunk(event: ActivitySnapshot): UIMessageChunk {
+ return {
+ type: `data-${event.activityType}`,
+ id: event.messageId,
+ data: event.content,
+ }
+}
+
+export function isDataChunk(chunk: UIMessageChunk): chunk is DataChunk {
+ return chunk.type.startsWith('data-')
+}
+
+/**
+ * The AG-UI event for an AI SDK data chunk. A persistent chunk with an `id`
+ * and object data is an activity, so the TanStack client shows it as an
+ * activity message and replaces it when the same `id` comes again. AG-UI
+ * activity content must be an object, so other data, transient chunks and
+ * chunks without an `id` stay CUSTOM events named `data-`.
+ */
+export function dataChunkEvent(chunk: DataChunk): StreamChunk {
+ if (
+ chunk.transient !== true &&
+ chunk.id !== undefined &&
+ isRecord(chunk.data)
+ )
+ return {
+ type: EventType.ACTIVITY_SNAPSHOT,
+ messageId: chunk.id,
+ activityType: chunk.type.slice('data-'.length),
+ content: chunk.data,
+ replace: true,
+ }
+ return { type: EventType.CUSTOM, name: chunk.type, value: chunk.data }
+}
+
+/** `doc` with an RFC 6902 patch applied, or undefined when the patch fails. */
+function patched(doc: unknown, patch: JsonPatch): unknown {
+ try {
+ return jsonPatch.applyPatch(structuredClone(doc), patch, true, false)
+ .newDocument
+ } catch (error) {
+ console.warn('JSON Patch failed', error)
+ return undefined
+ }
+}
diff --git a/packages/ai-ui-message-stream/src/index.ts b/packages/ai-ui-message-stream/src/index.ts
new file mode 100644
index 0000000000..536024ffb2
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/index.ts
@@ -0,0 +1,147 @@
+import { modelMessagesToUIMessages } from '@tanstack/ai'
+import type { ModelMessage, UIMessage } from '@tanstack/ai'
+import type { ConnectConnectionAdapter } from '@tanstack/ai-client'
+import type {
+ ChatTransport,
+ UIMessage as AISDKUIMessage,
+ UIMessageChunk,
+} from 'ai'
+import { toAISDKMessages } from './messages'
+import { fromUIMessageStream } from './stream'
+import type { FromUIMessageStreamOptions } from './stream'
+
+export { fromAISDKMessages, toAISDKMessages } from './messages'
+export { fromUIMessageStream, toUIMessageStream } from './stream'
+export type { FromUIMessageStreamOptions } from './stream'
+export {
+ resumeUIMessageStreamResponse,
+ toResumableUIMessageStreamResponse,
+} from './resume'
+export {
+ chatParamsFromAISDKRequest,
+ chatParamsFromAISDKRequestBody,
+} from './request'
+export type { AISDKChatParams } from './request'
+export type { SubagentData } from './data-parts'
+
+function asUIMessages(
+ messages: Array | Array,
+): Array {
+ const list: ReadonlyArray = messages
+ const ui = list.filter((m): m is UIMessage => 'parts' in m)
+ if (ui.length === list.length) return ui
+ return modelMessagesToUIMessages(
+ list.filter((m): m is ModelMessage => !('parts' in m)),
+ )
+}
+
+type ReconnectOptions = Parameters<
+ ChatTransport['reconnectToStream']
+>[0]
+
+/**
+ * Read `stream`, and when the connection drops, read the rest through
+ * `transport.reconnectToStream`. A replayed prefix is skipped.
+ */
+function reconnecting(
+ transport: ChatTransport,
+ stream: ReadableStream,
+ options: ReconnectOptions,
+): ReadableStream {
+ let reader = stream.getReader()
+ let delivered = 0
+ let skip = 0
+ // ponytail: a reconnect that delivers nothing new ends the stream with the
+ // error. Add attempts and backoff if one retry without progress is too few.
+ let deliveredAtReconnect: number | undefined
+ return new ReadableStream({
+ async pull(controller) {
+ for (;;) {
+ try {
+ const { done, value } = await reader.read()
+ if (done) return controller.close()
+ // ponytail: skips by count, so the replay must repeat the chunks in
+ // the same order, as the AI SDK resumable streams do.
+ if (skip > 0) {
+ skip--
+ continue
+ }
+ delivered++
+ return controller.enqueue(value)
+ } catch (error) {
+ if (options.abortSignal?.aborted) throw error
+ if (deliveredAtReconnect === delivered) throw error
+ deliveredAtReconnect = delivered
+ const resumed = await transport.reconnectToStream(options)
+ if (!resumed) throw error
+ reader = resumed.getReader()
+ skip = transport.resumeStreamIsReplay === true ? delivered : 0
+ }
+ }
+ },
+ cancel: (reason) => reader.cancel(reason),
+ })
+}
+
+/**
+ * Connect the TanStack chat client to an AI SDK route through an AI SDK
+ * `ChatTransport`, such as `new DefaultChatTransport({ api: '/api/chat' })`.
+ *
+ * The transport sends the history as AI SDK `UIMessage`s and reads the UI
+ * message stream. The chat `threadId` is the AI SDK `chatId`. Data passed to
+ * `sendMessage` goes in the request body, and per-run headers go on the
+ * request.
+ *
+ * When the stream drops, the adapter reconnects with
+ * `transport.reconnectToStream` and continues the run. Set `threadId` to also
+ * rejoin a run after a page reload (`joinRun`), with a chat `persistence`
+ * adapter on the client. For that, the transport must replay the stream from
+ * its start (`resumeStreamIsReplay`, true for `DefaultChatTransport`).
+ *
+ * Pass the same `tools` as `useChat({ tools })`, so a tool approval is a
+ * `tool-approval` interrupt that `resolveInterrupt(true)` answers.
+ */
+export function fromChatTransport(
+ transport: ChatTransport,
+ {
+ threadId,
+ tools,
+ }: Pick & { threadId?: string } = {},
+): ConnectConnectionAdapter {
+ return {
+ async *connect(messages, data, abortSignal, runContext) {
+ const stream = await transport.sendMessages({
+ trigger: 'submit-message',
+ chatId: runContext.threadId,
+ messageId: undefined,
+ messages: toAISDKMessages(asUIMessages(messages)),
+ abortSignal,
+ ...(runContext.headers && { headers: runContext.headers }),
+ ...(data && { body: data }),
+ })
+ yield* fromUIMessageStream(
+ reconnecting(transport, stream, {
+ chatId: runContext.threadId,
+ abortSignal,
+ ...(runContext.headers && { headers: runContext.headers }),
+ }),
+ { ...runContext, tools },
+ )
+ },
+ // A rejoin gets only the run id, and the AI SDK reconnects by chat id.
+ ...(threadId !== undefined &&
+ transport.resumeStreamIsReplay === true && {
+ async *joinRun(runId: string, abortSignal?: AbortSignal) {
+ const options = { chatId: threadId, abortSignal }
+ const stream = await transport.reconnectToStream(options)
+ // Thrown before the first event, so the client drops its resume pointer.
+ if (!stream) throw new Error(`No active stream for chat ${threadId}`)
+ yield* fromUIMessageStream(reconnecting(transport, stream, options), {
+ threadId,
+ runId,
+ tools,
+ })
+ },
+ }),
+ }
+}
diff --git a/packages/ai-ui-message-stream/src/messages.ts b/packages/ai-ui-message-stream/src/messages.ts
new file mode 100644
index 0000000000..c518bd7613
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/messages.ts
@@ -0,0 +1,286 @@
+import { getToolName, isToolUIPart } from 'ai'
+import type {
+ ContentPart,
+ ContentPartSource,
+ MessagePart,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '@tanstack/ai'
+import type {
+ DynamicToolUIPart,
+ FileUIPart,
+ ProviderMetadata,
+ UIMessage as AISDKUIMessage,
+ ToolUIPart,
+ UIMessagePart,
+ UIDataTypes,
+ UITools,
+} from 'ai'
+
+type AISDKPart = UIMessagePart
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function isProviderMetadata(value: unknown): value is ProviderMetadata {
+ return isRecord(value) && Object.values(value).every(isRecord)
+}
+
+function parseJson(text: string): unknown {
+ try {
+ return JSON.parse(text)
+ } catch {
+ return undefined
+ }
+}
+
+const DATA_URL = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s
+
+function fileToContentPart(part: FileUIPart): ContentPart {
+ const match = DATA_URL.exec(part.url)
+ const source: ContentPartSource = match
+ ? {
+ type: 'data',
+ value: match[2] ?? '',
+ mimeType: match[1] ?? part.mediaType,
+ }
+ : { type: 'url', value: part.url, mimeType: part.mediaType }
+ const kind = part.mediaType.split('/')[0]
+ if (kind === 'image' || kind === 'audio' || kind === 'video') {
+ return { type: kind, source }
+ }
+ return { type: 'document', source }
+}
+
+function toolParts(
+ part: ToolUIPart | DynamicToolUIPart,
+): Array {
+ const call: ToolCallPart = {
+ type: 'tool-call',
+ id: part.toolCallId,
+ name: getToolName(part),
+ arguments: JSON.stringify(part.input ?? {}),
+ input: part.input,
+ state:
+ part.state === 'input-streaming'
+ ? 'input-streaming'
+ : part.state === 'output-available'
+ ? 'complete'
+ : part.state === 'output-error' || part.state === 'output-denied'
+ ? 'error'
+ : 'input-complete',
+ }
+ // A result goes right after its call, so a text after the tool replays
+ // after the result.
+ if (part.state === 'output-available') {
+ const result: ToolResultPart = {
+ type: 'tool-result',
+ toolCallId: part.toolCallId,
+ content:
+ typeof part.output === 'string'
+ ? part.output
+ : JSON.stringify(part.output ?? null),
+ state: 'complete',
+ }
+ return [call, result]
+ }
+ if (part.state === 'output-error' || part.state === 'output-denied') {
+ const error =
+ part.state === 'output-error'
+ ? part.errorText
+ : (part.approval.reason ?? 'The user denied this tool call')
+ const result: ToolResultPart = {
+ type: 'tool-result',
+ toolCallId: part.toolCallId,
+ content: error,
+ state: 'error',
+ error,
+ ...(part.state === 'output-denied' && { outcome: 'denied' }),
+ }
+ return [call, result]
+ }
+ return [call]
+}
+
+function fromAISDKPart(part: AISDKPart): Array {
+ if (isToolUIPart(part)) return toolParts(part)
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, data, step and custom parts are UI only; default drops them.
+ switch (part.type) {
+ case 'text':
+ return [{ type: 'text', content: part.text }]
+ case 'reasoning': {
+ const tanstack = part.providerMetadata?.tanstack
+ const signature = isRecord(tanstack) ? tanstack.signature : undefined
+ return [
+ {
+ type: 'thinking',
+ content: part.text,
+ ...(typeof signature === 'string' && { signature }),
+ ...(isRecord(tanstack) &&
+ tanstack.redacted === true && { redacted: true }),
+ },
+ ]
+ }
+ case 'file':
+ return [fileToContentPart(part)]
+ default:
+ // Sources, data parts, step markers and custom parts are UI only.
+ return []
+ }
+}
+
+/**
+ * Convert AI SDK `UIMessage`s (the `messages` body that AI SDK `useChat`
+ * posts) into TanStack `UIMessage`s for `chat({ messages })`.
+ *
+ * Text, files, reasoning and tool calls with their results are kept. A client
+ * tool's output and a denied tool call go back to the model as tool results.
+ * A call that waits for approval, or has an answer, stays pending:
+ * `chatParamsFromAISDKRequest` sends the answer to `chat()` as `resume`.
+ */
+export function fromAISDKMessages(
+ messages: ReadonlyArray,
+): Array {
+ return messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ parts: message.parts.flatMap(fromAISDKPart),
+ }))
+}
+
+function sourceToUrl(source: ContentPartSource): string {
+ return source.type === 'data'
+ ? `data:${source.mimeType};base64,${source.value}`
+ : source.value
+}
+
+function toolOutput(content: ToolResultPart['content']): unknown {
+ if (typeof content !== 'string') return content
+ return parseJson(content) ?? content
+}
+
+function toAISDKParts(message: UIMessage): Array {
+ const results = new Map()
+ for (const part of message.parts) {
+ if (part.type === 'tool-result') results.set(part.toolCallId, part)
+ }
+
+ const parts: Array = []
+ for (const part of message.parts) {
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- tool results join their calls; TanStack UI-only parts are dropped by default.
+ switch (part.type) {
+ case 'text':
+ parts.push({ type: 'text', text: part.content })
+ break
+ case 'thinking': {
+ // `fromUIMessageStream` stores the AI SDK provider metadata as the
+ // signature. Send it back as it came.
+ const providerMetadata =
+ part.signature === undefined ? undefined : parseJson(part.signature)
+ parts.push({
+ type: 'reasoning',
+ text: part.content,
+ ...(isProviderMetadata(providerMetadata) && { providerMetadata }),
+ })
+ break
+ }
+ case 'image':
+ case 'audio':
+ case 'video':
+ case 'document':
+ parts.push({
+ type: 'file',
+ mediaType:
+ part.source.mimeType ??
+ (part.type === 'document' ? 'application/octet-stream' : part.type),
+ url: sourceToUrl(part.source),
+ })
+ break
+ case 'tool-call': {
+ const input = part.input ?? parseJson(part.arguments) ?? {}
+ const result = results.get(part.id)
+ const base = {
+ type: 'dynamic-tool' as const,
+ toolName: part.name,
+ toolCallId: part.id,
+ input,
+ }
+ if (result?.outcome === 'denied' && part.approval) {
+ parts.push({
+ ...base,
+ state: 'output-denied',
+ approval: { id: part.approval.id, approved: false },
+ })
+ } else if (result?.state === 'error') {
+ parts.push({
+ ...base,
+ state: 'output-error',
+ errorText:
+ result.error ??
+ (typeof result.content === 'string'
+ ? result.content
+ : JSON.stringify(result.content)),
+ })
+ } else if (result !== undefined) {
+ parts.push({
+ ...base,
+ state: 'output-available',
+ output: toolOutput(result.content),
+ })
+ } else if (part.output !== undefined) {
+ parts.push({
+ ...base,
+ state: 'output-available',
+ output: part.output,
+ })
+ } else if (part.approval?.approved !== undefined) {
+ // The AI SDK route runs or denies the tool from this answer.
+ parts.push({
+ ...base,
+ state: 'approval-responded',
+ approval: {
+ id: part.approval.id,
+ approved: part.approval.approved,
+ },
+ })
+ } else if (part.approval) {
+ parts.push({
+ ...base,
+ state: 'approval-requested',
+ approval: { id: part.approval.id },
+ })
+ } else {
+ parts.push({ ...base, state: 'input-available' })
+ }
+ break
+ }
+ default:
+ // Results are joined with their calls above. Activity, subagent,
+ // structured-output and UI-resource parts are TanStack UI only.
+ break
+ }
+ }
+ return parts
+}
+
+/**
+ * Convert TanStack chat messages into AI SDK `UIMessage`s, the history an AI
+ * SDK route reads with `convertToModelMessages`. Tool calls become
+ * `dynamic-tool` parts.
+ */
+export function toAISDKMessages(
+ messages: ReadonlyArray,
+): Array {
+ const out: Array = []
+ for (const message of messages) {
+ if (message.role === 'activity') continue
+ out.push({
+ id: message.id,
+ role: message.role,
+ parts: toAISDKParts(message),
+ })
+ }
+ return out
+}
diff --git a/packages/ai-ui-message-stream/src/request.ts b/packages/ai-ui-message-stream/src/request.ts
new file mode 100644
index 0000000000..c3ecfc2d6e
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/request.ts
@@ -0,0 +1,96 @@
+import { isToolUIPart, validateUIMessages } from 'ai'
+import { fromAISDKMessages } from './messages'
+import { tanstackField } from './stream'
+import type { RunAgentResumeItem, UIMessage } from '@tanstack/ai'
+import type { UIMessage as AISDKUIMessage } from 'ai'
+
+/** What `chat()` needs from an AI SDK `useChat` request. */
+export interface AISDKChatParams {
+ /** The AI SDK chat id. */
+ threadId: string
+ messages: Array
+ /** The user's answers to tool approvals, when there are any. */
+ resume?: Array
+ /**
+ * Set with `resume`: the paused TanStack run, from the last assistant
+ * message's `metadata.tanstack.runId`, else the chat id.
+ */
+ parentRunId?: string
+}
+
+/**
+ * Read the body that AI SDK `useChat` posts (`{ id, messages }`) as
+ * `chat()` parameters: `chat({ adapter, tools, ...params })`.
+ *
+ * A tool part in `approval-responded` state becomes a resume item, so
+ * `chat()` runs or denies the paused tool call.
+ *
+ * @throws An error when the body is not a chat request or a message is not
+ * a valid AI SDK `UIMessage`.
+ */
+export async function chatParamsFromAISDKRequestBody(
+ body: unknown,
+): Promise {
+ if (
+ typeof body !== 'object' ||
+ body === null ||
+ !('id' in body) ||
+ typeof body.id !== 'string' ||
+ !('messages' in body)
+ ) {
+ throw new Error('The body must have an `id` and `messages`.')
+ }
+ const messages = await validateUIMessages({
+ messages: body.messages,
+ })
+
+ const resume = messages.flatMap((message) =>
+ message.parts.flatMap(
+ (part): Array =>
+ isToolUIPart(part) && part.state === 'approval-responded'
+ ? [
+ {
+ interruptId: part.approval.id,
+ status: 'resolved',
+ payload: { approved: part.approval.approved },
+ },
+ ]
+ : [],
+ ),
+ )
+
+ // `toUIMessageStream` puts the run id in the message metadata.
+ const runId = tanstackField(
+ messages.filter((m) => m.role === 'assistant').at(-1)?.metadata,
+ 'runId',
+ )
+ return {
+ threadId: body.id,
+ messages: fromAISDKMessages(messages),
+ ...(resume.length > 0 && {
+ resume,
+ parentRunId: typeof runId === 'string' ? runId : body.id,
+ }),
+ }
+}
+
+/**
+ * Read an AI SDK `useChat` request as `chat()` parameters. See
+ * `chatParamsFromAISDKRequestBody`.
+ *
+ * @throws {Response} 400 when the body is not JSON or not a valid chat
+ * request.
+ */
+export async function chatParamsFromAISDKRequest(
+ request: Request,
+): Promise {
+ try {
+ return await chatParamsFromAISDKRequestBody(await request.json())
+ } catch (cause) {
+ // Keep the details on `cause` for server logs, not in the response.
+ throw Object.assign(
+ new Response('Invalid AI SDK chat request body.', { status: 400 }),
+ { cause },
+ )
+ }
+}
diff --git a/packages/ai-ui-message-stream/src/resume.ts b/packages/ai-ui-message-stream/src/resume.ts
new file mode 100644
index 0000000000..11f4d26dec
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/resume.ts
@@ -0,0 +1,42 @@
+import { replayRunStream, toServerSentEventsResponse } from '@tanstack/ai'
+import type { StreamChunk, StreamDurability } from '@tanstack/ai'
+import { createUIMessageStreamResponse } from 'ai'
+import { toUIMessageStream } from './stream'
+
+/**
+ * Answer the AI SDK resume request (`GET {api}/{chatId}/stream`, which
+ * `useChat({ resume: true })` sends on mount). Pass the durability log of the
+ * chat's active run. The run is replayed from its start, as the AI SDK
+ * `DefaultChatTransport` expects. With no log, the response is the AI SDK's
+ * "nothing to resume" 204.
+ */
+export function resumeUIMessageStreamResponse(
+ durability: StreamDurability | null | undefined,
+): Response {
+ if (!durability) return new Response(null, { status: 204 })
+ return createUIMessageStreamResponse({
+ stream: toUIMessageStream(replayRunStream(durability)),
+ })
+}
+
+/**
+ * Serve a `chat()` run to the AI SDK `useChat` so that it can be resumed. The
+ * run is written to `durability` and keeps running when the client goes away.
+ * The response reads the run back from the log, the same way
+ * {@link resumeUIMessageStreamResponse} does.
+ */
+export function toResumableUIMessageStreamResponse(
+ stream: AsyncIterable,
+ durability: StreamDurability,
+): Response {
+ // ponytail: the SSE helper owns the only exported durable producer. It
+ // writes the run to the log and does not wait for a reader, so its unused
+ // SSE body is drained (a cancel would mark the run as detached). Export the
+ // producer from @tanstack/ai to drop the extra SSE encoding.
+ void toServerSentEventsResponse(stream, {
+ durability: { adapter: durability },
+ })
+ .body?.pipeTo(new WritableStream())
+ .catch(() => {})
+ return resumeUIMessageStreamResponse(durability)
+}
diff --git a/packages/ai-ui-message-stream/src/stream.ts b/packages/ai-ui-message-stream/src/stream.ts
new file mode 100644
index 0000000000..2fb6a97db7
--- /dev/null
+++ b/packages/ai-ui-message-stream/src/stream.ts
@@ -0,0 +1,616 @@
+import {
+ EventType,
+ INTERRUPT_BINDING_VERSION,
+ RUN_ACCEPTED_EVENT,
+ hashSchemaInput,
+ normalizeApprovalSchema,
+ readInterruptBinding,
+ withInterruptBinding,
+} from '@tanstack/ai'
+import type {
+ AnyClientTool,
+ Interrupt,
+ StreamChunk,
+ ToolResultOutcome,
+} from '@tanstack/ai'
+import type { FinishReason, ProviderMetadata, UIMessageChunk } from 'ai'
+import { DataParts, dataChunkEvent, isDataChunk } from './data-parts'
+
+const TO_AI_SDK_FINISH: Partial> = {
+ stop: 'stop',
+ length: 'length',
+ content_filter: 'content-filter',
+ tool_calls: 'tool-calls',
+}
+
+const FROM_AI_SDK_FINISH: Record<
+ FinishReason,
+ 'stop' | 'length' | 'content_filter' | null
+> = {
+ stop: 'stop',
+ length: 'length',
+ 'content-filter': 'content_filter',
+ // A TanStack `tool_calls` finish means more steps follow in the same
+ // stream. An AI SDK stream has ended here, and the client must run its
+ // tools, so the reason is left out.
+ 'tool-calls': null,
+ error: null,
+ other: null,
+}
+
+/** TanStack marks a redacted thinking block by this reasoning message id prefix. */
+const REDACTED_PREFIX = 'redacted_thinking-'
+
+/** `metadata.tanstack[key]`, or undefined. */
+export function tanstackField(metadata: unknown, key: string): unknown {
+ const tanstack: unknown =
+ typeof metadata === 'object' && metadata !== null && 'tanstack' in metadata
+ ? metadata.tanstack
+ : undefined
+ return typeof tanstack === 'object' && tanstack !== null && key in tanstack
+ ? Reflect.get(tanstack, key)
+ : undefined
+}
+
+function parseJson(text: string): unknown {
+ try {
+ return JSON.parse(text)
+ } catch {
+ return undefined
+ }
+}
+
+/** Tool output as the AI SDK client renders it: parsed JSON, else the text. */
+function parseOutput(content: string | ReadonlyArray): unknown {
+ if (typeof content !== 'string') return content
+ return parseJson(content) ?? content
+}
+
+/**
+ * Convert the AG-UI event stream of `chat()` into an AI SDK UI message
+ * stream. Serve it with the AI SDK's `createUIMessageStreamResponse`, and the
+ * AI SDK `useChat` reads it like a `streamText` route.
+ *
+ * A tool approval becomes a `tool-approval-request` chunk. Read the answer
+ * with `chatParamsFromAISDKRequest`. The message metadata gets the paused
+ * TanStack run id as `{ tanstack: { runId } }`, so the answer resumes that
+ * run. A subagent's approval shows on its card only. An activity becomes a `data-`
+ * part, shared state a `data-state` part, and each subagent a `data-subagent`
+ * card, each updated in place by id. A CUSTOM event becomes a transient
+ * `data-` chunk, which reaches `onData`.
+ */
+export function toUIMessageStream(
+ stream: AsyncIterable,
+): ReadableStream {
+ async function* convert(): AsyncGenerator {
+ let started = false
+ let errored = false
+ let finishReason: FinishReason | undefined
+ const tools = new Map()
+ const signatures = new Map()
+ const dataParts = new DataParts()
+
+ function* start(messageId?: string): Generator {
+ if (started) return
+ started = true
+ yield messageId === undefined
+ ? { type: 'start' }
+ : { type: 'start', messageId }
+ }
+
+ for await (const event of stream) {
+ // A subagent's events go to its card, not to this message.
+ if ('subagentRunId' in event && event.subagentRunId !== undefined) {
+ const chunks = dataParts.chunks(event)
+ if (chunks.length > 0) {
+ yield* start()
+ yield* chunks
+ }
+ continue
+ }
+
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- steps and message snapshots have no AI SDK chunk; default skips them.
+ switch (event.type) {
+ case EventType.TEXT_MESSAGE_START:
+ yield* start(event.messageId)
+ yield { type: 'text-start', id: event.messageId }
+ break
+ case EventType.TEXT_MESSAGE_CONTENT:
+ yield { type: 'text-delta', id: event.messageId, delta: event.delta }
+ break
+ case EventType.TEXT_MESSAGE_END:
+ yield { type: 'text-end', id: event.messageId }
+ break
+
+ case EventType.REASONING_MESSAGE_START:
+ yield* start()
+ yield { type: 'reasoning-start', id: event.messageId }
+ break
+ case EventType.REASONING_MESSAGE_CONTENT:
+ yield {
+ type: 'reasoning-delta',
+ id: event.messageId,
+ delta: event.delta,
+ }
+ break
+ case EventType.REASONING_ENCRYPTED_VALUE:
+ // ponytail: only a signature sent before REASONING_MESSAGE_END is
+ // kept, which is the order the TanStack adapters use. Buffer the
+ // reasoning-end if an adapter ever sends it later.
+ if (event.subtype === 'message') {
+ signatures.set(event.entityId, event.encryptedValue)
+ }
+ break
+ case EventType.REASONING_MESSAGE_END: {
+ const signature = signatures.get(event.messageId)
+ const providerMetadata: ProviderMetadata | undefined =
+ signature === undefined
+ ? undefined
+ : {
+ tanstack: {
+ signature,
+ ...(event.messageId.startsWith(REDACTED_PREFIX) && {
+ redacted: true,
+ }),
+ },
+ }
+ yield {
+ type: 'reasoning-end',
+ id: event.messageId,
+ ...(providerMetadata && { providerMetadata }),
+ }
+ break
+ }
+
+ case EventType.TOOL_CALL_START:
+ yield* start(event.parentMessageId)
+ tools.set(event.toolCallId, { name: event.toolCallName, args: '' })
+ yield {
+ type: 'tool-input-start',
+ toolCallId: event.toolCallId,
+ toolName: event.toolCallName,
+ }
+ break
+ case EventType.TOOL_CALL_ARGS: {
+ const tool = tools.get(event.toolCallId)
+ if (tool) tool.args += event.delta
+ yield {
+ type: 'tool-input-delta',
+ toolCallId: event.toolCallId,
+ inputTextDelta: event.delta,
+ }
+ break
+ }
+ case EventType.TOOL_CALL_END: {
+ const tool = tools.get(event.toolCallId)
+ if (!tool) break
+ const input =
+ tanstackField(event.metadata, 'input') ??
+ (tool.args === '' ? {} : parseJson(tool.args))
+ yield {
+ type: 'tool-input-available',
+ toolCallId: event.toolCallId,
+ toolName: tool.name,
+ input,
+ }
+ break
+ }
+ case EventType.TOOL_CALL_RESULT:
+ // A resumed run can start with a result. A start without an id
+ // keeps the AI SDK message that holds the tool call.
+ yield* start()
+ if (tanstackField(event.metadata, 'toolResultOutcome') === 'denied') {
+ yield { type: 'tool-output-denied', toolCallId: event.toolCallId }
+ } else if (
+ tanstackField(event.metadata, 'state') === 'output-error'
+ ) {
+ yield {
+ type: 'tool-output-error',
+ toolCallId: event.toolCallId,
+ errorText:
+ typeof event.content === 'string'
+ ? event.content
+ : JSON.stringify(event.content),
+ }
+ } else {
+ yield {
+ type: 'tool-output-available',
+ toolCallId: event.toolCallId,
+ output: parseOutput(event.content),
+ }
+ }
+ break
+
+ case EventType.ACTIVITY_SNAPSHOT:
+ case EventType.ACTIVITY_DELTA:
+ case EventType.STATE_SNAPSHOT:
+ case EventType.STATE_DELTA:
+ yield* start()
+ yield* dataParts.chunks(event)
+ break
+
+ case EventType.CUSTOM:
+ // The durability log marks an accepted run. It is not app data.
+ if (event.name === RUN_ACCEPTED_EVENT) break
+ yield* start()
+ yield {
+ type: `data-${event.name}`,
+ data: event.value,
+ transient: true,
+ }
+ break
+
+ case EventType.RUN_FINISHED: {
+ const reason = tanstackField(event.metadata, 'finishReason')
+ if (typeof reason === 'string')
+ finishReason = TO_AI_SDK_FINISH[reason]
+ // A tool approval pauses the run. A client tool needs no chunk: the
+ // AI SDK client runs a tool call that has no output. A subagent's
+ // tool call is not in this message (the AI SDK client throws on an
+ // unknown one), so its approval shows on the subagent card only.
+ if (event.outcome?.type === 'interrupt') {
+ for (const interrupt of event.outcome.interrupts) {
+ if (
+ interrupt.metadata?.kind !== 'approval' ||
+ interrupt.toolCallId === undefined ||
+ !tools.has(interrupt.toolCallId)
+ )
+ continue
+ yield* start()
+ yield {
+ type: 'tool-approval-request',
+ approvalId: interrupt.id,
+ toolCallId: interrupt.toolCallId,
+ }
+ // The binding names the paused run. The event `runId` can be
+ // the adapter's own id.
+ const runId = readInterruptBinding(interrupt)?.interruptedRunId
+ if (runId !== undefined) {
+ yield {
+ type: 'message-metadata',
+ messageMetadata: { tanstack: { runId } },
+ }
+ }
+ }
+ }
+ break
+ }
+ case EventType.RUN_ERROR:
+ errored = true
+ yield { type: 'error', errorText: event.message }
+ break
+
+ default:
+ break
+ }
+ }
+
+ if (errored) return
+ yield* start()
+ yield finishReason === undefined
+ ? { type: 'finish' }
+ : { type: 'finish', finishReason }
+ }
+
+ const iterator = convert()
+ return new ReadableStream({
+ async pull(controller) {
+ const { done, value } = await iterator.next()
+ if (done) controller.close()
+ else controller.enqueue(value)
+ },
+ async cancel() {
+ await iterator.return(undefined)
+ },
+ })
+}
+
+/** Options for {@link fromUIMessageStream}. */
+export interface FromUIMessageStreamOptions {
+ threadId: string
+ runId: string
+ /**
+ * The client's tools, as passed to `useChat({ tools })`. A tool approval for
+ * one of them with `needsApproval: true` is a `tool-approval` interrupt.
+ */
+ tools?: ReadonlyArray
+}
+
+/**
+ * Convert an AI SDK UI message stream into AG-UI events for the TanStack chat
+ * client. All text and tool calls go into one assistant message, as the AI
+ * SDK builds them.
+ *
+ * A tool approval request ends the run with an approval interrupt. For a
+ * tool in `tools`, it is a `tool-approval` interrupt: answer it with
+ * `resolveInterrupt(true)` or `client.resolveInterrupts(true)`. For another
+ * tool, it is a generic interrupt: answer it with
+ * `resolveInterrupt({ approved })`. The next request sends the tool part as
+ * `approval-responded`. Sources, files
+ * and step boundaries are skipped. A `data-` part with an id and object
+ * data becomes an activity of type ``. Other data chunks become CUSTOM
+ * events named `data-`.
+ */
+export async function* fromUIMessageStream(
+ stream: ReadableStream,
+ { threadId, runId, tools: clientTools }: FromUIMessageStreamOptions,
+): AsyncGenerator {
+ let messageId = `${runId}-assistant`
+ let ended = false
+ // AI SDK reasoning ids are only unique inside one step, so each reasoning
+ // part gets its own AG-UI message id.
+ const reasoningIds = new Map()
+ let reasoningCount = 0
+ const tools = new Map()
+ const inputs = new Map()
+ const approvals: Array = []
+
+ function* finished(
+ metadata?: Record,
+ ): Generator {
+ yield {
+ type: EventType.RUN_FINISHED,
+ threadId,
+ runId,
+ outcome:
+ approvals.length > 0
+ ? { type: 'interrupt', interrupts: approvals }
+ : { type: 'success' },
+ ...(metadata && { metadata }),
+ }
+ }
+
+ function* ensureToolStarted(
+ toolCallId: string,
+ toolName: string,
+ input: unknown,
+ ): Generator {
+ let tool = tools.get(toolCallId)
+ if (!tool) {
+ tool = { started: true, hasArgs: false }
+ tools.set(toolCallId, tool)
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId,
+ toolCallName: toolName,
+ parentMessageId: messageId,
+ }
+ }
+ if (!tool.hasArgs) {
+ tool.hasArgs = true
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId,
+ delta: JSON.stringify(input ?? {}),
+ }
+ }
+ }
+
+ yield { type: EventType.RUN_STARTED, threadId, runId }
+
+ for await (const chunk of stream) {
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, files, steps and approvals are skipped; default maps data parts.
+ switch (chunk.type) {
+ case 'start':
+ if (chunk.messageId !== undefined) messageId = chunk.messageId
+ break
+
+ // Every AI SDK text part belongs to the one assistant message. The
+ // TanStack client starts a new text segment on each START.
+ case 'text-start':
+ yield {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId,
+ role: 'assistant',
+ }
+ break
+ case 'text-delta':
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ delta: chunk.delta,
+ }
+ break
+ case 'text-end':
+ yield { type: EventType.TEXT_MESSAGE_END, messageId }
+ break
+
+ case 'reasoning-start': {
+ const id = `${messageId}-reasoning-${reasoningCount++}`
+ reasoningIds.set(chunk.id, id)
+ yield {
+ type: EventType.REASONING_MESSAGE_START,
+ messageId: id,
+ role: 'reasoning',
+ }
+ break
+ }
+ case 'reasoning-delta': {
+ const id = reasoningIds.get(chunk.id)
+ if (id !== undefined) {
+ yield {
+ type: EventType.REASONING_MESSAGE_CONTENT,
+ messageId: id,
+ delta: chunk.delta,
+ }
+ }
+ break
+ }
+ case 'reasoning-end': {
+ const id = reasoningIds.get(chunk.id)
+ if (id === undefined) break
+ // The provider metadata holds the reasoning signature. The client
+ // keeps it as an opaque value and sends it back unchanged.
+ if (chunk.providerMetadata !== undefined) {
+ yield {
+ type: EventType.REASONING_ENCRYPTED_VALUE,
+ subtype: 'message',
+ entityId: id,
+ encryptedValue: JSON.stringify(chunk.providerMetadata),
+ }
+ }
+ yield { type: EventType.REASONING_MESSAGE_END, messageId: id }
+ break
+ }
+
+ case 'tool-input-start':
+ tools.set(chunk.toolCallId, { started: true, hasArgs: false })
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: chunk.toolCallId,
+ toolCallName: chunk.toolName,
+ parentMessageId: messageId,
+ }
+ break
+ case 'tool-input-delta': {
+ const tool = tools.get(chunk.toolCallId)
+ if (tool) tool.hasArgs = true
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: chunk.toolCallId,
+ delta: chunk.inputTextDelta,
+ }
+ break
+ }
+ case 'tool-input-available':
+ inputs.set(chunk.toolCallId, {
+ toolName: chunk.toolName,
+ input: chunk.input,
+ })
+ yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input)
+ yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId }
+ break
+ case 'tool-approval-request': {
+ // `kind: 'approval'` shows the approval on the tool call.
+ const call = inputs.get(chunk.toolCallId)
+ const descriptor: Interrupt = {
+ id: chunk.approvalId,
+ reason: 'tool_call',
+ toolCallId: chunk.toolCallId,
+ metadata: { kind: 'approval', ...call },
+ }
+ const tool = clientTools?.find((t) => t.name === call?.toolName)
+ if (call && tool?.needsApproval === true) {
+ // The binding chat() makes. The client accepts it only when the
+ // schema hashes match its own copy of the tool.
+ const schema = normalizeApprovalSchema(
+ tool.approvalSchema,
+ tool.inputSchema,
+ )
+ approvals.push(
+ withInterruptBinding(
+ { ...descriptor, responseSchema: schema.responseSchema },
+ {
+ v: INTERRUPT_BINDING_VERSION,
+ kind: 'tool-approval',
+ interruptId: chunk.approvalId,
+ toolName: call.toolName,
+ toolCallId: chunk.toolCallId,
+ originalArgs: call.input,
+ inputSchemaHash: hashSchemaInput(tool.inputSchema),
+ approvalSchemaHash: schema.approvalSchemaHash,
+ responseSchemaHash: schema.responseSchemaHash,
+ interruptedRunId: runId,
+ generation: 0,
+ },
+ ),
+ )
+ break
+ }
+ // A generic binding makes the interrupt resolvable without the
+ // client's copy of the tool.
+ approvals.push(
+ withInterruptBinding(descriptor, {
+ v: INTERRUPT_BINDING_VERSION,
+ kind: 'generic',
+ interruptId: chunk.approvalId,
+ interruptedRunId: runId,
+ generation: 0,
+ }),
+ )
+ break
+ }
+ case 'tool-output-denied':
+ yield toolError(
+ chunk.toolCallId,
+ 'The user denied this tool call',
+ 'denied',
+ )
+ break
+ case 'tool-input-error':
+ yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input)
+ yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId }
+ yield toolError(chunk.toolCallId, chunk.errorText)
+ break
+ case 'tool-output-available':
+ // A preliminary output is a progress update. Only the last one is the result.
+ if (chunk.preliminary === true) break
+ yield {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: `${chunk.toolCallId}-result`,
+ toolCallId: chunk.toolCallId,
+ content:
+ typeof chunk.output === 'string'
+ ? chunk.output
+ : JSON.stringify(chunk.output ?? null),
+ role: 'tool',
+ }
+ break
+ case 'tool-output-error':
+ yield toolError(chunk.toolCallId, chunk.errorText)
+ break
+
+ case 'error':
+ ended = true
+ yield { type: EventType.RUN_ERROR, message: chunk.errorText }
+ break
+ case 'abort':
+ ended = true
+ yield {
+ type: EventType.RUN_FINISHED,
+ threadId,
+ runId,
+ outcome: { type: 'cancelled' },
+ }
+ break
+ case 'finish': {
+ ended = true
+ const finishReason =
+ chunk.finishReason === undefined
+ ? undefined
+ : FROM_AI_SDK_FINISH[chunk.finishReason]
+ yield* finished(
+ finishReason == null ? undefined : { tanstack: { finishReason } },
+ )
+ break
+ }
+
+ default:
+ if (isDataChunk(chunk)) yield dataChunkEvent(chunk)
+ break
+ }
+ if (ended) return
+ }
+
+ yield* finished()
+}
+
+function toolError(
+ toolCallId: string,
+ errorText: string,
+ toolResultOutcome?: ToolResultOutcome,
+): StreamChunk {
+ return {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: `${toolCallId}-result`,
+ toolCallId,
+ content: errorText,
+ role: 'tool',
+ metadata: {
+ tanstack: {
+ state: 'output-error',
+ ...(toolResultOutcome && { toolResultOutcome }),
+ },
+ },
+ }
+}
diff --git a/packages/ai-ui-message-stream/tests/approvals.test.ts b/packages/ai-ui-message-stream/tests/approvals.test.ts
new file mode 100644
index 0000000000..025a3bf67e
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/approvals.test.ts
@@ -0,0 +1,382 @@
+import { describe, expect, it, vi } from 'vitest'
+import { EventType, chat } from '@tanstack/ai'
+import { fakeText } from '@tanstack/ai/testing'
+import { toolDefinition } from '@tanstack/ai/client'
+import { ChatClient } from '@tanstack/ai-client'
+import { readUIMessageStream } from 'ai'
+import { z } from 'zod'
+import { fromChatTransport, toUIMessageStream } from '../src'
+import { chatParamsFromAISDKRequest } from '../src/request'
+import type { StreamChunk } from '@tanstack/ai'
+import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai'
+
+function chunkStream(
+ list: Array,
+): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of list) controller.enqueue(chunk)
+ controller.close()
+ },
+ })
+}
+
+describe('TanStack server, AI SDK client: approvals', () => {
+ async function run(approved: boolean) {
+ const fake = fakeText()
+ fake.setResponses([
+ { toolCalls: [{ name: 'deleteFile', input: { path: 'a.txt' } }] },
+ ({ request }) => ({
+ text: `Result: ${String(request.messages.at(-1)?.content)}`,
+ }),
+ ])
+ const execute = vi.fn(() => ({ deleted: true }))
+ const deleteFile = toolDefinition({
+ name: 'deleteFile',
+ description: 'Delete a file',
+ inputSchema: z.object({ path: z.string() }),
+ needsApproval: true,
+ }).server(execute)
+
+ // The run ids chat() sees, from a middleware.
+ const runs: Array<{ runId: string; parentRunId?: string }> = []
+ const middleware = [
+ {
+ onStart: (ctx: { runId: string; parentRunId?: string }) => {
+ runs.push({ runId: ctx.runId, parentRunId: ctx.parentRunId })
+ },
+ },
+ ]
+
+ const user: UIMessage = {
+ id: 'u-1',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Delete a.txt' }],
+ }
+
+ // Turn 1: the AI SDK client sends the user message.
+ const firstParams = await chatParamsFromAISDKRequest(
+ new Request('http://x/api/chat', {
+ method: 'POST',
+ body: JSON.stringify({ id: 'chat-1', messages: [user] }),
+ }),
+ )
+ let first: UIMessage | undefined
+ for await (const message of readUIMessageStream({
+ stream: toUIMessageStream(
+ chat({
+ adapter: fake,
+ tools: [deleteFile],
+ middleware,
+ ...firstParams,
+ }),
+ ),
+ }))
+ first = message
+ // The message keeps the paused TanStack run id.
+ expect(first?.metadata).toEqual({ tanstack: { runId: runs[0]?.runId } })
+ const requested = first?.parts.find((p) => p.type === 'tool-deleteFile')
+ expect(requested).toMatchObject({
+ state: 'approval-requested',
+ input: { path: 'a.txt' },
+ approval: { id: expect.any(String) },
+ })
+ expect(execute).not.toHaveBeenCalled()
+
+ // The AI SDK client answers: `addToolApprovalResponse` sets this state.
+ const answered: UIMessage = {
+ ...first!,
+ parts: first!.parts.map((part) =>
+ part.type === 'tool-deleteFile' && part.state === 'approval-requested'
+ ? {
+ ...part,
+ state: 'approval-responded',
+ approval: { id: part.approval.id, approved },
+ }
+ : part,
+ ),
+ }
+
+ // Turn 2: the history goes back, and chat() resumes the paused call.
+ const params = await chatParamsFromAISDKRequest(
+ new Request('http://x/api/chat', {
+ method: 'POST',
+ body: JSON.stringify({ id: 'chat-1', messages: [user, answered] }),
+ }),
+ )
+ expect(params.resume).toEqual([
+ {
+ interruptId: answered.parts.flatMap((p) =>
+ p.type === 'tool-deleteFile' && p.state === 'approval-responded'
+ ? [p.approval.id]
+ : [],
+ )[0],
+ status: 'resolved',
+ payload: { approved },
+ },
+ ])
+ // The resume names the paused run, not the chat id.
+ expect(params.parentRunId).toBe(runs[0]?.runId)
+ expect(params.parentRunId).not.toBe('chat-1')
+ let second: UIMessage | undefined
+ for await (const message of readUIMessageStream({
+ // The AI SDK client continues the last assistant message.
+ message: answered,
+ stream: toUIMessageStream(
+ chat({ adapter: fake, tools: [deleteFile], middleware, ...params }),
+ ),
+ }))
+ second = message
+ expect(runs[1]?.parentRunId).toBe(runs[0]?.runId)
+ return { execute, second: second! }
+ }
+
+ it('runs the tool after the user approves', async () => {
+ const { execute, second } = await run(true)
+ expect(execute).toHaveBeenCalledTimes(1)
+ expect(second.parts).toMatchObject([
+ {
+ type: 'tool-deleteFile',
+ state: 'output-available',
+ output: { deleted: true },
+ },
+ { type: 'text', text: 'Result: {"deleted":true}' },
+ ])
+ })
+
+ it('skips the tool after the user denies', async () => {
+ const { execute, second } = await run(false)
+ expect(execute).not.toHaveBeenCalled()
+ expect(second.parts).toMatchObject([
+ {
+ type: 'tool-deleteFile',
+ state: 'output-denied',
+ approval: { approved: false },
+ },
+ { type: 'text', text: expect.stringContaining('Result:') },
+ ])
+ })
+
+ it('shows a subagent approval on its card, not as a top-level request', async () => {
+ const events: Array = [
+ { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: 'call-1',
+ toolCallName: 'researcher',
+ parentMessageId: 'm',
+ },
+ { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' },
+ {
+ type: EventType.SUBAGENT_STARTED,
+ subagentRunId: 's-1',
+ name: 'researcher',
+ parentToolCallId: 'call-1',
+ },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: 'c-1',
+ toolCallName: 'deleteFile',
+ subagentRunId: 's-1',
+ },
+ {
+ type: EventType.SUBAGENT_FINISHED,
+ subagentRunId: 's-1',
+ outcome: { type: 'suspended', interruptIds: ['ap-1'] },
+ },
+ {
+ type: EventType.RUN_FINISHED,
+ threadId: 't',
+ runId: 'r',
+ outcome: {
+ type: 'interrupt',
+ interrupts: [
+ {
+ id: 'ap-1',
+ reason: 'tool_call',
+ toolCallId: 'c-1',
+ subagentRunId: 's-1',
+ metadata: { kind: 'approval' },
+ },
+ ],
+ },
+ },
+ ]
+ async function* source() {
+ yield* events
+ }
+ let message: UIMessage | undefined
+ // The AI SDK client throws on an approval for an unknown tool call.
+ for await (const m of readUIMessageStream({
+ stream: toUIMessageStream(source()),
+ }))
+ message = m
+ expect(message?.parts).toMatchObject([
+ { type: 'tool-researcher', toolCallId: 'call-1' },
+ {
+ type: 'data-subagent',
+ id: 's-1',
+ data: { status: 'suspended', interruptIds: ['ap-1'] },
+ },
+ ])
+ })
+
+ it('rejects a body that is not an AI SDK chat request', async () => {
+ const error: unknown = await chatParamsFromAISDKRequest(
+ new Request('http://x/api/chat', { method: 'POST', body: '{}' }),
+ ).catch((e: unknown) => e)
+ expect(error).toBeInstanceOf(Response)
+ expect(error instanceof Response && error.status).toBe(400)
+ })
+})
+
+describe('AI SDK server, TanStack client: approvals', () => {
+ const deleteFile = toolDefinition({
+ name: 'deleteFile',
+ description: 'Delete a file',
+ inputSchema: z.object({ path: z.string() }),
+ needsApproval: true,
+ })
+
+ const tools = [deleteFile] as const
+
+ /** `bound`: the adapter gets the tools too, as the docs show. */
+ async function run(
+ approved: boolean,
+ answer: (client: ChatClient) => void,
+ bound = true,
+ ) {
+ const requests: Array> = []
+ const transport: ChatTransport = {
+ sendMessages({ messages }) {
+ requests.push(messages)
+ return Promise.resolve(
+ chunkStream(
+ requests.length === 1
+ ? [
+ { type: 'start', messageId: 'a-1' },
+ {
+ type: 'tool-input-start',
+ toolCallId: 'c-1',
+ toolName: 'deleteFile',
+ },
+ {
+ type: 'tool-input-available',
+ toolCallId: 'c-1',
+ toolName: 'deleteFile',
+ input: { path: 'a.txt' },
+ },
+ {
+ type: 'tool-approval-request',
+ approvalId: 'ap-1',
+ toolCallId: 'c-1',
+ },
+ { type: 'finish', finishReason: 'tool-calls' },
+ ]
+ : [
+ { type: 'start', messageId: 'a-1' },
+ approved
+ ? {
+ type: 'tool-output-available',
+ toolCallId: 'c-1',
+ output: { deleted: true },
+ }
+ : { type: 'tool-output-denied', toolCallId: 'c-1' },
+ { type: 'text-start', id: '0' },
+ { type: 'text-delta', id: '0', delta: 'All done.' },
+ { type: 'text-end', id: '0' },
+ { type: 'finish', finishReason: 'stop' },
+ ],
+ ),
+ )
+ },
+ reconnectToStream: () => Promise.resolve(null),
+ }
+ const client = new ChatClient({
+ threadId: 'thread-1',
+ tools,
+ connection: fromChatTransport(transport, bound ? { tools } : {}),
+ })
+
+ await client.sendMessage('Delete a.txt')
+ const toolCall = client
+ .getMessages()
+ .flatMap((m) => m.parts)
+ .find((p) => p.type === 'tool-call')
+ expect(toolCall).toMatchObject({
+ state: 'approval-requested',
+ approval: { id: 'ap-1' },
+ })
+
+ expect(client.getInterrupts()).toMatchObject([
+ {
+ kind: bound ? 'tool-approval' : 'generic',
+ canResolve: true,
+ id: 'ap-1',
+ metadata: { toolName: 'deleteFile', input: { path: 'a.txt' } },
+ },
+ ])
+ answer(client)
+
+ await vi.waitFor(() => expect(requests).toHaveLength(2))
+ expect(requests[1]!.find((m) => m.role === 'assistant')?.parts).toEqual([
+ {
+ type: 'dynamic-tool',
+ toolName: 'deleteFile',
+ toolCallId: 'c-1',
+ input: { path: 'a.txt' },
+ state: 'approval-responded',
+ approval: { id: 'ap-1', approved },
+ },
+ ])
+ await vi.waitFor(() =>
+ expect(JSON.stringify(client.getMessages())).toContain('All done.'),
+ )
+ expect(client.getInterrupts()).toEqual([])
+ return client
+ }
+
+ it('sends the approval and shows the tool output', async () => {
+ const client = await run(true, (c) => {
+ const [interrupt] = c.getInterrupts()
+ if (interrupt?.kind !== 'tool-approval') throw new Error('not bound')
+ interrupt.resolveInterrupt(true)
+ })
+ const parts = client.getMessages().flatMap((m) => m.parts)
+ expect(parts).toContainEqual(
+ expect.objectContaining({
+ type: 'tool-result',
+ toolCallId: 'c-1',
+ content: '{"deleted":true}',
+ }),
+ )
+ })
+
+ it('sends the denial and marks the tool result as denied', async () => {
+ const client = await run(false, (c) => c.resolveInterrupts(false))
+ const parts = client.getMessages().flatMap((m) => m.parts)
+ expect(parts).toContainEqual(
+ expect.objectContaining({
+ type: 'tool-result',
+ toolCallId: 'c-1',
+ state: 'error',
+ outcome: 'denied',
+ }),
+ )
+ })
+
+ it('falls back to a generic interrupt when the adapter has no tools', async () => {
+ const client = await run(
+ true,
+ (c) => {
+ const [interrupt] = c.getInterrupts()
+ if (interrupt?.kind !== 'generic') throw new Error('expected generic')
+ interrupt.resolveInterrupt({ approved: true })
+ },
+ false,
+ )
+ expect(client.getMessages().flatMap((m) => m.parts)).toContainEqual(
+ expect.objectContaining({ type: 'tool-result', toolCallId: 'c-1' }),
+ )
+ })
+})
diff --git a/packages/ai-ui-message-stream/tests/data-parts.test.ts b/packages/ai-ui-message-stream/tests/data-parts.test.ts
new file mode 100644
index 0000000000..5c9f5af8ba
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/data-parts.test.ts
@@ -0,0 +1,292 @@
+import { describe, expect, it, vi } from 'vitest'
+import { EventSchemas } from '@ag-ui/core/schemas'
+import { EventType } from '@tanstack/ai'
+import { ChatClient } from '@tanstack/ai-client'
+import { readUIMessageStream } from 'ai'
+import { fromChatTransport } from '../src'
+import { DataParts, dataChunkEvent, isDataChunk } from '../src/data-parts'
+import type { StreamChunk } from '@tanstack/ai'
+import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai'
+
+/** The last AI SDK message built from these events' data chunks. */
+async function readMessage(list: Array): Promise {
+ const parts = new DataParts()
+ const chunks: Array = [
+ { type: 'start', messageId: 'm' },
+ ...list.flatMap((event) => parts.chunks(event)),
+ { type: 'finish' },
+ ]
+ const stream = new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(chunk)
+ controller.close()
+ },
+ })
+ let last: UIMessage | undefined
+ for await (const message of readUIMessageStream({ stream })) last = message
+ if (!last) throw new Error('no message')
+ return last
+}
+
+describe('DataParts (TanStack server, AI SDK client)', () => {
+ it('replaces an activity part in place on each delta', async () => {
+ const message = await readMessage([
+ {
+ type: EventType.ACTIVITY_SNAPSHOT,
+ messageId: 'plan-1',
+ activityType: 'plan',
+ content: { steps: ['a'], done: false },
+ },
+ {
+ type: EventType.ACTIVITY_DELTA,
+ messageId: 'plan-1',
+ activityType: 'plan',
+ patch: [
+ { op: 'add', path: '/steps/-', value: 'b' },
+ { op: 'replace', path: '/done', value: true },
+ ],
+ },
+ // A failed patch keeps the last content.
+ {
+ type: EventType.ACTIVITY_DELTA,
+ messageId: 'plan-1',
+ activityType: 'plan',
+ patch: [{ op: 'remove', path: '/missing' }],
+ },
+ // No snapshot for this id.
+ {
+ type: EventType.ACTIVITY_DELTA,
+ messageId: 'other',
+ activityType: 'plan',
+ patch: [],
+ },
+ ])
+ expect(message.parts).toEqual([
+ {
+ type: 'data-plan',
+ id: 'plan-1',
+ data: { steps: ['a', 'b'], done: true },
+ },
+ ])
+ })
+
+ it('keeps an activity when replace is false', async () => {
+ const snapshot = {
+ type: EventType.ACTIVITY_SNAPSHOT,
+ messageId: 'a',
+ activityType: 'plan',
+ content: { v: 1 },
+ } as const
+ const message = await readMessage([
+ snapshot,
+ { ...snapshot, content: { v: 2 }, replace: false },
+ ])
+ expect(message.parts).toEqual([
+ { type: 'data-plan', id: 'a', data: { v: 1 } },
+ ])
+ })
+
+ it('sends the full shared state on snapshot and delta', async () => {
+ const message = await readMessage([
+ { type: EventType.STATE_SNAPSHOT, snapshot: { count: 1, tags: ['x'] } },
+ {
+ type: EventType.STATE_DELTA,
+ delta: [
+ { op: 'test', path: '/tags', value: ['x'] },
+ { op: 'copy', from: '/count', path: '/previous' },
+ { op: 'replace', path: '/count', value: 2 },
+ { op: 'move', from: '/tags/0', path: '/first' },
+ ],
+ },
+ ])
+ expect(message.parts).toEqual([
+ {
+ type: 'data-state',
+ id: 'state',
+ data: { count: 2, tags: [], previous: 1, first: 'x' },
+ },
+ ])
+ })
+
+ it('shows a subagent card going from running to finished', async () => {
+ const parts = new DataParts()
+ const tagged = { subagentRunId: 'sub-1' }
+ const list: Array = [
+ {
+ type: EventType.SUBAGENT_STARTED,
+ ...tagged,
+ name: 'researcher',
+ description: 'Finds facts',
+ parentToolCallId: 'call-1',
+ },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'c1',
+ role: 'assistant',
+ ...tagged,
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'c1',
+ delta: 'Found ',
+ ...tagged,
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'c1',
+ delta: 'it.',
+ ...tagged,
+ },
+ { type: EventType.TEXT_MESSAGE_END, messageId: 'c1', ...tagged },
+ { type: EventType.SUBAGENT_FINISHED, ...tagged },
+ ]
+ for (const event of list) EventSchemas.parse(event)
+
+ const statuses = list.flatMap((event) =>
+ parts
+ .chunks(event)
+ .map((chunk) =>
+ 'data' in chunk && typeof chunk.data === 'object' && chunk.data
+ ? 'status' in chunk.data && chunk.data.status
+ : undefined,
+ ),
+ )
+ expect(statuses).toEqual(['running', 'running', 'running', 'finished'])
+
+ const message = await readMessage(list)
+ expect(message.parts).toEqual([
+ {
+ type: 'data-subagent',
+ id: 'sub-1',
+ data: {
+ id: 'sub-1',
+ name: 'researcher',
+ description: 'Finds facts',
+ parentToolCallId: 'call-1',
+ status: 'finished',
+ text: 'Found it.',
+ },
+ },
+ ])
+ })
+
+ it('marks a subagent card as errored or suspended', async () => {
+ const message = await readMessage([
+ { type: EventType.SUBAGENT_STARTED, subagentRunId: 'a', name: 'a' },
+ {
+ type: EventType.SUBAGENT_ERROR,
+ subagentRunId: 'a',
+ message: 'boom',
+ code: 'E1',
+ },
+ // An error is final.
+ { type: EventType.SUBAGENT_FINISHED, subagentRunId: 'a' },
+ { type: EventType.SUBAGENT_STARTED, subagentRunId: 'b', name: 'b' },
+ {
+ type: EventType.SUBAGENT_FINISHED,
+ subagentRunId: 'b',
+ outcome: { type: 'suspended', interruptIds: ['i1'] },
+ },
+ ])
+ expect(message.parts.map((p) => 'data' in p && p.data)).toEqual([
+ {
+ id: 'a',
+ name: 'a',
+ status: 'error',
+ error: { message: 'boom', code: 'E1' },
+ text: '',
+ },
+ {
+ id: 'b',
+ name: 'b',
+ status: 'suspended',
+ interruptIds: ['i1'],
+ text: '',
+ },
+ ])
+ })
+})
+
+describe('dataChunkEvent (AI SDK server, TanStack client)', () => {
+ it('maps a persistent data chunk with an id to an activity snapshot', () => {
+ const chunk: UIMessageChunk = {
+ type: 'data-weather',
+ id: 'w-1',
+ data: { city: 'Oslo' },
+ }
+ if (!isDataChunk(chunk)) throw new Error('not a data chunk')
+ const event = dataChunkEvent(chunk)
+ EventSchemas.parse(event)
+ expect(event).toEqual({
+ type: EventType.ACTIVITY_SNAPSHOT,
+ messageId: 'w-1',
+ activityType: 'weather',
+ content: { city: 'Oslo' },
+ replace: true,
+ })
+ })
+
+ it.each([
+ { type: 'data-ping', id: 'p', data: { a: 1 }, transient: true },
+ { type: 'data-ping', data: { a: 1 } },
+ { type: 'data-ping', id: 'p', data: 'text' },
+ { type: 'data-ping', id: 'p', data: [1, 2] },
+ ])('keeps $type as a CUSTOM event: %o', (chunk) => {
+ if (!isDataChunk(chunk)) throw new Error('not a data chunk')
+ const event = dataChunkEvent(chunk)
+ EventSchemas.parse(event)
+ expect(event).toEqual({
+ type: EventType.CUSTOM,
+ name: 'data-ping',
+ value: chunk.data,
+ })
+ })
+
+ it('shows the activity in the TanStack client and replaces it by id', async () => {
+ const chunks: Array = [
+ { type: 'start', messageId: 'a' },
+ { type: 'text-start', id: '0' },
+ { type: 'data-weather', id: 'w-1', data: { city: 'Oslo' } },
+ { type: 'data-weather', id: 'w-1', data: { city: 'Bergen' } },
+ { type: 'text-delta', id: '0', delta: 'Sunny.' },
+ { type: 'text-end', id: '0' },
+ { type: 'finish', finishReason: 'stop' },
+ ]
+ const transport: ChatTransport = {
+ sendMessages: () =>
+ Promise.resolve(
+ new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(chunk)
+ controller.close()
+ },
+ }),
+ ),
+ reconnectToStream: () => Promise.resolve(null),
+ }
+ const client = new ChatClient({
+ threadId: 't',
+ connection: fromChatTransport(transport),
+ })
+ await client.sendMessage('weather?')
+ await vi.waitFor(() =>
+ expect(client.getMessages().map((m) => [m.role, m.parts])).toEqual([
+ ['user', [expect.objectContaining({ type: 'text' })]],
+ [
+ 'assistant',
+ [expect.objectContaining({ type: 'text', content: 'Sunny.' })],
+ ],
+ [
+ 'activity',
+ [
+ {
+ type: 'activity',
+ activityType: 'weather',
+ content: { city: 'Bergen' },
+ },
+ ],
+ ],
+ ]),
+ )
+ })
+})
diff --git a/packages/ai-ui-message-stream/tests/resume.test.ts b/packages/ai-ui-message-stream/tests/resume.test.ts
new file mode 100644
index 0000000000..53bcaac9d8
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/resume.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it, vi } from 'vitest'
+import { chat, memoryStream } from '@tanstack/ai'
+import { fakeText } from '@tanstack/ai/testing'
+import { ChatClient } from '@tanstack/ai-client'
+import { DefaultChatTransport, readUIMessageStream } from 'ai'
+import {
+ fromAISDKMessages,
+ fromChatTransport,
+ resumeUIMessageStreamResponse,
+ toResumableUIMessageStreamResponse,
+} from '../src'
+import type { ChatPersistedState } from '@tanstack/ai-client'
+import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai'
+
+const REPLY: Array = [
+ { type: 'start', messageId: 'a-1' },
+ { type: 'text-start', id: 't' },
+ { type: 'text-delta', id: 't', delta: 'Hel' },
+ { type: 'text-delta', id: 't', delta: 'lo.' },
+ { type: 'text-end', id: 't' },
+ { type: 'finish', finishReason: 'stop' },
+]
+
+/** A stream of `list` that fails with a dropped connection after `dropAfter` chunks. */
+function chunkStream(
+ list: Array,
+ dropAfter = Infinity,
+): ReadableStream {
+ let index = 0
+ return new ReadableStream({
+ pull(controller) {
+ if (index === dropAfter) controller.error(new TypeError('network error'))
+ else if (index === list.length) controller.close()
+ else controller.enqueue(list[index++]!)
+ },
+ })
+}
+
+function fakeTransport(
+ reconnect: () => ReadableStream | null,
+): ChatTransport & { reconnects: Array } {
+ const reconnects: Array = []
+ return {
+ resumeStreamIsReplay: true,
+ reconnects,
+ sendMessages: () => Promise.resolve(chunkStream(REPLY, 3)),
+ reconnectToStream: ({ chatId }) => {
+ reconnects.push(chatId)
+ return Promise.resolve(reconnect())
+ },
+ }
+}
+
+function assistantText(client: ChatClient): string {
+ const last = client.getMessages().at(-1)
+ if (last?.role !== 'assistant') return ''
+ return last.parts.map((p) => (p.type === 'text' ? p.content : '')).join('')
+}
+
+describe('TanStack client, AI SDK route: resume', () => {
+ it('reconnects a dropped stream and skips the replayed prefix', async () => {
+ const transport = fakeTransport(() => chunkStream(REPLY))
+ const client = new ChatClient({
+ threadId: 'chat-1',
+ connection: fromChatTransport(transport),
+ })
+
+ await client.sendMessage('hi')
+
+ expect(transport.reconnects).toEqual(['chat-1'])
+ expect(client.getMessages().map((m) => m.role)).toEqual([
+ 'user',
+ 'assistant',
+ ])
+ expect(assistantText(client)).toBe('Hello.')
+ expect(client.getError()).toBeUndefined()
+ })
+
+ it('rejoins the run after a page reload from the persisted resume pointer', async () => {
+ const transport = fakeTransport(() => chunkStream(REPLY))
+ const saved: ChatPersistedState = {
+ messages: [
+ {
+ id: 'u-1',
+ role: 'user',
+ parts: [{ type: 'text', content: 'hi' }],
+ },
+ {
+ id: 'a-1',
+ role: 'assistant',
+ parts: [{ type: 'text', content: 'Hel' }],
+ },
+ ],
+ resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } },
+ }
+ const client = new ChatClient({
+ threadId: 'chat-1',
+ persistence: {
+ getItem: () => saved,
+ setItem: () => {},
+ removeItem: () => {},
+ },
+ connection: fromChatTransport(transport, { threadId: 'chat-1' }),
+ })
+
+ client.attach()
+
+ await vi.waitFor(() => expect(assistantText(client)).toBe('Hello.'))
+ await vi.waitFor(() => expect(client.getIsLoading()).toBe(false))
+ expect(transport.reconnects).toEqual(['chat-1'])
+ expect(client.getMessages().map((m) => m.role)).toEqual([
+ 'user',
+ 'assistant',
+ ])
+ })
+
+ it('drops the resume pointer when the route has nothing to resume', async () => {
+ const transport = fakeTransport(() => null)
+ const writes: Array = []
+ const client = new ChatClient({
+ threadId: 'chat-1',
+ persistence: {
+ getItem: () => ({
+ messages: [
+ {
+ id: 'u-1',
+ role: 'user',
+ parts: [{ type: 'text', content: 'hi' }],
+ },
+ ],
+ resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } },
+ }),
+ setItem: (_id, state) => void writes.push(state),
+ removeItem: () => {},
+ },
+ connection: fromChatTransport(transport, { threadId: 'chat-1' }),
+ })
+
+ client.attach()
+
+ await vi.waitFor(() => {
+ expect(writes).not.toHaveLength(0)
+ expect(writes.at(-1)?.resume).toBeUndefined()
+ })
+ expect(transport.reconnects).toEqual(['chat-1'])
+ expect(client.getError()).toBeUndefined()
+ })
+})
+
+describe('AI SDK client, TanStack route: resume', () => {
+ function server() {
+ const fake = fakeText()
+ fake.setResponses([{ text: 'Resumed answer.' }])
+ // The app records the chat's active run. With `@tanstack/ai-persistence`
+ // this is `stores.runs.findActiveRun(chatId)`.
+ const activeRuns = new Map()
+
+ async function POST(request: Request): Promise {
+ const body: { id: string; messages: Array } =
+ await request.json()
+ const runId = crypto.randomUUID()
+ activeRuns.set(body.id, runId)
+ return toResumableUIMessageStreamResponse(
+ chat({
+ adapter: fake,
+ threadId: body.id,
+ runId,
+ messages: fromAISDKMessages(body.messages),
+ }),
+ memoryStream({ runId }),
+ )
+ }
+
+ // GET /api/chat/:id/stream
+ function GET(chatId: string): Response {
+ const runId = activeRuns.get(chatId)
+ return resumeUIMessageStreamResponse(
+ runId === undefined ? null : memoryStream({ runId }),
+ )
+ }
+
+ return new DefaultChatTransport({
+ api: 'http://test/api/chat',
+ fetch: async (input, init) => {
+ const url = new URL(String(input))
+ const match = /^\/api\/chat\/(.+)\/stream$/.exec(url.pathname)
+ if (match) return GET(decodeURIComponent(match[1]!))
+ return POST(new Request(url, init))
+ },
+ })
+ }
+
+ async function lastMessage(
+ stream: ReadableStream,
+ ): Promise {
+ let last: UIMessage | undefined
+ for await (const message of readUIMessageStream({ stream })) last = message
+ return last
+ }
+
+ it('replays the whole run on the resume GET after the POST is dropped', async () => {
+ const transport = server()
+ const posted = await transport.sendMessages({
+ trigger: 'submit-message',
+ chatId: 'chat-1',
+ messageId: undefined,
+ messages: [
+ { id: 'u-1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
+ ],
+ abortSignal: undefined,
+ })
+ // The page reloads: the POST response is dropped after its first chunk.
+ const reader = posted.getReader()
+ await reader.read()
+ await reader.cancel()
+
+ const resumed = await transport.reconnectToStream({ chatId: 'chat-1' })
+ expect(resumed).not.toBeNull()
+ const message = await lastMessage(resumed!)
+
+ expect(message?.parts.filter((p) => p.type === 'text')).toMatchObject([
+ { type: 'text', text: 'Resumed answer.' },
+ ])
+ expect(JSON.stringify(message)).not.toContain('run.accepted')
+ })
+
+ it('answers 204 (no stream) when the chat has no active run', async () => {
+ const transport = server()
+ expect(await transport.reconnectToStream({ chatId: 'other' })).toBeNull()
+ })
+})
diff --git a/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts
new file mode 100644
index 0000000000..8dece75597
--- /dev/null
+++ b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts
@@ -0,0 +1,354 @@
+import { describe, expect, it, vi } from 'vitest'
+import { EventSchemas } from '@ag-ui/core/schemas'
+import { EventType, chat, convertMessagesToModelMessages } from '@tanstack/ai'
+import { fakeText } from '@tanstack/ai/testing'
+import { toolDefinition } from '@tanstack/ai/client'
+import { ChatClient } from '@tanstack/ai-client'
+import { readUIMessageStream } from 'ai'
+import { z } from 'zod'
+import {
+ fromAISDKMessages,
+ fromChatTransport,
+ fromUIMessageStream,
+ toUIMessageStream,
+} from '../src'
+import type { StreamChunk } from '@tanstack/ai'
+import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai'
+
+async function* events(list: Array): AsyncGenerator {
+ yield* list
+}
+
+function chunkStream(
+ list: Array,
+): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of list) controller.enqueue(chunk)
+ controller.close()
+ },
+ })
+}
+
+describe('TanStack server, AI SDK client', () => {
+ it('builds the AI SDK message and replays it into chat()', async () => {
+ const stream = toUIMessageStream(
+ events([
+ { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' },
+ {
+ type: EventType.REASONING_MESSAGE_START,
+ messageId: 'think-1',
+ role: 'reasoning',
+ },
+ {
+ type: EventType.REASONING_MESSAGE_CONTENT,
+ messageId: 'think-1',
+ delta: 'Plan.',
+ },
+ {
+ type: EventType.REASONING_ENCRYPTED_VALUE,
+ subtype: 'message',
+ entityId: 'think-1',
+ encryptedValue: 'sig-1',
+ },
+ { type: EventType.REASONING_MESSAGE_END, messageId: 'think-1' },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'msg-1',
+ role: 'assistant',
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'msg-1',
+ delta: 'Checking.',
+ },
+ { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: 'call-1',
+ toolCallName: 'weather',
+ parentMessageId: 'msg-1',
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: 'call-1',
+ delta: '{"city":"NYC"}',
+ },
+ { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' },
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: 'res-1',
+ toolCallId: 'call-1',
+ content: '{"temp":72}',
+ role: 'tool',
+ },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'msg-1',
+ role: 'assistant',
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'msg-1',
+ delta: 'It is 72F.',
+ },
+ { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' },
+ {
+ type: EventType.RUN_FINISHED,
+ threadId: 't',
+ runId: 'r',
+ outcome: { type: 'success' },
+ metadata: { tanstack: { finishReason: 'stop' } },
+ },
+ ]),
+ )
+
+ let assistant: UIMessage | undefined
+ for await (const message of readUIMessageStream({ stream })) {
+ assistant = message
+ }
+
+ expect(assistant?.parts).toMatchObject([
+ {
+ type: 'reasoning',
+ text: 'Plan.',
+ providerMetadata: { tanstack: { signature: 'sig-1' } },
+ },
+ { type: 'text', text: 'Checking.' },
+ {
+ type: 'tool-weather',
+ toolCallId: 'call-1',
+ state: 'output-available',
+ input: { city: 'NYC' },
+ output: { temp: 72 },
+ },
+ { type: 'text', text: 'It is 72F.' },
+ ])
+
+ const user: UIMessage = {
+ id: 'u-1',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Weather in NYC?' }],
+ }
+ const history = convertMessagesToModelMessages(
+ fromAISDKMessages([user, assistant!]),
+ )
+ expect(history).toMatchObject([
+ { role: 'user', content: 'Weather in NYC?' },
+ {
+ role: 'assistant',
+ content: 'Checking.',
+ thinking: [{ content: 'Plan.', signature: 'sig-1' }],
+ toolCalls: [
+ {
+ id: 'call-1',
+ function: { name: 'weather', arguments: '{"city":"NYC"}' },
+ },
+ ],
+ },
+ { role: 'tool', toolCallId: 'call-1', content: '{"temp":72}' },
+ { role: 'assistant', content: 'It is 72F.' },
+ ])
+ })
+
+ it('serves real chat() output, then continues after an AI SDK client tool', async () => {
+ const fake = fakeText()
+ fake.setResponses([
+ {
+ thinking: 'Plan.',
+ toolCalls: [{ name: 'weather', input: { city: 'NYC' } }],
+ },
+ { toolCalls: [{ name: 'ask', input: { q: 'Units?' } }] },
+ ({ request }) => ({
+ text: `Answer: ${String(request.messages.at(-1)?.content)}`,
+ }),
+ ])
+ const weather = toolDefinition({
+ name: 'weather',
+ description: 'Weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+ }).server(() => ({ temp: 72 }))
+ const ask = toolDefinition({
+ name: 'ask',
+ description: 'Ask the user',
+ inputSchema: z.object({ q: z.string() }),
+ })
+
+ async function turn(messages: Array): Promise {
+ const stream = toUIMessageStream(
+ chat({
+ adapter: fake,
+ threadId: 'chat-1',
+ messages: fromAISDKMessages(messages),
+ tools: [weather, ask],
+ }),
+ )
+ let last: UIMessage | undefined
+ for await (const message of readUIMessageStream({ stream }))
+ last = message
+ return last!
+ }
+
+ const user: UIMessage = {
+ id: 'u-1',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Weather?' }],
+ }
+ const first = await turn([user])
+ expect(first.parts).toMatchObject([
+ { type: 'reasoning', text: 'Plan.' },
+ { type: 'tool-weather', state: 'output-available', output: { temp: 72 } },
+ { type: 'tool-ask', state: 'input-available', input: { q: 'Units?' } },
+ ])
+
+ // The AI SDK client runs `ask` and posts the history with its output.
+ const answered: UIMessage = {
+ ...first,
+ parts: first.parts.map((part) =>
+ part.type === 'tool-ask' && part.state === 'input-available'
+ ? { ...part, state: 'output-available', output: 'Fahrenheit' }
+ : part,
+ ),
+ }
+ const second = await turn([user, answered])
+ expect(second.parts).toMatchObject([
+ { type: 'text', text: 'Answer: Fahrenheit' },
+ ])
+ })
+
+ it('sends an AI SDK tool error back as an error result', () => {
+ const [assistant] = convertMessagesToModelMessages(
+ fromAISDKMessages([
+ {
+ id: 'a-1',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'dynamic-tool',
+ toolName: 'ping',
+ toolCallId: 'c-1',
+ state: 'output-error',
+ input: {},
+ errorText: 'offline',
+ },
+ ],
+ },
+ ]),
+ ).slice(1)
+ expect(assistant).toMatchObject({
+ role: 'tool',
+ toolCallId: 'c-1',
+ error: 'offline',
+ })
+ })
+})
+
+describe('AI SDK server, TanStack client', () => {
+ const firstTurn: Array = [
+ { type: 'start', messageId: 'a-1' },
+ { type: 'start-step' },
+ { type: 'reasoning-start', id: '0' },
+ { type: 'reasoning-delta', id: '0', delta: 'Hmm.' },
+ {
+ type: 'reasoning-end',
+ id: '0',
+ providerMetadata: { anthropic: { signature: 'sig' } },
+ },
+ { type: 'text-start', id: '1' },
+ { type: 'text-delta', id: '1', delta: 'Pinging.' },
+ { type: 'text-end', id: '1' },
+ { type: 'tool-input-start', toolCallId: 'c-1', toolName: 'ping' },
+ { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '{}' },
+ {
+ type: 'tool-input-available',
+ toolCallId: 'c-1',
+ toolName: 'ping',
+ input: {},
+ },
+ { type: 'finish-step' },
+ { type: 'finish', finishReason: 'tool-calls' },
+ ]
+
+ it('emits valid AG-UI events', async () => {
+ const out: Array = []
+ for await (const event of fromUIMessageStream(chunkStream(firstTurn), {
+ threadId: 't',
+ runId: 'r',
+ })) {
+ out.push(event)
+ }
+ for (const event of out) EventSchemas.parse(event)
+ expect(out.map((e) => e.type)).toEqual([
+ 'RUN_STARTED',
+ 'REASONING_MESSAGE_START',
+ 'REASONING_MESSAGE_CONTENT',
+ 'REASONING_ENCRYPTED_VALUE',
+ 'REASONING_MESSAGE_END',
+ 'TEXT_MESSAGE_START',
+ 'TEXT_MESSAGE_CONTENT',
+ 'TEXT_MESSAGE_END',
+ 'TOOL_CALL_START',
+ 'TOOL_CALL_ARGS',
+ 'TOOL_CALL_END',
+ 'RUN_FINISHED',
+ ])
+ })
+
+ it('runs a client tool and sends its output and reasoning back', async () => {
+ const requests: Array> = []
+ const transport: ChatTransport = {
+ sendMessages({ messages }) {
+ requests.push(messages)
+ return Promise.resolve(
+ chunkStream(
+ requests.length === 1
+ ? firstTurn
+ : [
+ { type: 'start' },
+ { type: 'text-start', id: '0' },
+ { type: 'text-delta', id: '0', delta: 'Got pong.' },
+ { type: 'text-end', id: '0' },
+ { type: 'finish', finishReason: 'stop' },
+ ],
+ ),
+ )
+ },
+ reconnectToStream: () => Promise.resolve(null),
+ }
+ const execute = vi.fn(() => ({ reply: 'pong' }))
+ const ping = toolDefinition({
+ name: 'ping',
+ description: 'Reply with pong',
+ inputSchema: z.object({}),
+ }).client(execute)
+ const client = new ChatClient({
+ threadId: 'thread-1',
+ connection: fromChatTransport(transport),
+ tools: [ping],
+ })
+
+ await client.sendMessage('ping me')
+
+ await vi.waitFor(() => expect(requests).toHaveLength(2))
+ expect(execute).toHaveBeenCalledTimes(1)
+ const sent = requests[1]!.find((m) => m.role === 'assistant')
+ expect(sent?.parts).toMatchObject([
+ {
+ type: 'reasoning',
+ text: 'Hmm.',
+ providerMetadata: { anthropic: { signature: 'sig' } },
+ },
+ { type: 'text', text: 'Pinging.' },
+ {
+ type: 'dynamic-tool',
+ toolName: 'ping',
+ toolCallId: 'c-1',
+ state: 'output-available',
+ output: { reply: 'pong' },
+ },
+ ])
+ await vi.waitFor(() =>
+ expect(JSON.stringify(client.getMessages())).toContain('Got pong.'),
+ )
+ })
+})
diff --git a/packages/ai-ui-message-stream/tsconfig.json b/packages/ai-ui-message-stream/tsconfig.json
new file mode 100644
index 0000000000..c38689f4ea
--- /dev/null
+++ b/packages/ai-ui-message-stream/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src", "tests"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/ai-ui-message-stream/vite.config.ts b/packages/ai-ui-message-stream/vite.config.ts
new file mode 100644
index 0000000000..11f5b20b70
--- /dev/null
+++ b/packages/ai-ui-message-stream/vite.config.ts
@@ -0,0 +1,37 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import { tanstackViteConfig } from '@tanstack/vite-config'
+import packageJson from './package.json'
+
+const config = defineConfig({
+ test: {
+ name: packageJson.name,
+ dir: './',
+ watch: false,
+
+ globals: true,
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html', 'lcov'],
+ exclude: [
+ 'node_modules/',
+ 'dist/',
+ 'tests/',
+ '**/*.test.ts',
+ '**/*.config.ts',
+ '**/types.ts',
+ ],
+ include: ['src/**/*.ts'],
+ },
+ },
+})
+
+export default mergeConfig(
+ config,
+ tanstackViteConfig({
+ entry: ['./src/index.ts'],
+ srcDir: './src',
+ cjs: false,
+ }),
+)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb2f1b2686..1eb3fb4014 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3703,6 +3703,31 @@ importers:
specifier: ^8.2.1
version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)
+ packages/ai-ui-message-stream:
+ dependencies:
+ fast-json-patch:
+ specifier: ^3.1.1
+ version: 3.1.1
+ devDependencies:
+ '@ag-ui/core':
+ specifier: 1.0.0
+ version: 1.0.0(zod@4.6.5)
+ '@tanstack/ai':
+ specifier: workspace:*
+ version: link:../ai
+ '@tanstack/ai-client':
+ specifier: workspace:*
+ version: link:../ai-client
+ '@vitest/coverage-v8':
+ specifier: 4.1.10
+ version: 4.1.10(vitest@4.1.11)
+ ai:
+ specifier: 7.0.148
+ version: 7.0.148(zod@4.6.5)
+ zod:
+ specifier: ^4.2.0
+ version: 4.6.5
+
packages/ai-utils:
devDependencies:
'@types/node':
@@ -3745,7 +3770,7 @@ importers:
devDependencies:
'@google/genai':
specifier: ^2.10.0
- version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))
+ version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))
'@tanstack/ai':
specifier: workspace:*
version: link:../ai
@@ -3954,6 +3979,12 @@ importers:
testing/e2e:
dependencies:
+ '@ai-sdk/openai':
+ specifier: 4.0.99
+ version: 4.0.99(zod@4.3.6)
+ '@ai-sdk/react':
+ specifier: 4.0.152
+ version: 4.0.152(react@19.2.3)(zod@4.3.6)
'@anthropic-ai/sdk':
specifier: ^0.135.0
version: 0.135.0(zod@4.3.6)
@@ -4080,6 +4111,9 @@ importers:
'@tanstack/ai-typesafe':
specifier: workspace:*
version: link:../../packages/ai-typesafe
+ '@tanstack/ai-ui-message-stream':
+ specifier: workspace:*
+ version: link:../../packages/ai-ui-message-stream
'@tanstack/ai-vercel-gateway':
specifier: workspace:*
version: link:../../packages/ai-vercel-gateway
@@ -4104,6 +4138,9 @@ importers:
'@tanstack/react-start':
specifier: ^1.168.60
version: 1.168.60(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))
+ ai:
+ specifier: 7.0.148
+ version: 7.0.148(zod@4.3.6)
arktype:
specifier: ^2.1.28
version: 2.2.0
@@ -4351,6 +4388,40 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
+ '@ai-sdk/gateway@4.0.121':
+ resolution: {integrity: sha512-cPNXvQ4B5OkmVAm98Dxe1fHdMthyqqqAG0HJx3D92NUa9FpcvkNxdAqiuZdeAhNAodFb9lBrslzzzEwxuVS53A==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/mcp@2.0.81':
+ resolution: {integrity: sha512-2ZqfHLu1K93uPse+QWbcNjkznCtM6UHMoNWjUDpmM9kCr+y9bWcf5VWfNAWi4ziQoncZPJo2qUWeDIXgrqLsKg==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/openai@4.0.99':
+ resolution: {integrity: sha512-XRHUiXgxhVEe0smSp537lzCqMsOVg6jvGTWDbTZeGMTc+iHeOwgHgZMFBvOiNWXZ8n125Jta72m+64PtU1WNJA==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/provider-utils@5.0.65':
+ resolution: {integrity: sha512-LCugcsP8oWmdeI3BASf4Fuo1wiC6Qx0YrefFssl7n12s+LV4DNi5RdwWQrtrB4oek3GGNQRN/h/NSwVZM2eHKw==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
+ '@ai-sdk/provider@4.0.31':
+ resolution: {integrity: sha512-3wJnwVTFg8SwOyoZCv+ov7Re2GB+7IQnU5a5MYJACrtiWDDUHgqTBeeesq2MB2ddgojzL8hgWleltwpAgtUqfg==}
+ engines: {node: '>=22'}
+
+ '@ai-sdk/react@4.0.152':
+ resolution: {integrity: sha512-2A4bttJJgDmdiCTXO6qM6J28q1C3ucAVAwbWzYuaQ4ZF1PUy7GwelNtMGMf2FoRl8krCeHYAdAhRnJlnfciKpQ==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
+
'@alcalzone/ansi-tokenize@0.3.1':
resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==}
engines: {node: '>=18'}
@@ -12182,6 +12253,9 @@ packages:
'@webgpu/types@0.1.72':
resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==}
+ '@workflow/serde@4.1.0':
+ resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
+
'@workflow/serde@4.1.0-beta.2':
resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==}
@@ -12246,6 +12320,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
+ ai@7.0.148:
+ resolution: {integrity: sha512-FPgfIAhz/FrlzcvqTN7pGGibahR5rD19NXZWX9WQ1tskTic+CN+jxvubpreyq8uN1maIEHPwvZmil/3IYN8FIw==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ zod: ^3.25.76 || ^4.1.8
+
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -15225,6 +15305,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+ json-schema@0.4.0:
+ resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -18034,6 +18117,11 @@ packages:
resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==}
engines: {node: '>=18'}
+ swr@2.5.1:
+ resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -18119,6 +18207,10 @@ packages:
throat@5.0.0:
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
+ throttleit@2.1.0:
+ resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
+ engines: {node: '>=18'}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -19490,10 +19582,76 @@ snapshots:
optionalDependencies:
zod: 4.2.1
+ '@ag-ui/core@1.0.0(zod@4.6.5)':
+ optionalDependencies:
+ zod: 4.6.5
+
'@agentclientprotocol/sdk@1.8.0(zod@4.6.5)':
dependencies:
zod: 4.6.5
+ '@ai-sdk/gateway@4.0.121(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6)
+ '@vercel/oidc': 3.2.0
+ zod: 4.3.6
+
+ '@ai-sdk/gateway@4.0.121(zod@4.6.5)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5)
+ '@vercel/oidc': 3.2.0
+ zod: 4.6.5
+
+ '@ai-sdk/mcp@2.0.81(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6)
+ cross-spawn: 7.0.6
+ pkce-challenge: 5.0.1
+ zod: 4.3.6
+
+ '@ai-sdk/openai@4.0.99(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6)
+ zod: 4.3.6
+
+ '@ai-sdk/provider-utils@5.0.65(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@standard-schema/spec': 1.1.0
+ '@workflow/serde': 4.1.0
+ eventsource-parser: 3.1.1
+ undici: 7.29.1
+ zod: 4.3.6
+
+ '@ai-sdk/provider-utils@5.0.65(zod@4.6.5)':
+ dependencies:
+ '@ai-sdk/provider': 4.0.31
+ '@standard-schema/spec': 1.1.0
+ '@workflow/serde': 4.1.0
+ eventsource-parser: 3.1.1
+ undici: 7.29.1
+ zod: 4.6.5
+
+ '@ai-sdk/provider@4.0.31':
+ dependencies:
+ json-schema: 0.4.0
+
+ '@ai-sdk/react@4.0.152(react@19.2.3)(zod@4.3.6)':
+ dependencies:
+ '@ai-sdk/mcp': 2.0.81(zod@4.3.6)
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6)
+ ai: 7.0.148(zod@4.3.6)
+ react: 19.2.3
+ swr: 2.5.1(react@19.2.3)
+ throttleit: 2.1.0
+ transitivePeerDependencies:
+ - zod
+
'@alcalzone/ansi-tokenize@0.3.1':
dependencies:
ansi-styles: 6.2.3
@@ -22720,6 +22878,19 @@ snapshots:
- supports-color
- utf-8-validate
+ '@google/genai@2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))':
+ dependencies:
+ google-auth-library: 10.5.0
+ p-retry: 4.6.2
+ protobufjs: 7.6.4
+ ws: 8.21.0
+ optionalDependencies:
+ '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
'@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)':
dependencies:
graphql: 16.14.2
@@ -23214,7 +23385,7 @@ snapshots:
'@modelcontextprotocol/core@2.1.0':
dependencies:
- zod: 4.3.6
+ zod: 4.6.5
'@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)':
dependencies:
@@ -23297,6 +23468,31 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)':
+ dependencies:
+ '@hono/node-server': 1.19.17(hono@4.13.8)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.1
+ express: 5.2.1
+ express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0)
+ hono: 4.13.8
+ jose: 6.2.12
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.6.5
+ zod-to-json-schema: 3.25.2(zod@4.6.5)
+ optionalDependencies:
+ '@cfworker/json-schema': 4.1.1
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
'@modelcontextprotocol/server@2.1.0':
dependencies:
'@modelcontextprotocol/core': 2.1.0
@@ -29503,6 +29699,8 @@ snapshots:
'@webgpu/types@0.1.72': {}
+ '@workflow/serde@4.1.0': {}
+
'@workflow/serde@4.1.0-beta.2': {}
'@xmldom/xmldom@0.8.13': {}
@@ -29551,6 +29749,20 @@ snapshots:
agent-base@7.1.4: {}
+ ai@7.0.148(zod@4.3.6):
+ dependencies:
+ '@ai-sdk/gateway': 4.0.121(zod@4.3.6)
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6)
+ zod: 4.3.6
+
+ ai@7.0.148(zod@4.6.5):
+ dependencies:
+ '@ai-sdk/gateway': 4.0.121(zod@4.6.5)
+ '@ai-sdk/provider': 4.0.31
+ '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5)
+ zod: 4.6.5
+
ajv-draft-04@1.0.0(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -30322,7 +30534,7 @@ snapshots:
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
- undici: 7.29.0
+ undici: 7.29.1
whatwg-mimetype: 4.0.0
chokidar@4.0.3:
@@ -32957,6 +33169,8 @@ snapshots:
json-schema-typed@8.0.2: {}
+ json-schema@0.4.0: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
@@ -37418,6 +37632,12 @@ snapshots:
transitivePeerDependencies:
- '@typescript-eslint/types'
+ swr@2.5.1(react@19.2.3):
+ dependencies:
+ dequal: 2.0.3
+ react: 19.2.3
+ use-sync-external-store: 1.6.0(react@19.2.3)
+
symbol-tree@3.2.4: {}
synckit@0.11.13:
@@ -37537,6 +37757,8 @@ snapshots:
throat@5.0.0: {}
+ throttleit@2.1.0: {}
+
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
diff --git a/skills/tanstack-ai-migration/SKILL.md b/skills/tanstack-ai-migration/SKILL.md
index 165da6dde7..f107ef1519 100644
--- a/skills/tanstack-ai-migration/SKILL.md
+++ b/skills/tanstack-ai-migration/SKILL.md
@@ -36,6 +36,21 @@ Read the guide, then load the target API's own skill with
`npx @tanstack/intent@latest load @tanstack/ai#ai-core`. Do not port from memory: a half-remembered mapping produces
code that type-checks in neither SDK.
+To port one side at a time, `@tanstack/ai-ui-message-stream` connects the two
+SDKs over the AI SDK UI message stream:
+
+- Server first: keep AI SDK `useChat` and serve it from `chat()` with
+ `chat({ adapter, ...(await chatParamsFromAISDKRequest(request)) })` and
+ `toUIMessageStream`.
+ https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server
+- Client first: keep the `streamText` route and pass
+ `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })`
+ as the `connection` of TanStack `useChat`.
+ https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server
+
+Tool approvals, client tools, activity, subagents and stream resume cross the
+bridge. Shared state goes from a TanStack server to an AI SDK client only.
+
## Upgrading TanStack AI itself
Start with the version the app has, and the current release:
diff --git a/testing/e2e/fixtures/ai-sdk/approval.json b/testing/e2e/fixtures/ai-sdk/approval.json
new file mode 100644
index 0000000000..37bab7bffc
--- /dev/null
+++ b/testing/e2e/fixtures/ai-sdk/approval.json
@@ -0,0 +1,27 @@
+{
+ "fixtures": [
+ {
+ "match": {
+ "userMessage": "[ai-sdk-approval] buy the stratocaster",
+ "hasToolResult": false
+ },
+ "response": {
+ "toolCalls": [
+ {
+ "name": "addToCart",
+ "arguments": "{\"guitarId\":\"1\",\"quantity\":1}"
+ }
+ ]
+ }
+ },
+ {
+ "match": {
+ "userMessage": "[ai-sdk-approval] buy the stratocaster",
+ "hasToolResult": true
+ },
+ "response": {
+ "content": "The Stratocaster is in your cart."
+ }
+ }
+ ]
+}
diff --git a/testing/e2e/package.json b/testing/e2e/package.json
index ca1b4ed91f..d86be524b5 100644
--- a/testing/e2e/package.json
+++ b/testing/e2e/package.json
@@ -12,6 +12,8 @@
"postinstall": "playwright install chromium"
},
"dependencies": {
+ "@ai-sdk/openai": "4.0.99",
+ "@ai-sdk/react": "4.0.152",
"@anthropic-ai/sdk": "^0.135.0",
"@copilotkit/aimock": "^1.34.0",
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -54,6 +56,7 @@
"@tanstack/ai-skills": "workspace:*",
"@tanstack/ai-solid": "workspace:*",
"@tanstack/ai-typesafe": "workspace:*",
+ "@tanstack/ai-ui-message-stream": "workspace:*",
"@tanstack/ai-vercel-gateway": "workspace:*",
"@tanstack/ai-vertex": "workspace:*",
"@tanstack/ai-worldlabs": "workspace:*",
@@ -62,6 +65,7 @@
"@tanstack/react-ai-devtools": "workspace:*",
"@tanstack/react-router": "^1.170.41",
"@tanstack/react-start": "^1.168.60",
+ "ai": "7.0.148",
"arktype": "^2.1.28",
"react": "^19.2.3",
"react-dom": "^19.2.3",
diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts
index 35cb311a76..de5a1682dc 100644
--- a/testing/e2e/src/routeTree.gen.ts
+++ b/testing/e2e/src/routeTree.gen.ts
@@ -11,6 +11,9 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ActivityTestRouteImport } from './routes/activity-test'
+import { Route as AiSdkClientRouteImport } from './routes/ai-sdk-client'
+import { Route as AiSdkResumeRouteImport } from './routes/ai-sdk-resume'
+import { Route as AiSdkServerRouteImport } from './routes/ai-sdk-server'
import { Route as ByokRouteImport } from './routes/byok'
import { Route as CallOwnershipRouteImport } from './routes/call-ownership'
import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge'
@@ -52,6 +55,8 @@ import { Route as ProviderIndexRouteImport } from './routes/$provider/index'
import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature'
import { Route as ApiActivityTestRouteImport } from './routes/api.activity-test'
import { Route as ApiAdapterInputModalitiesRouteImport } from './routes/api.adapter-input-modalities'
+import { Route as ApiAiSdkClientRouteImport } from './routes/api.ai-sdk-client'
+import { Route as ApiAiSdkServerRouteImport } from './routes/api.ai-sdk-server'
import { Route as ApiAnthropicAuthWireRouteImport } from './routes/api.anthropic-auth-wire'
import { Route as ApiAnthropicBugTestRouteImport } from './routes/api.anthropic-bug-test'
import { Route as ApiAnthropicComputerToolsetWireRouteImport } from './routes/api.anthropic-computer-toolset-wire'
@@ -199,11 +204,12 @@ import { Route as ApiVoiceRouteImport } from './routes/api.voice'
import { Route as ApiWebMcpPageToolsRouteImport } from './routes/api.web-mcp-page-tools'
import { Route as ApiWorldRouteImport } from './routes/api.world'
import { Route as ApiWrapFetchWireRouteImport } from './routes/api.wrap-fetch-wire'
+import { Route as ApiAiSdkResumeSplatRouteImport } from './routes/api.ai-sdk-resume.$'
import { Route as ApiAudioStreamRouteImport } from './routes/api.audio.stream'
-import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$'
-import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$'
import { Route as ApiHarnessHandoffSplatRouteImport } from './routes/api.harness-handoff.$'
import { Route as ApiHarnessObservabilitySplatRouteImport } from './routes/api.harness-observability.$'
+import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$'
+import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$'
import { Route as ApiHarnessWaitsSplatRouteImport } from './routes/api.harness-waits.$'
import { Route as ApiImageStreamRouteImport } from './routes/api.image.stream'
import { Route as ApiTranscriptionStreamRouteImport } from './routes/api.transcription.stream'
@@ -220,6 +226,21 @@ const ActivityTestRoute = ActivityTestRouteImport.update({
path: '/activity-test',
getParentRoute: () => rootRouteImport,
} as any)
+const AiSdkClientRoute = AiSdkClientRouteImport.update({
+ id: '/ai-sdk-client',
+ path: '/ai-sdk-client',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AiSdkResumeRoute = AiSdkResumeRouteImport.update({
+ id: '/ai-sdk-resume',
+ path: '/ai-sdk-resume',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AiSdkServerRoute = AiSdkServerRouteImport.update({
+ id: '/ai-sdk-server',
+ path: '/ai-sdk-server',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ByokRoute = ByokRouteImport.update({
id: '/byok',
path: '/byok',
@@ -429,6 +450,16 @@ const ApiAdapterInputModalitiesRoute =
path: '/api/adapter-input-modalities',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiAiSdkClientRoute = ApiAiSdkClientRouteImport.update({
+ id: '/api/ai-sdk-client',
+ path: '/api/ai-sdk-client',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiAiSdkServerRoute = ApiAiSdkServerRouteImport.update({
+ id: '/api/ai-sdk-server',
+ path: '/api/ai-sdk-server',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiAnthropicAuthWireRoute = ApiAnthropicAuthWireRouteImport.update({
id: '/api/anthropic-auth-wire',
path: '/api/anthropic-auth-wire',
@@ -1207,22 +1238,16 @@ const ApiWrapFetchWireRoute = ApiWrapFetchWireRouteImport.update({
path: '/api/wrap-fetch-wire',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiAiSdkResumeSplatRoute = ApiAiSdkResumeSplatRouteImport.update({
+ id: '/api/ai-sdk-resume/$',
+ path: '/api/ai-sdk-resume/$',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({
id: '/stream',
path: '/stream',
getParentRoute: () => ApiAudioRoute,
} as any)
-const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({
- id: '/api/harness-protocol/$',
- path: '/api/harness-protocol/$',
- getParentRoute: () => rootRouteImport,
-} as any)
-const ApiHarnessSecretAnswerSplatRoute =
- ApiHarnessSecretAnswerSplatRouteImport.update({
- id: '/api/harness-secret-answer/$',
- path: '/api/harness-secret-answer/$',
- getParentRoute: () => rootRouteImport,
- } as any)
const ApiHarnessHandoffSplatRoute = ApiHarnessHandoffSplatRouteImport.update({
id: '/api/harness-handoff/$',
path: '/api/harness-handoff/$',
@@ -1234,6 +1259,17 @@ const ApiHarnessObservabilitySplatRoute =
path: '/api/harness-observability/$',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({
+ id: '/api/harness-protocol/$',
+ path: '/api/harness-protocol/$',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ApiHarnessSecretAnswerSplatRoute =
+ ApiHarnessSecretAnswerSplatRouteImport.update({
+ id: '/api/harness-secret-answer/$',
+ path: '/api/harness-secret-answer/$',
+ getParentRoute: () => rootRouteImport,
+ } as any)
const ApiHarnessWaitsSplatRoute = ApiHarnessWaitsSplatRouteImport.update({
id: '/api/harness-waits/$',
path: '/api/harness-waits/$',
@@ -1263,6 +1299,9 @@ const ApiVideoStreamRoute = ApiVideoStreamRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/activity-test': typeof ActivityTestRoute
+ '/ai-sdk-client': typeof AiSdkClientRoute
+ '/ai-sdk-resume': typeof AiSdkResumeRoute
+ '/ai-sdk-server': typeof AiSdkServerRoute
'/byok': typeof ByokRoute
'/call-ownership': typeof CallOwnershipRoute
'/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute
@@ -1303,6 +1342,8 @@ export interface FileRoutesByFullPath {
'/$provider/$feature': typeof ProviderFeatureRoute
'/api/activity-test': typeof ApiActivityTestRoute
'/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute
+ '/api/ai-sdk-client': typeof ApiAiSdkClientRoute
+ '/api/ai-sdk-server': typeof ApiAiSdkServerRoute
'/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
'/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute
@@ -1451,11 +1492,12 @@ export interface FileRoutesByFullPath {
'/api/world': typeof ApiWorldRoute
'/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute
'/$provider/': typeof ProviderIndexRoute
+ '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute
'/api/audio/stream': typeof ApiAudioStreamRoute
- '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
- '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute
'/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute
+ '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
+ '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute
'/api/image/stream': typeof ApiImageStreamRoute
'/api/transcription/stream': typeof ApiTranscriptionStreamRoute
@@ -1465,6 +1507,9 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/activity-test': typeof ActivityTestRoute
+ '/ai-sdk-client': typeof AiSdkClientRoute
+ '/ai-sdk-resume': typeof AiSdkResumeRoute
+ '/ai-sdk-server': typeof AiSdkServerRoute
'/byok': typeof ByokRoute
'/call-ownership': typeof CallOwnershipRoute
'/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute
@@ -1505,6 +1550,8 @@ export interface FileRoutesByTo {
'/$provider/$feature': typeof ProviderFeatureRoute
'/api/activity-test': typeof ApiActivityTestRoute
'/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute
+ '/api/ai-sdk-client': typeof ApiAiSdkClientRoute
+ '/api/ai-sdk-server': typeof ApiAiSdkServerRoute
'/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
'/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute
@@ -1653,11 +1700,12 @@ export interface FileRoutesByTo {
'/api/world': typeof ApiWorldRoute
'/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute
'/$provider': typeof ProviderIndexRoute
+ '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute
'/api/audio/stream': typeof ApiAudioStreamRoute
- '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
- '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute
'/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute
+ '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
+ '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute
'/api/image/stream': typeof ApiImageStreamRoute
'/api/transcription/stream': typeof ApiTranscriptionStreamRoute
@@ -1668,6 +1716,9 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/activity-test': typeof ActivityTestRoute
+ '/ai-sdk-client': typeof AiSdkClientRoute
+ '/ai-sdk-resume': typeof AiSdkResumeRoute
+ '/ai-sdk-server': typeof AiSdkServerRoute
'/byok': typeof ByokRoute
'/call-ownership': typeof CallOwnershipRoute
'/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute
@@ -1708,6 +1759,8 @@ export interface FileRoutesById {
'/$provider/$feature': typeof ProviderFeatureRoute
'/api/activity-test': typeof ApiActivityTestRoute
'/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute
+ '/api/ai-sdk-client': typeof ApiAiSdkClientRoute
+ '/api/ai-sdk-server': typeof ApiAiSdkServerRoute
'/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute
'/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute
'/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute
@@ -1856,11 +1909,12 @@ export interface FileRoutesById {
'/api/world': typeof ApiWorldRoute
'/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute
'/$provider/': typeof ProviderIndexRoute
+ '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute
'/api/audio/stream': typeof ApiAudioStreamRoute
- '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
- '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute
'/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute
+ '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute
+ '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute
'/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute
'/api/image/stream': typeof ApiImageStreamRoute
'/api/transcription/stream': typeof ApiTranscriptionStreamRoute
@@ -1872,6 +1926,9 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/activity-test'
+ | '/ai-sdk-client'
+ | '/ai-sdk-resume'
+ | '/ai-sdk-server'
| '/byok'
| '/call-ownership'
| '/chat-client-default-bridge'
@@ -1912,6 +1969,8 @@ export interface FileRouteTypes {
| '/$provider/$feature'
| '/api/activity-test'
| '/api/adapter-input-modalities'
+ | '/api/ai-sdk-client'
+ | '/api/ai-sdk-server'
| '/api/anthropic-auth-wire'
| '/api/anthropic-bug-test'
| '/api/anthropic-computer-toolset-wire'
@@ -2060,11 +2119,12 @@ export interface FileRouteTypes {
| '/api/world'
| '/api/wrap-fetch-wire'
| '/$provider/'
+ | '/api/ai-sdk-resume/$'
| '/api/audio/stream'
- | '/api/harness-protocol/$'
- | '/api/harness-secret-answer/$'
| '/api/harness-handoff/$'
| '/api/harness-observability/$'
+ | '/api/harness-protocol/$'
+ | '/api/harness-secret-answer/$'
| '/api/harness-waits/$'
| '/api/image/stream'
| '/api/transcription/stream'
@@ -2074,6 +2134,9 @@ export interface FileRouteTypes {
to:
| '/'
| '/activity-test'
+ | '/ai-sdk-client'
+ | '/ai-sdk-resume'
+ | '/ai-sdk-server'
| '/byok'
| '/call-ownership'
| '/chat-client-default-bridge'
@@ -2114,6 +2177,8 @@ export interface FileRouteTypes {
| '/$provider/$feature'
| '/api/activity-test'
| '/api/adapter-input-modalities'
+ | '/api/ai-sdk-client'
+ | '/api/ai-sdk-server'
| '/api/anthropic-auth-wire'
| '/api/anthropic-bug-test'
| '/api/anthropic-computer-toolset-wire'
@@ -2262,11 +2327,12 @@ export interface FileRouteTypes {
| '/api/world'
| '/api/wrap-fetch-wire'
| '/$provider'
+ | '/api/ai-sdk-resume/$'
| '/api/audio/stream'
- | '/api/harness-protocol/$'
- | '/api/harness-secret-answer/$'
| '/api/harness-handoff/$'
| '/api/harness-observability/$'
+ | '/api/harness-protocol/$'
+ | '/api/harness-secret-answer/$'
| '/api/harness-waits/$'
| '/api/image/stream'
| '/api/transcription/stream'
@@ -2276,6 +2342,9 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/activity-test'
+ | '/ai-sdk-client'
+ | '/ai-sdk-resume'
+ | '/ai-sdk-server'
| '/byok'
| '/call-ownership'
| '/chat-client-default-bridge'
@@ -2316,6 +2385,8 @@ export interface FileRouteTypes {
| '/$provider/$feature'
| '/api/activity-test'
| '/api/adapter-input-modalities'
+ | '/api/ai-sdk-client'
+ | '/api/ai-sdk-server'
| '/api/anthropic-auth-wire'
| '/api/anthropic-bug-test'
| '/api/anthropic-computer-toolset-wire'
@@ -2464,11 +2535,12 @@ export interface FileRouteTypes {
| '/api/world'
| '/api/wrap-fetch-wire'
| '/$provider/'
+ | '/api/ai-sdk-resume/$'
| '/api/audio/stream'
- | '/api/harness-protocol/$'
- | '/api/harness-secret-answer/$'
| '/api/harness-handoff/$'
| '/api/harness-observability/$'
+ | '/api/harness-protocol/$'
+ | '/api/harness-secret-answer/$'
| '/api/harness-waits/$'
| '/api/image/stream'
| '/api/transcription/stream'
@@ -2479,6 +2551,9 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ActivityTestRoute: typeof ActivityTestRoute
+ AiSdkClientRoute: typeof AiSdkClientRoute
+ AiSdkResumeRoute: typeof AiSdkResumeRoute
+ AiSdkServerRoute: typeof AiSdkServerRoute
ByokRoute: typeof ByokRoute
CallOwnershipRoute: typeof CallOwnershipRoute
ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute
@@ -2519,6 +2594,8 @@ export interface RootRouteChildren {
ProviderFeatureRoute: typeof ProviderFeatureRoute
ApiActivityTestRoute: typeof ApiActivityTestRoute
ApiAdapterInputModalitiesRoute: typeof ApiAdapterInputModalitiesRoute
+ ApiAiSdkClientRoute: typeof ApiAiSdkClientRoute
+ ApiAiSdkServerRoute: typeof ApiAiSdkServerRoute
ApiAnthropicAuthWireRoute: typeof ApiAnthropicAuthWireRoute
ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute
ApiAnthropicComputerToolsetWireRoute: typeof ApiAnthropicComputerToolsetWireRoute
@@ -2667,10 +2744,11 @@ export interface RootRouteChildren {
ApiWorldRoute: typeof ApiWorldRoute
ApiWrapFetchWireRoute: typeof ApiWrapFetchWireRoute
ProviderIndexRoute: typeof ProviderIndexRoute
- ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute
- ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute
+ ApiAiSdkResumeSplatRoute: typeof ApiAiSdkResumeSplatRoute
ApiHarnessHandoffSplatRoute: typeof ApiHarnessHandoffSplatRoute
ApiHarnessObservabilitySplatRoute: typeof ApiHarnessObservabilitySplatRoute
+ ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute
+ ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute
ApiHarnessWaitsSplatRoute: typeof ApiHarnessWaitsSplatRoute
}
@@ -2690,6 +2768,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ActivityTestRouteImport
parentRoute: typeof rootRouteImport
}
+ '/ai-sdk-client': {
+ id: '/ai-sdk-client'
+ path: '/ai-sdk-client'
+ fullPath: '/ai-sdk-client'
+ preLoaderRoute: typeof AiSdkClientRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/ai-sdk-resume': {
+ id: '/ai-sdk-resume'
+ path: '/ai-sdk-resume'
+ fullPath: '/ai-sdk-resume'
+ preLoaderRoute: typeof AiSdkResumeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/ai-sdk-server': {
+ id: '/ai-sdk-server'
+ path: '/ai-sdk-server'
+ fullPath: '/ai-sdk-server'
+ preLoaderRoute: typeof AiSdkServerRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/byok': {
id: '/byok'
path: '/byok'
@@ -2977,6 +3076,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAdapterInputModalitiesRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/ai-sdk-client': {
+ id: '/api/ai-sdk-client'
+ path: '/api/ai-sdk-client'
+ fullPath: '/api/ai-sdk-client'
+ preLoaderRoute: typeof ApiAiSdkClientRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/ai-sdk-server': {
+ id: '/api/ai-sdk-server'
+ path: '/api/ai-sdk-server'
+ fullPath: '/api/ai-sdk-server'
+ preLoaderRoute: typeof ApiAiSdkServerRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/anthropic-auth-wire': {
id: '/api/anthropic-auth-wire'
path: '/api/anthropic-auth-wire'
@@ -4006,6 +4119,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiWrapFetchWireRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/ai-sdk-resume/$': {
+ id: '/api/ai-sdk-resume/$'
+ path: '/api/ai-sdk-resume/$'
+ fullPath: '/api/ai-sdk-resume/$'
+ preLoaderRoute: typeof ApiAiSdkResumeSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/audio/stream': {
id: '/api/audio/stream'
path: '/stream'
@@ -4013,20 +4133,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAudioStreamRouteImport
parentRoute: typeof ApiAudioRoute
}
- '/api/harness-protocol/$': {
- id: '/api/harness-protocol/$'
- path: '/api/harness-protocol/$'
- fullPath: '/api/harness-protocol/$'
- preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/api/harness-secret-answer/$': {
- id: '/api/harness-secret-answer/$'
- path: '/api/harness-secret-answer/$'
- fullPath: '/api/harness-secret-answer/$'
- preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport
- parentRoute: typeof rootRouteImport
- }
'/api/harness-handoff/$': {
id: '/api/harness-handoff/$'
path: '/api/harness-handoff/$'
@@ -4041,6 +4147,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHarnessObservabilitySplatRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/harness-protocol/$': {
+ id: '/api/harness-protocol/$'
+ path: '/api/harness-protocol/$'
+ fullPath: '/api/harness-protocol/$'
+ preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/api/harness-secret-answer/$': {
+ id: '/api/harness-secret-answer/$'
+ path: '/api/harness-secret-answer/$'
+ fullPath: '/api/harness-secret-answer/$'
+ preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/harness-waits/$': {
id: '/api/harness-waits/$'
path: '/api/harness-waits/$'
@@ -4140,6 +4260,9 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ActivityTestRoute: ActivityTestRoute,
+ AiSdkClientRoute: AiSdkClientRoute,
+ AiSdkResumeRoute: AiSdkResumeRoute,
+ AiSdkServerRoute: AiSdkServerRoute,
ByokRoute: ByokRoute,
CallOwnershipRoute: CallOwnershipRoute,
ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute,
@@ -4180,6 +4303,8 @@ const rootRouteChildren: RootRouteChildren = {
ProviderFeatureRoute: ProviderFeatureRoute,
ApiActivityTestRoute: ApiActivityTestRoute,
ApiAdapterInputModalitiesRoute: ApiAdapterInputModalitiesRoute,
+ ApiAiSdkClientRoute: ApiAiSdkClientRoute,
+ ApiAiSdkServerRoute: ApiAiSdkServerRoute,
ApiAnthropicAuthWireRoute: ApiAnthropicAuthWireRoute,
ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute,
ApiAnthropicComputerToolsetWireRoute: ApiAnthropicComputerToolsetWireRoute,
@@ -4334,10 +4459,11 @@ const rootRouteChildren: RootRouteChildren = {
ApiWorldRoute: ApiWorldRoute,
ApiWrapFetchWireRoute: ApiWrapFetchWireRoute,
ProviderIndexRoute: ProviderIndexRoute,
- ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute,
- ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute,
+ ApiAiSdkResumeSplatRoute: ApiAiSdkResumeSplatRoute,
ApiHarnessHandoffSplatRoute: ApiHarnessHandoffSplatRoute,
ApiHarnessObservabilitySplatRoute: ApiHarnessObservabilitySplatRoute,
+ ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute,
+ ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute,
ApiHarnessWaitsSplatRoute: ApiHarnessWaitsSplatRoute,
}
export const routeTree = rootRouteImport
diff --git a/testing/e2e/src/routes/ai-sdk-client.tsx b/testing/e2e/src/routes/ai-sdk-client.tsx
new file mode 100644
index 0000000000..652b7d8f75
--- /dev/null
+++ b/testing/e2e/src/routes/ai-sdk-client.tsx
@@ -0,0 +1,67 @@
+import { useEffect } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import { useChat } from '@ai-sdk/react'
+import {
+ DefaultChatTransport,
+ isToolUIPart,
+ lastAssistantMessageIsCompleteWithApprovalResponses,
+} from 'ai'
+
+// AI SDK `useChat` talks to the TanStack `chat()` route in
+// `api.ai-sdk-client.ts`. `?prompt=` sets the first user message.
+const transport = new DefaultChatTransport({ api: '/api/ai-sdk-client' })
+
+export const Route = createFileRoute('/ai-sdk-client')({
+ validateSearch: (search: Record) => ({
+ prompt: typeof search.prompt === 'string' ? search.prompt : undefined,
+ }),
+ component: AISDKClientPage,
+})
+
+function AISDKClientPage() {
+ const { prompt = 'Weather in Oslo?' } = Route.useSearch()
+ const { messages, sendMessage, addToolApprovalResponse } = useChat({
+ transport,
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
+ })
+
+ const parts = messages
+ .filter((message) => message.role === 'assistant')
+ .flatMap((message) => message.parts)
+ const text = parts.flatMap((part) =>
+ part.type === 'text' ? [part.text] : [],
+ )
+ const toolParts = parts.filter(isToolUIPart)
+ const toolOutputs = toolParts.flatMap((part) =>
+ part.state === 'output-available' ? [String(part.output)] : [],
+ )
+
+ useEffect(() => {
+ void sendMessage({ text: prompt })
+ // Send once on mount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ return (
+