diff --git a/.changeset/ai-ui-message-stream-client.md b/.changeset/ai-ui-message-stream-client.md new file mode 100644 index 0000000000..a12909be45 --- /dev/null +++ b/.changeset/ai-ui-message-stream-client.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-ui-message-stream': minor +'@tanstack/ai-client': patch +--- + +TanStack `useChat` gets more from an AI SDK route through `fromChatTransport`: + +- Client tools go to the route in the request body. Read them on the route with `clientToolsFromAISDKBody(body, serverTools)`, so the route needs no copy. +- With `outputSchema`, an `Output.object` answer is a typed `structured-output` part with partial objects. +- A dropped stream reconnects with the backoff of the TanStack HTTP connections. Set it with the `reconnect` option. + +`@tanstack/ai-client` exports `createReconnectTracker` and its `ReconnectTracker` type. diff --git a/.changeset/ai-ui-message-stream.md b/.changeset/ai-ui-message-stream.md new file mode 100644 index 0000000000..9d7074aced --- /dev/null +++ b/.changeset/ai-ui-message-stream.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-ui-message-stream': minor +--- + +Add `@tanstack/ai-ui-message-stream`, a stop gap for porting an app from the Vercel AI SDK one side at a time. It connects TanStack AI to the AI SDK UI message stream. + +- Serve AI SDK `useChat` from `chat()` with `chatParamsFromAISDKRequest` and `toUIMessageStream`. Use `toResumableUIMessageStreamResponse` and `resumeUIMessageStreamResponse` for `useChat({ resume: true })`. +- Point TanStack `useChat` at an AI SDK route with `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })`. + +Text, reasoning with its signature, tool calls and results, client tools, tool approvals, activity, subagents and stream resume cross in both directions. Shared state goes from a TanStack server to an AI SDK client. diff --git a/docs/config.json b/docs/config.json index 359b32483f..c88cfa36e3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1951,13 +1951,28 @@ "label": "From Vercel AI SDK", "to": "migration/migration-from-vercel-ai", "addedAt": "2026-04-20", - "updatedAt": "2026-10-22" + "updatedAt": "2026-10-23" }, { "label": "Vercel AI SDK: full API mapping", "to": "migration/migration-from-vercel-ai-advanced", "addedAt": "2026-10-21" }, + { + "label": "AI SDK useChat with a TanStack AI server", + "to": "migration/ai-sdk-client-tanstack-server", + "addedAt": "2026-10-23" + }, + { + "label": "TanStack useChat with an AI SDK server", + "to": "migration/tanstack-client-ai-sdk-server", + "addedAt": "2026-10-23" + }, + { + "label": "AI SDK bridge compatibility", + "to": "migration/ai-sdk-compatibility", + "addedAt": "2026-10-23" + }, { "label": "AG-UI Client Compliance", "to": "migration/ag-ui-compliance", diff --git a/docs/migration/ai-sdk-client-tanstack-server.md b/docs/migration/ai-sdk-client-tanstack-server.md new file mode 100644 index 0000000000..cc20922e14 --- /dev/null +++ b/docs/migration/ai-sdk-client-tanstack-server.md @@ -0,0 +1,262 @@ +--- +title: AI SDK useChat with a TanStack AI server +id: ai-sdk-client-tanstack-server +order: 3 +description: "Serve the Vercel AI SDK useChat hook from TanStack AI chat(). Read the request with chatParamsFromAISDKRequest and convert the reply with toUIMessageStream. Your AI SDK UI does not change." +keywords: + - tanstack ai + - vercel ai sdk + - useChat + - ai elements + - ui message stream + - toUIMessageStream + - tool approval + - resumable streams + - migration +--- + +Serve the AI SDK `useChat` hook from TanStack AI `chat()`. Read the request with `chatParamsFromAISDKRequest`, and convert the reply with `toUIMessageStream`. Your AI SDK client code, AI Elements included, does not change. + +Use this page to move the server first. To move the client first, see [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). + +> [!WARNING] +> This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the server works, port the client to TanStack `useChat` with the [migration guide](./migration-from-vercel-ai), then remove the bridge. + +## 1. Install the packages + + + +react: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +vue: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +solid: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +svelte: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +preact: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +angular: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +octane: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai +vanilla: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai + + + +Keep the `ai` package. The server uses its response helper, and your client still uses `useChat`. + +## 2. Replace the server route + +```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +What each call does: + +- `chatParamsFromAISDKRequest` reads the body that `useChat` sends. It returns the `threadId` (the chat id), the `messages`, and the answers to tool approvals. If the body is not valid, it throws a `Response` with status 400. +- `toUIMessageStream` converts the events of `chat()` into the AI SDK UI message stream. + +## 3. Keep the client + +The client from your AI SDK app works as it is: + +```tsx +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' + +export function Chat() { + const { messages, sendMessage } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }) + + return ( +
+ {messages.map((m) => ( +
+ {m.parts.map((p, i) => (p.type === 'text' ? {p.text} : null))} +
+ ))} + +
+ ) +} +``` + +## 4. Add tools + +Server tools run in `chat()`. The client shows each call as a `tool-` part with its input and output. + +- **Client tool:** a tool definition with no `.server()` implementation. `chat()` stops at the call, and the AI SDK client runs it in `onToolCall`. +- **Tool approval:** set `needsApproval: true`. `chat()` stops before the tool runs, and the client shows the part in the `approval-requested` state. + +```typescript +import { chat, toolDefinition } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(({ city }) => ({ city, temperature: 72 })) + +const bookFlight = toolDefinition({ + name: 'bookFlight', + description: 'Book a flight', + inputSchema: z.object({ to: z.string() }), + needsApproval: true, +}).server(({ to }) => ({ booked: to })) + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + tools: [getWeather, bookFlight], + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +On the client, answer the approval with `addToolApprovalResponse`. Then `sendAutomaticallyWhen` sends the answer to the server, and `chat()` runs or skips the tool. + +The server keeps the id of the paused run in the message metadata, under `tanstack.runId`. If you validate metadata with `messageMetadataSchema`, allow that key. + +```tsx +import { useChat } from '@ai-sdk/react' +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithApprovalResponses, +} from 'ai' + +export function Chat() { + const { messages, addToolApprovalResponse } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + }) + + return messages.map((m) => + m.parts.map((part) => + part.type === 'tool-bookFlight' && part.state === 'approval-requested' ? ( + + ) : null, + ), + ) +} +``` + +## 5. Show activity, state and subagents + +`toUIMessageStream` sends each of these as a data part. The client replaces a data part in place when it gets a new value for the same id. + +- **Activity:** a `data-` part with the activity id. +- **Shared state:** a `data-state` part with the full state. +- **Subagent:** a `data-subagent` part per child, with its name, status and text. Its data has the type `SubagentData`. + +Type the data parts on `useChat` to read them: + +```tsx +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import type { SubagentData } from '@tanstack/ai-ui-message-stream' +import type { UIMessage } from 'ai' + +type ChatMessage = UIMessage + +export function Subagents() { + const { messages } = useChat({ + transport: new DefaultChatTransport({ api: '/api/chat' }), + }) + + return messages.flatMap((m) => + m.parts.map((part) => + part.type === 'data-subagent' ? ( +

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

+ ) : null, + ), + ) +} +``` + +The AI SDK client does not send state back. A change to the state on the client does not reach `chat()`. + +## 6. Resume a stream after a reload + +AI SDK `useChat({ resume: true })` asks `GET /api/chat//stream` for the active run when the page loads. To answer it, record each run in a durability log, and keep a record of the active run of each chat. This example uses the runs store of [`@tanstack/ai-persistence`](../persistence/overview): + +```typescript +import { chat, memoryStream } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { withPersistence } from '@tanstack/ai-persistence' +import { + chatParamsFromAISDKRequest, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '@tanstack/ai-ui-message-stream' +import { persistence } from './persistence' + +// POST /api/chat +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + const runId = crypto.randomUUID() + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + runId, + middleware: [withPersistence(persistence)], + ...params, + }) + + return toResumableUIMessageStreamResponse(stream, memoryStream({ runId })) +} + +// GET /api/chat/:id/stream +export async function GET(request: Request) { + const chatId = new URL(request.url).pathname.split('/').at(-2) ?? '' + const run = await persistence.stores.runs.findActiveRun(chatId) + return resumeUIMessageStreamResponse(run && memoryStream({ runId: run.runId })) +} +``` + +- `toResumableUIMessageStreamResponse` writes the run to the log. The run continues when the client goes away. +- `resumeUIMessageStreamResponse` replays the run from its start. With no active run, it returns 204, and the client does nothing. +- `memoryStream` keeps the log in the memory of one process. For more than one server, use [`@tanstack/ai-durable-stream`](../resumable-streams/overview). + +## What does not go through + +- A change to shared state on the client. The AI SDK client does not send state. +- The `reason` text of an approval answer. +- A `stop()` on the client stops the reading, not the run. To stop the run, pass an `abortController` to `chat()`. + +For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility). + +## Done + +Your AI SDK `useChat` UI streams replies, tools, approvals, activity and subagents from `chat()`, and it continues a reply after a reload. diff --git a/docs/migration/ai-sdk-compatibility.md b/docs/migration/ai-sdk-compatibility.md new file mode 100644 index 0000000000..f4d689fa95 --- /dev/null +++ b/docs/migration/ai-sdk-compatibility.md @@ -0,0 +1,165 @@ +--- +title: AI SDK bridge compatibility +id: ai-sdk-compatibility +order: 5 +description: 'What crosses between TanStack AI and the Vercel AI SDK UI message stream in @tanstack/ai-ui-message-stream: what works as is, what is mapped to another shape, and what is dropped.' +keywords: + - tanstack ai + - vercel ai sdk + - ui message stream + - ag-ui + - compatibility + - migration +--- + +Use these tables to see what `@tanstack/ai-ui-message-stream` does with each feature, event and message part. The guides are [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server) and [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). + +> [!WARNING] +> The bridge is a stop gap while you port one side of an app. The rows marked ❌ **Dropped** and ➡️ **One way** are why. Finish the port with the [migration guide](./migration-from-vercel-ai), then remove the bridge. + +Each row has one of these results: + +| Result | Meaning | +| -------------- | ------------------------------------------------------------------ | +| ✅ **Same** | It arrives with the same meaning and data. | +| 🔄 **Mapped** | It arrives as a different shape that carries the same data. | +| 🧩 **Grouped** | It is joined with other items into one item, or is split from one. | +| ➡️ **One way** | It crosses in one direction only. | +| ❌ **Dropped** | It does not cross. | + +## 📋 Features + +| Feature | AI SDK client, TanStack server | TanStack client, AI SDK server | +| -------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Text | ✅ Same | ✅ Same | +| Reasoning and its signature | ✅ Same | ✅ Same | +| Tool-call provider data (Gemini `thoughtSignature`, OpenAI item ids) | 🔄 Mapped to `callProviderMetadata` and back | 🔄 Mapped to tool-call `metadata` and back | +| Server tools | ✅ Same | ✅ Same | +| Provider-executed tools (web search) | 🔄 Mapped to `providerExecuted` | 🔄 Mapped to the TanStack provider-executed flag | +| Client tools | ✅ Same | 🔄 Mapped. Their schemas go to the route in the body. Read them with `clientToolsFromAISDKBody`. | +| Tool approvals | 🔄 Mapped to `tool-approval-request` and back to `resume` | 🔄 Mapped to a `tool-approval` interrupt. The approval `signature` goes back to the route. | +| Tool errors and denials | ✅ Same | ✅ Same | +| Steps of a tool loop | 🔄 Mapped to `start-step` / `finish-step` | 🔄 Mapped to `STEP_*` events, and to `step-start` parts in the history | +| Files and images from the user | 🔄 Mapped to content parts | 🔄 Mapped to file parts | +| Web search sources | 🔄 Mapped to `source-url` chunks | 🔄 Mapped to custom events | +| Model, response ids and token usage | 🔄 Mapped to message metadata under `tanstack` | ❌ Dropped (the AI SDK stream has none) | +| Message metadata of the app | ✅ Same | 🔄 Mapped to `metadata.aiSdk` and back | +| Activity | 🔄 Mapped to a `data-` part | 🔄 Mapped from a `data-` part with an `id` | +| Subagents | 🧩 Grouped into one `data-subagent` card per child | ❌ Dropped (the AI SDK has no subagents) | +| Shared state | ➡️ One way, to a `data-state` part | ❌ Dropped (the AI SDK has no state) | +| Structured output | ✅ Same (the JSON is text) | 🔄 Mapped to a typed `structured-output` part with `outputSchema`, and back to text in the history | +| Custom events | 🔄 Mapped to a transient `data-` chunk | 🔄 Mapped from a transient `data-` chunk | +| Stream resume | ✅ Same, with a durability log | ✅ Same. A reload needs `threadId` on `fromChatTransport`. | +| System messages | ❌ Dropped. The server owns its system prompts. | ❌ Dropped. `streamText` rejects them. | +| Generated files | ❌ Dropped (`chat()` sends none) | 🔄 Mapped to custom events | + +## ▲ AI SDK client, TanStack server + +### ⬇️ Reply: `chat()` events to AI SDK chunks + +| AG-UI event from `chat()` | AI SDK chunk | Result | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `RUN_STARTED` | `start` with the message id of the first text or tool call | 🔄 Mapped | +| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `text-start` / `text-delta` / `text-end` | ✅ Same | +| `REASONING_MESSAGE_START` / `CONTENT` / `END` | `reasoning-start` / `reasoning-delta` / `reasoning-end` | ✅ Same | +| `REASONING_ENCRYPTED_VALUE`, message | `providerMetadata.tanstack.signature` on `reasoning-end` | 🔄 Mapped. Redacted thinking adds `redacted: true`. | +| `REASONING_ENCRYPTED_VALUE`, tool call | `thoughtSignature` in the tool call's `providerMetadata.tanstack` | 🔄 Mapped | +| `TOOL_CALL_START` / `ARGS` | `tool-input-start` / `tool-input-delta` | ✅ Same | +| `TOOL_CALL_START.metadata` | `providerMetadata.tanstack` on the tool chunks (`callProviderMetadata`) | 🔄 Mapped | +| `TOOL_CALL_END` | `tool-input-available` with the parsed input | 🔄 Mapped | +| Provider-executed call | `providerExecuted: true`, and a `tool-output-available` with its sources | 🔄 Mapped | +| Sources of a provider-executed call | `source-url` | 🔄 Mapped | +| `TOOL_CALL_RESULT` | `tool-output-available` | ✅ Same. JSON output is parsed. | +| `TOOL_CALL_RESULT` with an error | `tool-output-error` | 🔄 Mapped | +| `TOOL_CALL_RESULT` that the user denied | `tool-output-denied` | 🔄 Mapped | +| A new model call in the run | `start-step`, and `finish-step` at its end | 🔄 Mapped | +| `RUN_FINISHED` metadata (model, `responseId`, `responseItems`, `source`) | `message-metadata` under `tanstack` | 🔄 Mapped | +| `RUN_FINISHED.usage` | `message-metadata` as `tanstack.tokenUsage`, one entry per model call | 🔄 Mapped | +| `RUN_FINISHED` with an approval interrupt | `tool-approval-request`, and `tanstack.runId` in the message metadata | 🔄 Mapped | +| `RUN_FINISHED` with a client tool interrupt | Nothing. The AI SDK client runs a call that has no output. | 🔄 Mapped | +| `RUN_FINISHED` | `finish` with the finish reason | 🔄 Mapped | +| `RUN_ERROR` | `error` | 🔄 Mapped | +| `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` | `data-` with the activity id and the full content | 🔄 Mapped. A delta is applied, then the full content is sent. | +| `STATE_SNAPSHOT` / `STATE_DELTA` | `data-state` with id `state` and the full state | ➡️ One way | +| `SUBAGENT_*` events and child text | `data-subagent` with id `subagentRunId` | 🧩 Grouped. One card holds the name, status, text and error. | +| Child tool calls, reasoning and activity | | ❌ Dropped. The card shows the child text only. | +| Approval inside a subagent | `interruptIds` on the subagent card | 🧩 Grouped | +| `CUSTOM` | Transient `data-` | 🔄 Mapped. It reaches `onData`, not the message. | +| `MESSAGES_SNAPSHOT`, `RAW` | | ❌ Dropped | +| `STEP_*` and `*_CHUNK` shorthand events | | ❌ Dropped. `chat()` does not send them. | + +### ⬆️ Request: AI SDK message parts to `chat()` messages + +| AI SDK part | TanStack part | Result | +| ----------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- | +| `text` | `text` | ✅ Same | +| `reasoning` | `thinking` with the signature | ✅ Same | +| `file` | `image`, `audio`, `video` or `document`, by media type | 🔄 Mapped. A data URL becomes a data source. | +| `tool-` / `dynamic-tool` with output | `tool-call` plus `tool-result` | 🧩 Grouped. The result follows its call. | +| `callProviderMetadata.tanstack` | `tool-call` `metadata` | 🔄 Mapped | +| Tool part with `providerExecuted` | `tool-call` with no `tool-result` | 🔄 Mapped. `chat()` does not run it again. | +| Tool part with `output-error` / `output-denied` | `tool-call` plus an error `tool-result` | 🧩 Grouped | +| Tool part with `approval-responded` | Pending `tool-call`, plus a `resume` item `{ approved }` | 🔄 Mapped | +| The `reason` of an approval answer | | ❌ Dropped. `resume` has no field for it. | +| Message `metadata` | `metadata` | ✅ Same. With more than one step, `tanstack.responseItems` is removed. | +| `system` message | | ❌ Dropped. Pass `systemPrompts` to `chat()` on the server. | +| `source-url`, `source-document`, `data-*`, `step-start`, `custom`, `reasoning-file` | | ❌ Dropped | + +## 🌴 TanStack client, AI SDK server + +### ⬇️ Reply: AI SDK chunks to TanStack events + +| AI SDK chunk | AG-UI event | Result | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `start` | `RUN_STARTED` | 🔄 Mapped. The message id is the AG-UI message id. | +| `messageMetadata` on `start`, `message-metadata`, `finish` | `metadata.aiSdk` on the message, at the end of the run | 🔄 Mapped | +| `start-step` / `finish-step` | `STEP_STARTED` / `STEP_FINISHED` | ✅ Same | +| `reset-step` | | ❌ Dropped | +| `text-start` / `text-delta` / `text-end` | `TEXT_MESSAGE_START` / `CONTENT` / `END` | 🧩 Grouped. All text parts go into one assistant message. | +| Text of an `Output.object` answer, with `outputSchema` | `structured-output.start`, the text, then `structured-output.complete` with the checked object | 🔄 Mapped. A schema failure ends the run with `RUN_ERROR`. | +| `providerMetadata` on text chunks | | ❌ Dropped. TanStack keeps no metadata per text part. | +| `text-end.citations` | `CUSTOM` named `citations` | 🔄 Mapped | +| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `REASONING_MESSAGE_*` | ✅ Same. Each reasoning part gets its own id. | +| `providerMetadata` on `reasoning-end` | `REASONING_ENCRYPTED_VALUE` | 🔄 Mapped. It goes back to the route unchanged. | +| `tool-input-start` / `tool-input-delta` | `TOOL_CALL_START` / `TOOL_CALL_ARGS` | ✅ Same | +| `providerMetadata` on tool input chunks | tool-call `metadata.aiSdk.callProviderMetadata` | 🔄 Mapped | +| `providerExecuted` | tool-call `metadata.providerExecuted` | 🔄 Mapped. The client does not run the call. | +| `inputEncoding: 'text'` | One `TOOL_CALL_ARGS` with the final input | 🔄 Mapped | +| `tool-input-available` | `TOOL_CALL_END`, after any approval or output for the same call | ✅ Same | +| `tool-input-error` | `TOOL_CALL_END` plus an error `TOOL_CALL_RESULT` | 🔄 Mapped | +| `tool-output-available` | `TOOL_CALL_RESULT` | ✅ Same | +| `providerMetadata` on tool output chunks | tool-call `metadata.aiSdk.resultProviderMetadata` | 🔄 Mapped when it comes right after the call. Later, it is dropped. | +| `tool-output-available` with `preliminary: true` | | ❌ Dropped. Only the final output is sent. | +| `tool-output-error` / `tool-output-denied` | Error `TOOL_CALL_RESULT` | 🔄 Mapped | +| `tool-approval-request` | Approval interrupt on `RUN_FINISHED` | 🔄 Mapped. Without `tools`, it is a generic interrupt: resolve it with `{ approved }`. | +| `signature`, `approvalDescriptor`, `inputSchemaInput`, `reason` of an approval request | tool-call `metadata.aiSdk.approval` | 🔄 Mapped | +| `tool-approval-request` with `isAutomatic` | | ❌ Dropped. The route already decided, and the output or denial follows. | +| `tool-approval-response` | | ❌ Dropped | +| `finish` | `RUN_FINISHED` with the finish reason | 🔄 Mapped | +| `finish` with `tool-calls` | `RUN_FINISHED` with no finish reason | 🔄 Mapped. In TanStack, `tool_calls` means more steps follow in the same stream. | +| `error` | `RUN_ERROR` | 🔄 Mapped | +| `abort` | `RUN_FINISHED` with a `cancelled` outcome | 🔄 Mapped | +| `data-` with an `id` and object data | `ACTIVITY_SNAPSHOT` of type `` | 🔄 Mapped. The same id replaces the activity. | +| Other `data-` (transient, no `id`, or not an object) | `CUSTOM` named `data-` | 🔄 Mapped | +| `source-url`, `source-document`, `file`, `reasoning-file` | `CUSTOM` with the same name | 🔄 Mapped | +| `custom` | | ❌ Dropped | + +### ⬆️ Request: TanStack messages to AI SDK message parts + +| TanStack part | AI SDK part | Result | +| ------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `text` | `text` | ✅ Same | +| `thinking` | `reasoning` with the stored provider metadata | ✅ Same | +| `image`, `audio`, `video`, `document` | `file` | 🔄 Mapped. A data source becomes a data URL. | +| `tool-call` plus its `tool-result` | One `dynamic-tool` part | 🧩 Grouped | +| tool-call `metadata.aiSdk` | `callProviderMetadata`, `resultProviderMetadata` | 🔄 Mapped | +| Provider-executed `tool-call` | `dynamic-tool` with `providerExecuted: true` | 🔄 Mapped | +| `tool-call` with an approval answer | `dynamic-tool` with `approval-responded`, and the stored `signature` | 🔄 Mapped | +| `tool-call` that waits for approval | `dynamic-tool` with `approval-requested` | 🔄 Mapped | +| Client tool declarations of the run | `clientTools` in the request body | 🔄 Mapped | +| A tool result followed by more of the reply | `step-start` before the next step | 🔄 Mapped | +| `structured-output`, complete | `text` with the JSON | 🔄 Mapped | +| Message `metadata.aiSdk` | Message `metadata` | 🔄 Mapped | +| `system` messages | | ❌ Dropped. `streamText` rejects them unless `allowSystemInMessages` is set. | +| `activity` messages | | ❌ Dropped | +| `subagent`, `ui-resource` | | ❌ Dropped | diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index 5bb016b038..92e105c9cc 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -25,6 +25,12 @@ Move a Vercel AI SDK app (`ai` and `@ai-sdk/*`) to TanStack AI by swapping the p Do the steps below in order. The "Before" samples are AI SDK v5 and v6 code. Where v4 is different, the page says so. For every other API, see [Vercel AI SDK: full API mapping](./migration-from-vercel-ai-advanced). +To move one side at a time, connect the two SDKs over the AI SDK UI message stream. This bridge is a stop gap while you port. Remove it when both sides use TanStack AI: + +- Server first: [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). +- Client first: [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server). +- What crosses and what does not: [AI SDK bridge compatibility](./ai-sdk-compatibility). + ## Port a chat app ### 1. Swap the packages diff --git a/docs/migration/tanstack-client-ai-sdk-server.md b/docs/migration/tanstack-client-ai-sdk-server.md new file mode 100644 index 0000000000..edd0cae07a --- /dev/null +++ b/docs/migration/tanstack-client-ai-sdk-server.md @@ -0,0 +1,295 @@ +--- +title: TanStack useChat with an AI SDK server +id: tanstack-client-ai-sdk-server +order: 4 +description: "Point TanStack AI useChat at a Vercel AI SDK route. Wrap an AI SDK ChatTransport with fromChatTransport and pass it as the connection. Your AI SDK server does not change." +keywords: + - tanstack ai + - vercel ai sdk + - useChat + - streamText + - ui message stream + - fromChatTransport + - tool approval + - resumable streams + - migration +--- + +Point TanStack AI `useChat` at a Vercel AI SDK route. Wrap an AI SDK `ChatTransport` with `fromChatTransport`, and pass it as the `connection`. Your `streamText` route does not change. + +Use this page to move the client first. To move the server first, see [AI SDK useChat with a TanStack AI server](./ai-sdk-client-tanstack-server). + +Against your AI SDK route, TanStack `useChat` gives you: + +- Client persistence and resume after a reload, with no server code. +- A [message queue](../chat/queueing) while a reply streams. +- Client tools that the route reads from the request, with no copy on the route. +- Typed structured output, with partial objects while it streams. +- Typed approvals, TanStack devtools, and hooks for React, Solid, Vue, Svelte, Preact, Angular and more. + +> [!WARNING] +> This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the client works, port the route to `chat()` with the [migration guide](./migration-from-vercel-ai), then remove the bridge. + +## 1. Install the packages + + + +react: @tanstack/ai-react @tanstack/ai-ui-message-stream ai +vue: @tanstack/ai-vue @tanstack/ai-ui-message-stream ai +solid: @tanstack/ai-solid @tanstack/ai-ui-message-stream ai +svelte: @tanstack/ai-svelte @tanstack/ai-ui-message-stream ai +preact: @tanstack/ai-preact @tanstack/ai-ui-message-stream ai +angular: @tanstack/ai-angular @tanstack/ai-ui-message-stream ai +octane: @tanstack/ai-octane @tanstack/ai-ui-message-stream ai octane +vanilla: @tanstack/ai-client @tanstack/ai-ui-message-stream ai + + + +## 2. Connect useChat to the route + +```tsx +import { useState } from 'react' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function Chat() { + const [input, setInput] = useState('') + const { messages, sendMessage } = useChat({ + connection, + threadId: 'support-chat', + }) + + return ( +
+ {messages.map((message) => ( +
+ {message.parts.map((part, idx) => + part.type === 'text' ? {part.content} : null, + )} +
+ ))} + setInput(e.target.value)} /> + +
+ ) +} +``` + +`DefaultChatTransport` sends the request and reads the stream, as it does for the AI SDK hook. Its options work the same, for example `headers`, `credentials` and `prepareSendMessagesRequest`. The `threadId` goes to the route as the chat `id`. + +## 3. Keep the server + +Your AI SDK route works as it is: + +```typescript +import { openai } from '@ai-sdk/openai' +import { convertToModelMessages, streamText } from 'ai' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const result = streamText({ + model: openai('gpt-6.1-sol'), + messages: await convertToModelMessages(messages), + }) + + return result.toUIMessageStreamResponse() +} +``` + +## 4. Run client tools and approve tool calls + +Define client tools on the client only. The connection sends their names, descriptions and JSON Schemas to the route in the request body. Read them on the route with `clientToolsFromAISDKBody`, together with the server tools: + +```typescript +import { openai } from '@ai-sdk/openai' +import { clientToolsFromAISDKBody } from '@tanstack/ai-ui-message-stream' +import { convertToModelMessages, streamText, tool } from 'ai' +import { z } from 'zod' + +const getWeather = tool({ + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ city, temperature: 72 }), +}) + +export async function POST(request: Request) { + const body = await request.json() + + const result = streamText({ + model: openai('gpt-6.1-sol'), + messages: await convertToModelMessages(body.messages), + tools: clientToolsFromAISDKBody(body, { getWeather }), + }) + + return result.toUIMessageStreamResponse() +} +``` + +- A server tool wins when a client tool has the same name. +- A client tool has no `execute` on the route, so `streamText` stops at the call. +- Browser tools from [WebMCP](../tools/webmcp) reach the route the same way. + +Pass the tools to `useChat`, and to `fromChatTransport` for approvals: + +- **Client tool:** give it a `.client()` implementation. When the route stops at the call, the client runs the tool and sends the output back. +- **Tool approval:** a tool that needs approval on the route shows up in `interrupts`. Resolve it with `interrupt.resolveInterrupt(true)` or `false`. The answer goes back to the route, and `streamText` runs or skips the tool. + +```tsx +import { toolDefinition } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +const getLocation = toolDefinition({ + name: 'getLocation', + description: 'Get the location of the user', + inputSchema: z.object({}), +}).client(() => ({ city: 'Sydney' })) + +const bookFlight = toolDefinition({ + name: 'bookFlight', + description: 'Book a flight', + inputSchema: z.object({ to: z.string() }), + needsApproval: true, +}) + +const tools = [getLocation, bookFlight] as const + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), + { tools }, +) + +export function Chat() { + const { interrupts } = useChat({ + connection, + threadId: 'support-chat', + tools, + }) + + return interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + ) +} +``` + +## 5. Get typed structured output + +When the route streams an object with `Output.object`, pass the same schema to `useChat` and to `fromChatTransport`. The answer is a `structured-output` part, with partial objects while it streams and the checked `data` at the end: + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +const recipe = z.object({ name: z.string(), steps: z.array(z.string()) }) + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/recipe' }), + { outputSchema: recipe }, +) + +export function Recipe() { + const { messages } = useChat({ + connection, + threadId: 'recipe', + outputSchema: recipe, + }) + + return messages.flatMap((message) => + message.parts.map((part, i) => + part.type === 'structured-output' ? ( +

{part.partial?.name}

+ ) : null, + ), + ) +} +``` + +If the text is not valid JSON for the schema, the run ends with an error. All text of the reply counts as the answer, so a route that also writes text in a step that calls tools fails the check. + +## 6. Load a saved chat + +If your app saves AI SDK messages, convert them with `fromAISDKMessages` and pass them as `initialMessages`: + +```tsx +import { useChat } from '@tanstack/ai-react' +import { + fromAISDKMessages, + fromChatTransport, +} from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import type { UIMessage } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function SavedChat({ saved }: { saved: Array }) { + return useChat({ + connection, + threadId: 'support-chat', + initialMessages: fromAISDKMessages(saved), + }) +} +``` + +## 7. Show data parts as activity + +A data part with an `id` (`writer.write({ type: 'data-weather', id, data })` on the route) arrives as an activity message of type `weather`. A new value for the same id replaces it. A transient data part, or one with no `id`, arrives as a custom event named `data-`. + +## 8. Resume a stream + +A dropped connection needs nothing. The connection calls `reconnectToStream` on the transport, and the reply continues. It waits longer between tries that get no new data, as the TanStack HTTP connections do. To change the waits and the limit, pass `reconnect` to `fromChatTransport`, for example `{ maxAttempts: 10 }`. + +To continue a reply after a page reload, add client persistence, and pass the `threadId` to `fromChatTransport`. The route must support the AI SDK resume `GET` (`/api/chat//stream`): + +```tsx +import { localStoragePersistence, useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const threadId = 'support-chat' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), + { threadId }, +) + +export function useSupportChat() { + return useChat({ + connection, + threadId, + persistence: localStoragePersistence(), + }) +} +``` + +Use one connection for each thread. The rejoin after a reload needs a transport that replays the reply from its start, as `DefaultChatTransport` does. + +## What does not go through + +- AG-UI shared state. The AI SDK has no state. +- Provider metadata on text parts, for example OpenAI item ids on text. TanStack keeps no metadata per text part. +- Sources, citations and generated files arrive as custom events, not as message parts. + +For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility). + +## Done + +TanStack `useChat` streams replies, runs tools, asks for approvals, and continues after a reload against your AI SDK route. diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index c4561ca23e..aa41eb719c 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -175,6 +175,8 @@ export { StreamTruncatedError, DurableStreamIncompleteError, StreamReconnectLimitError, + createReconnectTracker, + type ReconnectTracker, type ConnectConnectionAdapter, type ChatHydrateOptions, type ChatHydrationResult, diff --git a/packages/ai-ui-message-stream/README.md b/packages/ai-ui-message-stream/README.md new file mode 100644 index 0000000000..e496c4d4b6 --- /dev/null +++ b/packages/ai-ui-message-stream/README.md @@ -0,0 +1,97 @@ +
+ + + + TanStack AI + +
+ +
+ +# @tanstack/ai-ui-message-stream + +Connect TanStack AI to the Vercel AI SDK UI message stream, the wire format of the AI SDK `useChat` hook. + +- Serve AI SDK `useChat` from TanStack AI `chat()`. +- Call an AI SDK `streamText` route from TanStack AI `useChat`. + +This package is a stop gap while you port an app from the AI SDK one side at a time. Some features change shape or are dropped on the way. See the [compatibility table](https://tanstack.com/ai/latest/docs/migration/ai-sdk-compatibility). When both sides use TanStack AI, remove it. + +## Installation + +```bash +pnpm add @tanstack/ai-ui-message-stream @tanstack/ai ai +``` + +## AI SDK client, TanStack AI server + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' + +export async function POST(request: Request) { + const params = await chatParamsFromAISDKRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-6.1-sol'), + ...params, + }) + + return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }) +} +``` + +## TanStack AI client, AI SDK server + +```tsx +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' + +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/chat' }), +) + +export function Chat() { + const { messages, sendMessage } = useChat({ + connection, + threadId: 'support-chat', + }) + // render messages +} +``` + +## API + +| Export | Direction | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `chatParamsFromAISDKRequest(request)` | AI SDK `useChat` request to `chat()` params, approval answers included | +| `toUIMessageStream(stream)` | `chat()` events to an AI SDK UI message stream | +| `toResumableUIMessageStreamResponse(stream, durability)` | A `chat()` run that AI SDK `useChat({ resume: true })` can rejoin | +| `resumeUIMessageStreamResponse(durability)` | The AI SDK resume `GET` | +| `fromChatTransport(transport, { threadId, tools, outputSchema, reconnect })` | AI SDK `ChatTransport` to a TanStack `useChat` connection | +| `clientToolsFromAISDKBody(body, serverTools)` | On an AI SDK route: the client tools of the request, plus the server tools | +| `fromAISDKMessages` / `toAISDKMessages` | Message history in each direction | +| `fromUIMessageStream(stream, ids)` | AI SDK UI message stream to TanStack events | + +Text, reasoning, tools, tool approvals, activity, subagents and stream resume cross in both directions where the other side has them. Shared state goes from a TanStack server to an AI SDK client only, because the AI SDK client does not send state. + +## Docs + +- [AI SDK useChat with a TanStack AI server](https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server) +- [TanStack useChat with an AI SDK server](https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server) diff --git a/packages/ai-ui-message-stream/package.json b/packages/ai-ui-message-stream/package.json new file mode 100644 index 0000000000..c25632ba95 --- /dev/null +++ b/packages/ai-ui-message-stream/package.json @@ -0,0 +1,60 @@ +{ + "name": "@tanstack/ai-ui-message-stream", + "version": "0.0.1", + "description": "A stop gap for porting from the Vercel AI SDK one side at a time: serve AI SDK useChat from chat(), or call an AI SDK route from TanStack useChat, over the AI SDK UI message stream.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-ui-message-stream" + }, + "keywords": [ + "ai", + "tanstack", + "ai-sdk", + "ag-ui", + "ui-message-stream", + "usechat" + ], + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:coverage": "vitest run --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "fast-json-patch": "^3.1.1" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "@tanstack/ai-client": "workspace:^", + "ai": "^7.0.0" + }, + "devDependencies": { + "@ag-ui/core": "1.0.0", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@vitest/coverage-v8": "4.1.10", + "ai": "7.0.148", + "zod": "^4.2.0" + } +} diff --git a/packages/ai-ui-message-stream/src/client-tools.ts b/packages/ai-ui-message-stream/src/client-tools.ts new file mode 100644 index 0000000000..bcddace4d3 --- /dev/null +++ b/packages/ai-ui-message-stream/src/client-tools.ts @@ -0,0 +1,60 @@ +import { jsonSchema, tool } from 'ai' +import type { ToolSet } from 'ai' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Add the tools that a TanStack `useChat` client declares (`clientTools` in + * the request body, sent by `fromChatTransport`) to the tools of an AI SDK + * route. A client tool has no `execute`, so `streamText` stops at its call + * and the client runs it. A server tool wins on a name clash. + * + * ```ts + * const body = await request.json() + * const result = streamText({ + * model, + * messages: await convertToModelMessages(body.messages), + * tools: clientToolsFromAISDKBody(body, { weather }), + * }) + * ``` + * + * @throws An error when `clientTools` is present but is not a list of + * `{ name, description, parameters }` with a JSON Schema object. + */ +export function clientToolsFromAISDKBody( + body: unknown, + serverTools: TServerTools, +): TServerTools & ToolSet { + const declared = isRecord(body) ? body.clientTools : undefined + if (declared === undefined) return serverTools + if (!Array.isArray(declared)) { + throw new Error('`clientTools` must be an array.') + } + // `fromEntries` defines own properties, so a `__proto__` name is inert. + const clientTools: ToolSet = Object.fromEntries( + declared.map((entry: unknown) => { + if ( + !isRecord(entry) || + typeof entry.name !== 'string' || + entry.name === '' || + typeof entry.description !== 'string' || + !isRecord(entry.parameters) + ) { + throw new Error( + 'Each client tool must have a `name`, a `description` and JSON Schema `parameters`.', + ) + } + // No `execute`: `streamText` stops at the call and the client runs it. + return [ + entry.name, + tool({ + description: entry.description, + inputSchema: jsonSchema(entry.parameters), + }), + ] + }), + ) + return { ...clientTools, ...serverTools } +} diff --git a/packages/ai-ui-message-stream/src/data-parts.ts b/packages/ai-ui-message-stream/src/data-parts.ts new file mode 100644 index 0000000000..eecc8c2b80 --- /dev/null +++ b/packages/ai-ui-message-stream/src/data-parts.ts @@ -0,0 +1,198 @@ +import jsonPatch from 'fast-json-patch' +import { EventType } from '@tanstack/ai' +import type { StreamChunk, SubagentHandleData } from '@tanstack/ai' +import type { UIMessageChunk } from 'ai' + +type ActivitySnapshot = Extract +type JsonPatch = Extract['delta'] + +/** + * The data of a `data-subagent` part: the TanStack subagent card, with the + * child's text in place of its messages. + */ +export interface SubagentData extends Omit< + SubagentHandleData, + 'messages' | 'parentRunId' +> { + text: string +} + +/** An AI SDK `data-` chunk. */ +export type DataChunk = Extract + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Turns AG-UI activity, state and subagent events into AI SDK data chunks. + * Each chunk has an `id`, so the AI SDK client replaces the data part in place + * when the same `data-` and `id` come again. Make one per stream. + * + * - `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA`: `data-`, `id` is the + * activity `messageId`, data is the full content. + * - `STATE_SNAPSHOT` / `STATE_DELTA`: `data-state`, `id: 'state'`, data is the + * full state. + * - Events with a `subagentRunId`: `data-subagent`, `id` is the + * `subagentRunId`, data is a {@link SubagentData}. + */ +export class DataParts { + private readonly activities = new Map() + private state: unknown = {} + private readonly subagents = new Map() + + /** The data chunks for one event. Empty for any other event. */ + chunks(event: StreamChunk): Array { + if ('subagentRunId' in event && event.subagentRunId !== undefined) + return this.subagent(event, event.subagentRunId) + + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- only activity and state events have data parts. + switch (event.type) { + case EventType.ACTIVITY_SNAPSHOT: { + const existing = this.activities.get(event.messageId) + if (existing && event.replace === false) return [] + this.activities.set(event.messageId, event) + return [activityChunk(event)] + } + case EventType.ACTIVITY_DELTA: { + // Same rules as the TanStack client: no snapshot, another type, a + // failed patch, or content that is no longer an object keep the old + // content. + const existing = this.activities.get(event.messageId) + if (existing?.activityType !== event.activityType) { + console.warn( + `ACTIVITY_DELTA: No '${event.activityType}' activity '${event.messageId}'`, + ) + return [] + } + const content = patched(existing.content, event.patch) + if (!isRecord(content)) return [] + const next = { ...existing, content } + this.activities.set(event.messageId, next) + return [activityChunk(next)] + } + case EventType.STATE_SNAPSHOT: + this.state = event.snapshot + return [{ type: 'data-state', id: 'state', data: this.state }] + case EventType.STATE_DELTA: { + const state = patched(this.state, event.delta) + if (state === undefined) return [] + this.state = state + return [{ type: 'data-state', id: 'state', data: state }] + } + default: + return [] + } + } + + // ponytail: re-sends the whole card on every child text delta, so the wire + // carries O(n²) text for a long child. Throttle or send a text-only delta + // part if children get long. + private subagent(event: StreamChunk, id: string): Array { + let card = this.subagents.get(id) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- the card shows lifecycle and text only. + switch (event.type) { + case EventType.SUBAGENT_STARTED: + if (card) { + // A resume continues the same child. + card.status = 'running' + delete card.interruptIds + delete card.error + if (event.metadata !== undefined) card.metadata = event.metadata + break + } + card = { + id, + name: event.name, + ...(event.description !== undefined && { + description: event.description, + }), + status: 'running', + ...(event.parentSubagentRunId !== undefined && { + parentSubagentRunId: event.parentSubagentRunId, + }), + ...(event.parentToolCallId !== undefined && { + parentToolCallId: event.parentToolCallId, + }), + ...(event.metadata !== undefined && { metadata: event.metadata }), + text: '', + } + this.subagents.set(id, card) + break + case EventType.TEXT_MESSAGE_START: + // Keep the child's text messages apart. Nothing to show yet. + if (card && card.text !== '') card.text += '\n\n' + return [] + case EventType.TEXT_MESSAGE_CONTENT: + if (!card) return [] + card.text += event.delta + break + case EventType.SUBAGENT_FINISHED: + if (!card || card.status === 'error') return [] + if (event.outcome?.type === 'suspended') { + card.status = 'suspended' + card.interruptIds = event.outcome.interruptIds ?? [] + } else { + card.status = 'finished' + } + break + case EventType.SUBAGENT_ERROR: + if (!card) return [] + card.status = 'error' + card.error = { + message: event.message, + ...(event.code !== undefined && { code: event.code }), + } + break + default: + return [] + } + return [{ type: 'data-subagent', id, data: structuredClone(card) }] + } +} + +function activityChunk(event: ActivitySnapshot): UIMessageChunk { + return { + type: `data-${event.activityType}`, + id: event.messageId, + data: event.content, + } +} + +export function isDataChunk(chunk: UIMessageChunk): chunk is DataChunk { + return chunk.type.startsWith('data-') +} + +/** + * The AG-UI event for an AI SDK data chunk. A persistent chunk with an `id` + * and object data is an activity, so the TanStack client shows it as an + * activity message and replaces it when the same `id` comes again. AG-UI + * activity content must be an object, so other data, transient chunks and + * chunks without an `id` stay CUSTOM events named `data-`. + */ +export function dataChunkEvent(chunk: DataChunk): StreamChunk { + if ( + chunk.transient !== true && + chunk.id !== undefined && + isRecord(chunk.data) + ) + return { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: chunk.id, + activityType: chunk.type.slice('data-'.length), + content: chunk.data, + replace: true, + } + return { type: EventType.CUSTOM, name: chunk.type, value: chunk.data } +} + +/** `doc` with an RFC 6902 patch applied, or undefined when the patch fails. */ +function patched(doc: unknown, patch: JsonPatch): unknown { + try { + return jsonPatch.applyPatch(structuredClone(doc), patch, true, false) + .newDocument + } catch (error) { + console.warn('JSON Patch failed', error) + return undefined + } +} diff --git a/packages/ai-ui-message-stream/src/index.ts b/packages/ai-ui-message-stream/src/index.ts new file mode 100644 index 0000000000..15c5e1ca8e --- /dev/null +++ b/packages/ai-ui-message-stream/src/index.ts @@ -0,0 +1,188 @@ +import { modelMessagesToUIMessages } from '@tanstack/ai' +import type { ModelMessage, UIMessage } from '@tanstack/ai' +import { createReconnectTracker } from '@tanstack/ai-client' +import type { + ConnectConnectionAdapter, + ReconnectOptions, +} from '@tanstack/ai-client' +import type { + ChatTransport, + UIMessage as AISDKUIMessage, + UIMessageChunk, +} from 'ai' +import { toAISDKMessages } from './messages' +import { fromUIMessageStream } from './stream' +import type { FromUIMessageStreamOptions } from './stream' + +export { fromAISDKMessages, toAISDKMessages } from './messages' +export { fromUIMessageStream, toUIMessageStream } from './stream' +export type { FromUIMessageStreamOptions } from './stream' +export { + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from './resume' +export { + chatParamsFromAISDKRequest, + chatParamsFromAISDKRequestBody, +} from './request' +export type { AISDKChatParams } from './request' +export { clientToolsFromAISDKBody } from './client-tools' +export type { SubagentData } from './data-parts' + +function asUIMessages( + messages: Array | Array, +): Array { + const list: ReadonlyArray = messages + const ui = list.filter((m): m is UIMessage => 'parts' in m) + if (ui.length === list.length) return ui + return modelMessagesToUIMessages( + list.filter((m): m is ModelMessage => !('parts' in m)), + ) +} + +type ReconnectRequest = Parameters< + ChatTransport['reconnectToStream'] +>[0] + +/** + * Read `stream`, and when the connection drops, read the rest through + * `transport.reconnectToStream`, with the backoff and limits of the TanStack + * HTTP connections. A replayed prefix is skipped. + */ +function reconnecting( + transport: ChatTransport, + stream: ReadableStream, + request: ReconnectRequest, + reconnect: ReconnectOptions | undefined, +): ReadableStream { + const tracker = createReconnectTracker(reconnect) + let reader = stream.getReader() + let delivered = 0 + let skip = 0 + let deliveredAtLastDrop = 0 + return new ReadableStream({ + async pull(controller) { + for (;;) { + try { + const { done, value } = await reader.read() + if (done) return controller.close() + // ponytail: skips by count, so the replay must repeat the chunks in + // the same order, as the AI SDK resumable streams do. + if (skip > 0) { + skip-- + continue + } + delivered++ + return controller.enqueue(value) + } catch (error) { + if (request.abortSignal?.aborted) throw error + // Throws StreamReconnectLimitError when the run is stuck. + await tracker.waitBeforeReconnect( + delivered > deliveredAtLastDrop, + request.abortSignal, + ) + deliveredAtLastDrop = delivered + let resumed: ReadableStream | null + try { + resumed = await transport.reconnectToStream(request) + } catch { + // The old reader fails again, so the next pass waits and retries. + continue + } + if (!resumed) throw error + reader = resumed.getReader() + skip = transport.resumeStreamIsReplay === true ? delivered : 0 + } + } + }, + cancel: (reason) => reader.cancel(reason), + }) +} + +/** + * Connect the TanStack chat client to an AI SDK route through an AI SDK + * `ChatTransport`, such as `new DefaultChatTransport({ api: '/api/chat' })`. + * + * The transport sends the history as AI SDK `UIMessage`s and reads the UI + * message stream. The chat `threadId` is the AI SDK `chatId`. Data passed to + * `sendMessage` goes in the request body, and per-run headers go on the + * request. + * + * When the stream drops, the adapter reconnects with + * `transport.reconnectToStream` and continues the run, with the same backoff + * as the TanStack HTTP connections (`reconnect` sets it). Set `threadId` to also + * rejoin a run after a page reload (`joinRun`), with a chat `persistence` + * adapter on the client. For that, the transport must replay the stream from + * its start (`resumeStreamIsReplay`, true for `DefaultChatTransport`). + * + * Pass the same `tools` as `useChat({ tools })`, so a tool approval is a + * `tool-approval` interrupt that `resolveInterrupt(true)` answers. + * + * Pass the same `outputSchema` as `useChat({ outputSchema })` when the route + * uses `Output.object({ schema })`, so the answer is a typed + * `structured-output` part. + * + * The client's tools (JSON Schema) go in the body as `clientTools`. Read them + * on the route with `clientToolsFromAISDKBody`, so the route needs no copy. + */ +export function fromChatTransport( + transport: ChatTransport, + { + threadId, + tools, + outputSchema, + reconnect, + }: Pick & { + threadId?: string + /** Backoff and limits for reconnects after a dropped stream. */ + reconnect?: ReconnectOptions + } = {}, +): ConnectConnectionAdapter { + return { + async *connect(messages, data, abortSignal, runContext) { + const { clientTools } = runContext + const body = clientTools?.length ? { ...data, clientTools } : data + const stream = await transport.sendMessages({ + trigger: 'submit-message', + chatId: runContext.threadId, + messageId: undefined, + messages: toAISDKMessages(asUIMessages(messages)), + abortSignal, + ...(runContext.headers && { headers: runContext.headers }), + ...(body && { body }), + }) + yield* fromUIMessageStream( + reconnecting( + transport, + stream, + { + chatId: runContext.threadId, + abortSignal, + ...(runContext.headers && { headers: runContext.headers }), + }, + reconnect, + ), + { ...runContext, tools, outputSchema }, + ) + }, + // A rejoin gets only the run id, and the AI SDK reconnects by chat id. + ...(threadId !== undefined && + transport.resumeStreamIsReplay === true && { + async *joinRun(runId: string, abortSignal?: AbortSignal) { + const options = { chatId: threadId, abortSignal } + const stream = await transport.reconnectToStream(options) + // Thrown before the first event, so the client drops its resume pointer. + if (!stream) throw new Error(`No active stream for chat ${threadId}`) + yield* fromUIMessageStream( + reconnecting(transport, stream, options, reconnect), + { + threadId, + runId, + tools, + outputSchema, + }, + ) + }, + }), + } +} diff --git a/packages/ai-ui-message-stream/src/messages.ts b/packages/ai-ui-message-stream/src/messages.ts new file mode 100644 index 0000000000..8bd651f94f --- /dev/null +++ b/packages/ai-ui-message-stream/src/messages.ts @@ -0,0 +1,417 @@ +import { isProviderExecutedToolCall } from '@tanstack/ai' +import { getToolName, isToolUIPart } from 'ai' +import type { + ContentPart, + ContentPartSource, + MessagePart, + ProviderExecutedToolMetadata, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai' +import type { + DynamicToolUIPart, + FileUIPart, + ProviderMetadata, + UIMessage as AISDKUIMessage, + ToolUIPart, + UIMessagePart, + UIDataTypes, + UITools, +} from 'ai' + +type AISDKPart = UIMessagePart + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isProviderMetadata(value: unknown): value is ProviderMetadata { + return isRecord(value) && Object.values(value).every(isRecord) +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +const DATA_URL = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s + +function fileToContentPart(part: FileUIPart): ContentPart { + const match = DATA_URL.exec(part.url) + const source: ContentPartSource = match + ? { + type: 'data', + value: match[2] ?? '', + mimeType: match[1] ?? part.mediaType, + } + : { type: 'url', value: part.url, mimeType: part.mediaType } + const kind = part.mediaType.split('/')[0] + if (kind === 'image' || kind === 'audio' || kind === 'video') { + return { type: kind, source } + } + return { type: 'document', source } +} + +function toolParts( + part: ToolUIPart | DynamicToolUIPart, +): Array { + const call: ToolCallPart = { + type: 'tool-call', + id: part.toolCallId, + name: getToolName(part), + arguments: JSON.stringify(part.input ?? {}), + input: part.input, + state: + part.state === 'input-streaming' + ? 'input-streaming' + : part.state === 'output-available' + ? 'complete' + : part.state === 'output-error' || part.state === 'output-denied' + ? 'error' + : 'input-complete', + // `toUIMessageStream` sends the TanStack tool-call metadata (Gemini + // `thoughtSignature`, `providerExecuted`) as `providerMetadata.tanstack`. + ...(part.callProviderMetadata?.tanstack !== undefined && { + metadata: part.callProviderMetadata.tanstack, + }), + } + // The provider ran the call and replays its result from the metadata, so + // it has no tool result. + if (isProviderExecutedToolCall(call)) return [call] + // A result goes right after its call, so a text after the tool replays + // after the result. + if (part.state === 'output-available') { + const result: ToolResultPart = { + type: 'tool-result', + toolCallId: part.toolCallId, + content: + typeof part.output === 'string' + ? part.output + : JSON.stringify(part.output ?? null), + state: 'complete', + } + return [call, result] + } + if (part.state === 'output-error' || part.state === 'output-denied') { + const error = + part.state === 'output-error' + ? part.errorText + : (part.approval.reason ?? 'The user denied this tool call') + const result: ToolResultPart = { + type: 'tool-result', + toolCallId: part.toolCallId, + content: error, + state: 'error', + error, + ...(part.state === 'output-denied' && { outcome: 'denied' }), + } + return [call, result] + } + return [call] +} + +function fromAISDKPart(part: AISDKPart): Array { + if (isToolUIPart(part)) return toolParts(part) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- sources, data, step and custom parts are UI only; default drops them. + switch (part.type) { + case 'text': + return [{ type: 'text', content: part.text }] + case 'reasoning': { + const tanstack = part.providerMetadata?.tanstack + const signature = isRecord(tanstack) ? tanstack.signature : undefined + return [ + { + type: 'thinking', + content: part.text, + ...(typeof signature === 'string' && { signature }), + ...(isRecord(tanstack) && + tanstack.redacted === true && { redacted: true }), + }, + ] + } + case 'file': + return [fileToContentPart(part)] + default: + // Sources, data parts, step markers and custom parts are UI only. + return [] + } +} + +/** + * Convert AI SDK `UIMessage`s (the `messages` body that AI SDK `useChat` + * posts) into TanStack `UIMessage`s for `chat({ messages })`. + * + * Text, files, reasoning, tool calls with their results and metadata, and the + * message metadata are kept. A client tool's output and a denied tool call + * go back to the model as tool results. A provider-executed call has none. + * A call that waits for approval, or has an answer, stays pending: + * `chatParamsFromAISDKRequest` sends the answer to `chat()` as `resume`. + */ +export function fromAISDKMessages( + messages: ReadonlyArray, +): Array { + return messages.map((message) => { + const metadata = messageMetadata(message) + return { + id: message.id, + role: message.role, + parts: message.parts.flatMap(fromAISDKPart), + ...(metadata !== undefined && { metadata }), + } + }) +} + +/** + * The message metadata, with `tanstack` from `toUIMessageStream`. The OpenAI + * Responses adapter replays `tanstack.responseItems`, which name the items + * of one model call. A message of more steps drops them and replays as text. + */ +function messageMetadata( + message: AISDKUIMessage, +): Record | undefined { + if (!isRecord(message.metadata)) return undefined + const tanstack = message.metadata.tanstack + const steps = message.parts.filter((part) => part.type === 'step-start') + if (!isRecord(tanstack) || !('responseItems' in tanstack) || steps.length < 2) + return message.metadata + const { responseItems: _dropped, ...rest } = tanstack + return { ...message.metadata, tanstack: rest } +} + +function sourceToUrl(source: ContentPartSource): string { + return source.type === 'data' + ? `data:${source.mimeType};base64,${source.value}` + : source.value +} + +function toolOutput(content: ToolResultPart['content']): unknown { + if (typeof content !== 'string') return content + return parseJson(content) ?? content +} + +/** The AI SDK approval fields that the TanStack tool-call part has no place for. */ +export type AISDKApprovalFields = Omit< + Extract['approval'], + 'id' | 'approved' | 'reason' +> + +/** + * `ToolCallPart.metadata` as `fromUIMessageStream` sets it. `providerExecuted` + * is the TanStack convention. `aiSdk` keeps the AI SDK tool part fields that + * `toAISDKMessages` sends back. + */ +export interface AISDKToolCallMetadata extends ProviderExecutedToolMetadata { + aiSdk?: { + callProviderMetadata?: ProviderMetadata + resultProviderMetadata?: ProviderMetadata + approval?: AISDKApprovalFields + } +} + +function isApprovalFields(value: unknown): value is AISDKApprovalFields { + return ( + isRecord(value) && + (value.signature === undefined || typeof value.signature === 'string') && + (value.requestReason === undefined || + typeof value.requestReason === 'string') && + (value.isAutomatic === undefined || typeof value.isAutomatic === 'boolean') + ) +} + +/** The AI SDK fields kept on a tool call, read back from its metadata. */ +function aiSdkToolFields(part: ToolCallPart) { + const aiSdk = + isRecord(part.metadata) && isRecord(part.metadata.aiSdk) + ? part.metadata.aiSdk + : {} + return { + call: isProviderMetadata(aiSdk.callProviderMetadata) + ? aiSdk.callProviderMetadata + : undefined, + result: isProviderMetadata(aiSdk.resultProviderMetadata) + ? aiSdk.resultProviderMetadata + : undefined, + approval: isApprovalFields(aiSdk.approval) ? aiSdk.approval : {}, + } +} + +function toAISDKParts(message: UIMessage): Array { + const results = new Map() + const providerExecuted = new Set() + for (const part of message.parts) { + if (part.type === 'tool-result') results.set(part.toolCallId, part) + if (part.type === 'tool-call' && isProviderExecutedToolCall(part)) + providerExecuted.add(part.id) + } + + // A tool result ends a step, as in `chat()`: the parts after it are the + // model's next answer. `convertToModelMessages` splits at `step-start`. + // A provider runs its own tools inside one step. + // ponytail: the TanStack message does not keep the AI SDK step bounds, so a + // server tool's result that streamed before a later call of the same step + // splits that step in two. Keep the step index on the parts if that matters. + const parts: Array = [] + let stepEnded = false + function push(part: AISDKPart) { + if (stepEnded) parts.push({ type: 'step-start' }) + stepEnded = false + parts.push(part) + } + + for (const part of message.parts) { + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- tool results join their calls; TanStack UI-only parts are dropped by default. + switch (part.type) { + case 'text': + push({ type: 'text', text: part.content }) + break + case 'structured-output': { + // Same rule as `chat()`: a complete answer goes back as its JSON text. + if (part.status !== 'complete') break + const text = + part.raw !== '' + ? part.raw + : part.data === undefined + ? '' + : JSON.stringify(part.data) + if (text !== '') push({ type: 'text', text }) + break + } + case 'tool-result': + if (!providerExecuted.has(part.toolCallId)) stepEnded = true + break + case 'thinking': { + // `fromUIMessageStream` stores the AI SDK provider metadata as the + // signature. Send it back as it came. + const providerMetadata = + part.signature === undefined ? undefined : parseJson(part.signature) + push({ + type: 'reasoning', + text: part.content, + ...(isProviderMetadata(providerMetadata) && { providerMetadata }), + }) + break + } + case 'image': + case 'audio': + case 'video': + case 'document': + push({ + type: 'file', + mediaType: + part.source.mimeType ?? + (part.type === 'document' ? 'application/octet-stream' : part.type), + url: sourceToUrl(part.source), + }) + break + case 'tool-call': { + const input = part.input ?? parseJson(part.arguments) ?? {} + const result = results.get(part.id) + const fields = aiSdkToolFields(part) + const base = { + type: 'dynamic-tool' as const, + toolName: part.name, + toolCallId: part.id, + input, + ...(providerExecuted.has(part.id) && { providerExecuted: true }), + ...(fields.call && { callProviderMetadata: fields.call }), + } + const resultMetadata = fields.result && { + resultProviderMetadata: fields.result, + } + if (result?.outcome === 'denied' && part.approval) { + push({ + ...base, + state: 'output-denied', + approval: { + ...fields.approval, + id: part.approval.id, + approved: false, + }, + }) + } else if (result?.state === 'error') { + push({ + ...base, + ...resultMetadata, + state: 'output-error', + errorText: + result.error ?? + (typeof result.content === 'string' + ? result.content + : JSON.stringify(result.content)), + }) + } else if (result !== undefined) { + push({ + ...base, + ...resultMetadata, + state: 'output-available', + output: toolOutput(result.content), + }) + } else if (part.output !== undefined) { + push({ + ...base, + ...resultMetadata, + state: 'output-available', + output: part.output, + }) + } else if (part.approval?.approved !== undefined) { + // The AI SDK route runs or denies the tool from this answer. A + // route with `experimental_toolApprovalSecret` checks the signature. + push({ + ...base, + state: 'approval-responded', + approval: { + ...fields.approval, + id: part.approval.id, + approved: part.approval.approved, + }, + }) + } else if (part.approval) { + push({ + ...base, + state: 'approval-requested', + approval: { ...fields.approval, id: part.approval.id }, + }) + } else { + push({ ...base, state: 'input-available' }) + } + break + } + default: + // Results are joined with their calls above. Activity, subagent and + // UI-resource parts are TanStack UI only. + break + } + } + return parts +} + +/** + * Convert TanStack chat messages into AI SDK `UIMessage`s, the history an AI + * SDK route reads with `convertToModelMessages`. Tool calls become + * `dynamic-tool` parts, and a `step-start` part follows each tool result. + * System messages are left out: `streamText` rejects them in `messages`. The + * AI SDK message metadata that `fromUIMessageStream` kept goes back as the + * message `metadata`. + */ +export function toAISDKMessages( + messages: ReadonlyArray, +): Array { + const out: Array = [] + for (const message of messages) { + if (message.role === 'activity' || message.role === 'system') continue + const metadata = isRecord(message.metadata) + ? message.metadata.aiSdk + : undefined + out.push({ + id: message.id, + role: message.role, + ...(metadata !== undefined && { metadata }), + parts: toAISDKParts(message), + }) + } + return out +} diff --git a/packages/ai-ui-message-stream/src/request.ts b/packages/ai-ui-message-stream/src/request.ts new file mode 100644 index 0000000000..c3ecfc2d6e --- /dev/null +++ b/packages/ai-ui-message-stream/src/request.ts @@ -0,0 +1,96 @@ +import { isToolUIPart, validateUIMessages } from 'ai' +import { fromAISDKMessages } from './messages' +import { tanstackField } from './stream' +import type { RunAgentResumeItem, UIMessage } from '@tanstack/ai' +import type { UIMessage as AISDKUIMessage } from 'ai' + +/** What `chat()` needs from an AI SDK `useChat` request. */ +export interface AISDKChatParams { + /** The AI SDK chat id. */ + threadId: string + messages: Array + /** The user's answers to tool approvals, when there are any. */ + resume?: Array + /** + * Set with `resume`: the paused TanStack run, from the last assistant + * message's `metadata.tanstack.runId`, else the chat id. + */ + parentRunId?: string +} + +/** + * Read the body that AI SDK `useChat` posts (`{ id, messages }`) as + * `chat()` parameters: `chat({ adapter, tools, ...params })`. + * + * A tool part in `approval-responded` state becomes a resume item, so + * `chat()` runs or denies the paused tool call. + * + * @throws An error when the body is not a chat request or a message is not + * a valid AI SDK `UIMessage`. + */ +export async function chatParamsFromAISDKRequestBody( + body: unknown, +): Promise { + if ( + typeof body !== 'object' || + body === null || + !('id' in body) || + typeof body.id !== 'string' || + !('messages' in body) + ) { + throw new Error('The body must have an `id` and `messages`.') + } + const messages = await validateUIMessages({ + messages: body.messages, + }) + + const resume = messages.flatMap((message) => + message.parts.flatMap( + (part): Array => + isToolUIPart(part) && part.state === 'approval-responded' + ? [ + { + interruptId: part.approval.id, + status: 'resolved', + payload: { approved: part.approval.approved }, + }, + ] + : [], + ), + ) + + // `toUIMessageStream` puts the run id in the message metadata. + const runId = tanstackField( + messages.filter((m) => m.role === 'assistant').at(-1)?.metadata, + 'runId', + ) + return { + threadId: body.id, + messages: fromAISDKMessages(messages), + ...(resume.length > 0 && { + resume, + parentRunId: typeof runId === 'string' ? runId : body.id, + }), + } +} + +/** + * Read an AI SDK `useChat` request as `chat()` parameters. See + * `chatParamsFromAISDKRequestBody`. + * + * @throws {Response} 400 when the body is not JSON or not a valid chat + * request. + */ +export async function chatParamsFromAISDKRequest( + request: Request, +): Promise { + try { + return await chatParamsFromAISDKRequestBody(await request.json()) + } catch (cause) { + // Keep the details on `cause` for server logs, not in the response. + throw Object.assign( + new Response('Invalid AI SDK chat request body.', { status: 400 }), + { cause }, + ) + } +} diff --git a/packages/ai-ui-message-stream/src/resume.ts b/packages/ai-ui-message-stream/src/resume.ts new file mode 100644 index 0000000000..11f4d26dec --- /dev/null +++ b/packages/ai-ui-message-stream/src/resume.ts @@ -0,0 +1,42 @@ +import { replayRunStream, toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk, StreamDurability } from '@tanstack/ai' +import { createUIMessageStreamResponse } from 'ai' +import { toUIMessageStream } from './stream' + +/** + * Answer the AI SDK resume request (`GET {api}/{chatId}/stream`, which + * `useChat({ resume: true })` sends on mount). Pass the durability log of the + * chat's active run. The run is replayed from its start, as the AI SDK + * `DefaultChatTransport` expects. With no log, the response is the AI SDK's + * "nothing to resume" 204. + */ +export function resumeUIMessageStreamResponse( + durability: StreamDurability | null | undefined, +): Response { + if (!durability) return new Response(null, { status: 204 }) + return createUIMessageStreamResponse({ + stream: toUIMessageStream(replayRunStream(durability)), + }) +} + +/** + * Serve a `chat()` run to the AI SDK `useChat` so that it can be resumed. The + * run is written to `durability` and keeps running when the client goes away. + * The response reads the run back from the log, the same way + * {@link resumeUIMessageStreamResponse} does. + */ +export function toResumableUIMessageStreamResponse( + stream: AsyncIterable, + durability: StreamDurability, +): Response { + // ponytail: the SSE helper owns the only exported durable producer. It + // writes the run to the log and does not wait for a reader, so its unused + // SSE body is drained (a cancel would mark the run as detached). Export the + // producer from @tanstack/ai to drop the extra SSE encoding. + void toServerSentEventsResponse(stream, { + durability: { adapter: durability }, + }) + .body?.pipeTo(new WritableStream()) + .catch(() => {}) + return resumeUIMessageStreamResponse(durability) +} diff --git a/packages/ai-ui-message-stream/src/stream.ts b/packages/ai-ui-message-stream/src/stream.ts new file mode 100644 index 0000000000..7ff6ca5e4e --- /dev/null +++ b/packages/ai-ui-message-stream/src/stream.ts @@ -0,0 +1,977 @@ +import { + EventType, + INTERRUPT_BINDING_VERSION, + RUN_ACCEPTED_EVENT, + getProviderExecutedMetadata, + hashSchemaInput, + normalizeApprovalSchema, + readInterruptBinding, + validateWithStandardSchema, + withInterruptBinding, +} from '@tanstack/ai' +import type { + AnyClientTool, + Interrupt, + RunFinishedEvent, + SchemaInput, + StreamChunk, + ToolCallStartEvent, + ToolResultOutcome, +} from '@tanstack/ai' +import type { FinishReason, ProviderMetadata, UIMessageChunk } from 'ai' +import { DataParts, dataChunkEvent, isDataChunk, isRecord } from './data-parts' +import type { AISDKApprovalFields, AISDKToolCallMetadata } from './messages' + +const TO_AI_SDK_FINISH: Partial> = { + stop: 'stop', + length: 'length', + content_filter: 'content-filter', + tool_calls: 'tool-calls', +} + +const FROM_AI_SDK_FINISH: Record< + FinishReason, + 'stop' | 'length' | 'content_filter' | null +> = { + stop: 'stop', + length: 'length', + 'content-filter': 'content_filter', + // A TanStack `tool_calls` finish means more steps follow in the same + // stream. An AI SDK stream has ended here, and the client must run its + // tools, so the reason is left out. + 'tool-calls': null, + error: null, + other: null, +} + +/** TanStack marks a redacted thinking block by this reasoning message id prefix. */ +const REDACTED_PREFIX = 'redacted_thinking-' + +/** `metadata.tanstack[key]`, or undefined. */ +export function tanstackField(metadata: unknown, key: string): unknown { + const tanstack: unknown = + typeof metadata === 'object' && metadata !== null && 'tanstack' in metadata + ? metadata.tanstack + : undefined + return typeof tanstack === 'object' && tanstack !== null && key in tanstack + ? Reflect.get(tanstack, key) + : undefined +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +/** Tool output as the AI SDK client renders it: parsed JSON, else the text. */ +function parseOutput(content: string | ReadonlyArray): unknown { + if (typeof content !== 'string') return content + return parseJson(content) ?? content +} + +/** A tool call that `toUIMessageStream` has seen. */ +interface ToolCallState { + name: string + args: string + /** `TOOL_CALL_START.metadata`, the TanStack `ToolCallPart.metadata`. */ + metadata?: ToolCallStartEvent['metadata'] +} + +/** + * The tool call's TanStack metadata as AI SDK call provider metadata, under + * `tanstack`. `fromAISDKMessages` reads it back. + */ +function toolCallFields(tool: ToolCallState): { + providerMetadata?: ProviderMetadata + providerExecuted?: boolean +} { + if (tool.metadata === undefined) return {} + return { + providerMetadata: { tanstack: tool.metadata }, + ...(getProviderExecutedMetadata(tool) && { providerExecuted: true }), + } +} + +/** + * Convert the AG-UI event stream of `chat()` into an AI SDK UI message + * stream. Serve it with the AI SDK's `createUIMessageStreamResponse`, and the + * AI SDK `useChat` reads it like a `streamText` route. + * + * Each model call is one step. Its `RUN_FINISHED` metadata (model, response + * ids, finish reason) and usage go into the message metadata. A tool call's + * metadata goes into `providerMetadata.tanstack`. A provider-executed call + * gets a `providerExecuted` output and a `source-url` part for each source. + * + * A tool approval becomes a `tool-approval-request` chunk. Read the answer + * with `chatParamsFromAISDKRequest`. The message metadata gets the paused + * TanStack run id as `{ tanstack: { runId } }`, so the answer resumes that + * run. A subagent's approval shows on its card only. An activity becomes a `data-` + * part, shared state a `data-state` part, and each subagent a `data-subagent` + * card, each updated in place by id. A CUSTOM event becomes a transient + * `data-` chunk, which reaches `onData`. + */ +export function toUIMessageStream( + stream: AsyncIterable, +): ReadableStream { + async function* convert(): AsyncGenerator { + let started = false + let errored = false + let finishReason: FinishReason | undefined + const tools = new Map() + const signatures = new Map() + const dataParts = new DataParts() + // Each model call of chat() is one AI SDK step. It ends at RUN_FINISHED. + let inStep = false + const usage: Array> = [] + + function* start(messageId?: string): Generator { + if (started) return + started = true + yield messageId === undefined + ? { type: 'start' } + : { type: 'start', messageId } + } + + function* startStep(): Generator { + if (inStep) return + inStep = true + yield { type: 'start-step' } + } + + function* finishStep(): Generator { + if (!inStep) return + inStep = false + yield { type: 'finish-step' } + } + + for await (const event of stream) { + // A subagent's events go to its card, not to this message. + if ('subagentRunId' in event && event.subagentRunId !== undefined) { + const chunks = dataParts.chunks(event) + if (chunks.length > 0) { + yield* start() + yield* chunks + } + continue + } + + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- steps and message snapshots have no AI SDK chunk; default skips them. + switch (event.type) { + case EventType.TEXT_MESSAGE_START: + yield* start(event.messageId) + yield* startStep() + yield { type: 'text-start', id: event.messageId } + break + case EventType.TEXT_MESSAGE_CONTENT: + yield { type: 'text-delta', id: event.messageId, delta: event.delta } + break + case EventType.TEXT_MESSAGE_END: + yield { type: 'text-end', id: event.messageId } + break + + case EventType.REASONING_MESSAGE_START: + yield* start() + yield* startStep() + yield { type: 'reasoning-start', id: event.messageId } + break + case EventType.REASONING_MESSAGE_CONTENT: + yield { + type: 'reasoning-delta', + id: event.messageId, + delta: event.delta, + } + break + case EventType.REASONING_ENCRYPTED_VALUE: + // ponytail: only a signature sent before REASONING_MESSAGE_END is + // kept, which is the order the TanStack adapters use. Buffer the + // reasoning-end if an adapter ever sends it later. + if (event.subtype === 'message') { + signatures.set(event.entityId, event.encryptedValue) + } + // A tool call's signature (Gemini `thoughtSignature`) joins its + // metadata, as the TanStack client stores it. It goes out on + // `tool-input-available`. + if (event.subtype === 'tool-call') { + const tool = tools.get(event.entityId) + if (tool) { + tool.metadata = { + ...tool.metadata, + thoughtSignature: event.encryptedValue, + } + } + } + break + case EventType.REASONING_MESSAGE_END: { + const signature = signatures.get(event.messageId) + const providerMetadata: ProviderMetadata | undefined = + signature === undefined + ? undefined + : { + tanstack: { + signature, + ...(event.messageId.startsWith(REDACTED_PREFIX) && { + redacted: true, + }), + }, + } + yield { + type: 'reasoning-end', + id: event.messageId, + ...(providerMetadata && { providerMetadata }), + } + break + } + + case EventType.TOOL_CALL_START: { + yield* start(event.parentMessageId) + yield* startStep() + const tool: ToolCallState = { + name: event.toolCallName, + args: '', + metadata: event.metadata, + } + tools.set(event.toolCallId, tool) + yield { + type: 'tool-input-start', + toolCallId: event.toolCallId, + toolName: event.toolCallName, + ...toolCallFields(tool), + } + break + } + case EventType.TOOL_CALL_ARGS: { + const tool = tools.get(event.toolCallId) + if (tool) tool.args += event.delta + yield { + type: 'tool-input-delta', + toolCallId: event.toolCallId, + inputTextDelta: event.delta, + } + break + } + case EventType.TOOL_CALL_END: { + const tool = tools.get(event.toolCallId) + if (!tool) break + const input = + tanstackField(event.metadata, 'input') ?? + (tool.args === '' ? {} : parseJson(tool.args)) + yield { + type: 'tool-input-available', + toolCallId: event.toolCallId, + toolName: tool.name, + input, + ...toolCallFields(tool), + } + // The provider ran this call and sends no TOOL_CALL_RESULT. The AI + // SDK client marks it done with a provider-executed output. + const executed = getProviderExecutedMetadata(tool) + if (executed) { + yield { + type: 'tool-output-available', + toolCallId: event.toolCallId, + output: executed.sources ?? null, + providerExecuted: true, + } + for (const [index, source] of (executed.sources ?? []).entries()) { + yield { + type: 'source-url', + sourceId: `${event.toolCallId}-source-${index}`, + url: source.url, + ...(source.title !== undefined && { title: source.title }), + } + } + } + break + } + case EventType.TOOL_CALL_RESULT: + // A resumed run can start with a result. A start without an id + // keeps the AI SDK message that holds the tool call. + yield* start() + if (tanstackField(event.metadata, 'toolResultOutcome') === 'denied') { + yield { type: 'tool-output-denied', toolCallId: event.toolCallId } + } else if ( + tanstackField(event.metadata, 'state') === 'output-error' + ) { + yield { + type: 'tool-output-error', + toolCallId: event.toolCallId, + errorText: + typeof event.content === 'string' + ? event.content + : JSON.stringify(event.content), + } + } else { + yield { + type: 'tool-output-available', + toolCallId: event.toolCallId, + output: parseOutput(event.content), + } + } + break + + case EventType.ACTIVITY_SNAPSHOT: + case EventType.ACTIVITY_DELTA: + case EventType.STATE_SNAPSHOT: + case EventType.STATE_DELTA: + yield* start() + yield* dataParts.chunks(event) + break + + case EventType.CUSTOM: + // The durability log marks an accepted run. It is not app data. + if (event.name === RUN_ACCEPTED_EVENT) break + yield* start() + yield { + type: `data-${event.name}`, + data: event.value, + transient: true, + } + break + + case EventType.RUN_FINISHED: { + yield* finishStep() + // The model call's metadata (model, source, response ids) goes on + // the message, as the TanStack client keeps it. AI SDK merges each + // `message-metadata`. `tanstack.tokenUsage` lists each model call's + // AG-UI usage, under the TanStack key so it cannot clash with an + // app's own metadata. + if (event.usage !== undefined) usage.push(event.usage) + if (event.metadata !== undefined || usage.length > 0) { + const tanstack = event.metadata?.tanstack + yield* start() + yield { + type: 'message-metadata', + messageMetadata: { + ...event.metadata, + ...(usage.length > 0 && { + tanstack: { + ...(isRecord(tanstack) && tanstack), + tokenUsage: [...usage], + }, + }), + }, + } + } + const reason = tanstackField(event.metadata, 'finishReason') + if (typeof reason === 'string') + finishReason = TO_AI_SDK_FINISH[reason] + // A tool approval pauses the run. A client tool needs no chunk: the + // AI SDK client runs a tool call that has no output. A subagent's + // tool call is not in this message (the AI SDK client throws on an + // unknown one), so its approval shows on the subagent card only. + if (event.outcome?.type === 'interrupt') { + for (const interrupt of event.outcome.interrupts) { + if ( + interrupt.metadata?.kind !== 'approval' || + interrupt.toolCallId === undefined || + !tools.has(interrupt.toolCallId) + ) + continue + yield* start() + yield { + type: 'tool-approval-request', + approvalId: interrupt.id, + toolCallId: interrupt.toolCallId, + } + // The binding names the paused run. The event `runId` can be + // the adapter's own id. + const runId = readInterruptBinding(interrupt)?.interruptedRunId + if (runId !== undefined) { + yield { + type: 'message-metadata', + messageMetadata: { tanstack: { runId } }, + } + } + } + } + break + } + case EventType.RUN_ERROR: + errored = true + yield { type: 'error', errorText: event.message } + break + + default: + break + } + } + + if (errored) return + yield* start() + yield* finishStep() + yield finishReason === undefined + ? { type: 'finish' } + : { type: 'finish', finishReason } + } + + const iterator = convert() + return new ReadableStream({ + async pull(controller) { + const { done, value } = await iterator.next() + if (done) controller.close() + else controller.enqueue(value) + }, + async cancel() { + await iterator.return(undefined) + }, + }) +} + +/** Options for {@link fromUIMessageStream}. */ +export interface FromUIMessageStreamOptions { + threadId: string + runId: string + /** + * The client's tools, as passed to `useChat({ tools })`. A tool approval for + * one of them with `needsApproval: true` is a `tool-approval` interrupt. + */ + tools?: ReadonlyArray + /** + * The schema of the route's `Output.object({ schema })`, as passed to + * `useChat({ outputSchema })`. The answer text becomes a `structured-output` + * part with partial objects while it streams. At the end the text is + * checked against the schema: a valid answer is the part's `data`, and an + * invalid one ends the run with a RUN_ERROR. + */ + outputSchema?: SchemaInput +} + +/** + * Convert an AI SDK UI message stream into AG-UI events for the TanStack chat + * client. All text and tool calls go into one assistant message, as the AI + * SDK builds them. + * + * A tool approval request ends the run with an approval interrupt. For a + * tool in `tools`, it is a `tool-approval` interrupt: answer it with + * `resolveInterrupt(true)` or `client.resolveInterrupts(true)`. For another + * tool, it is a generic interrupt: answer it with + * `resolveInterrupt({ approved })`. The next request sends the tool part as + * `approval-responded`. An approval the route made itself (`isAutomatic`) + * asks nothing. + * + * A tool call keeps its AI SDK provider metadata, approval signature and + * `providerExecuted` flag in its metadata, and the message keeps the AI SDK + * message metadata as `metadata.aiSdk`, so `toAISDKMessages` sends them back. + * A provider-executed call is not run on the client. Steps become + * STEP_STARTED / STEP_FINISHED. Sources, files and citations become CUSTOM + * events named `source-url`, `source-document`, `file`, `reasoning-file` and + * `citations`. A `data-` part with an id and object data becomes an + * activity of type ``. Other data chunks become CUSTOM events named + * `data-`. + */ +export async function* fromUIMessageStream( + stream: ReadableStream, + { + threadId, + runId, + tools: clientTools, + outputSchema, + }: FromUIMessageStreamOptions, +): AsyncGenerator { + let messageId = `${runId}-assistant` + let ended = false + // AI SDK reasoning ids are only unique inside one step, so each reasoning + // part gets its own AG-UI message id. + const reasoningIds = new Map() + let reasoningCount = 0 + let stepCount = 0 + // `text`: the input streams as raw text, not JSON, so only the final input + // becomes the arguments. + const tools = new Map< + string, + { hasArgs: boolean; text: boolean; ended?: boolean } + >() + // TOOL_CALL_END waits for the next chunk. An approval request or an output + // for the same call can still add metadata, and AG-UI allows no + // TOOL_CALL_ARGS after TOOL_CALL_END. + const pendingEnds = new Set() + const toolMetadata = new Map() + const inputs = new Map() + const approvals: Array = [] + // ponytail: shallow merge, where the AI SDK merges nested objects too. + let messageMetadata: unknown + // The answer text, once text starts with `outputSchema` set. + // ponytail: all text of the stream is the answer, so text in a tool-call + // step joins it. Start the answer per step if a route sends such text. + let structuredRaw: string | undefined + + /** + * `structured-output.complete` with the checked answer, or a RUN_ERROR + * when the text is not JSON or fails the schema. + */ + async function structuredEnd(): Promise { + if (structuredRaw === undefined) return undefined + const object = parseJson(structuredRaw) + const result = + object === undefined + ? undefined + : await validateWithStandardSchema(outputSchema, object) + if (result?.success) { + return { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { object: result.data, raw: structuredRaw, messageId }, + } + } + return { + type: EventType.RUN_ERROR, + code: 'structured-output-validation-failed', + message: + result === undefined + ? 'The structured output is not valid JSON' + : `The structured output does not match the schema: ${result.issues + .map((issue) => issue.message) + .join(', ')}`, + } + } + + function mergeMessageMetadata(value: unknown) { + if (value === undefined) return + messageMetadata = + isRecord(messageMetadata) && isRecord(value) + ? { ...messageMetadata, ...value } + : value + } + + /** RUN_FINISHED metadata. The client merges it into the run's messages. */ + function runMetadata( + tanstack?: Record, + ): Record | undefined { + if (tanstack === undefined && messageMetadata === undefined) + return undefined + return { + ...(tanstack && { tanstack }), + ...(messageMetadata !== undefined && { aiSdk: messageMetadata }), + } + } + + function* finished( + metadata?: Record, + ): Generator { + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + outcome: + approvals.length > 0 + ? { type: 'interrupt', interrupts: approvals } + : { type: 'success' }, + ...(metadata && { metadata }), + } + } + + /** + * Add AI SDK fields to a tool call's metadata. The TanStack client keeps + * the metadata of TOOL_CALL_START and TOOL_CALL_ARGS on the tool-call + * part, so a started call gets the update on an empty TOOL_CALL_ARGS. The + * client replaces the `aiSdk` key, so each update carries all of it. + */ + function* updateToolMetadata( + toolCallId: string, + providerExecuted: boolean | undefined, + aiSdk: NonNullable, + ): Generator { + const current = toolMetadata.get(toolCallId) + const next: AISDKToolCallMetadata = { + ...current, + ...(providerExecuted === true && { providerExecuted: true }), + aiSdk: { ...current?.aiSdk, ...aiSdk }, + } + toolMetadata.set(toolCallId, next) + // A started call takes the update until it ends. After the end the + // update stays off the stream. + const tool = tools.get(toolCallId) + if (tool && !tool.ended) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: '', + metadata: next, + } + } + } + + /** The call metadata of a tool chunk, when it has any. */ + function* callMetadata(chunk: { + toolCallId: string + providerExecuted?: boolean + providerMetadata?: ProviderMetadata + }): Generator { + if (chunk.providerExecuted !== true && chunk.providerMetadata === undefined) + return + yield* updateToolMetadata( + chunk.toolCallId, + chunk.providerExecuted, + chunk.providerMetadata === undefined + ? {} + : { callProviderMetadata: chunk.providerMetadata }, + ) + } + + /** The result metadata of a tool chunk, for a call started in this stream. */ + function* resultMetadata(chunk: { + toolCallId: string + providerMetadata?: ProviderMetadata + }): Generator { + if (chunk.providerMetadata === undefined || !tools.has(chunk.toolCallId)) + return + yield* updateToolMetadata(chunk.toolCallId, undefined, { + resultProviderMetadata: chunk.providerMetadata, + }) + } + + /** End one waiting call, or every waiting call except `keep`. */ + function* endTools(only?: string, keep?: string): Generator { + for (const toolCallId of only === undefined ? [...pendingEnds] : [only]) { + if (toolCallId === keep || !pendingEnds.delete(toolCallId)) continue + const tool = tools.get(toolCallId) + if (tool) tool.ended = true + yield { type: EventType.TOOL_CALL_END, toolCallId } + } + } + + function* ensureToolStarted( + toolCallId: string, + toolName: string, + input: unknown, + ): Generator { + let tool = tools.get(toolCallId) + if (!tool) { + tool = { hasArgs: false, text: false } + tools.set(toolCallId, tool) + const metadata = toolMetadata.get(toolCallId) + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: toolName, + parentMessageId: messageId, + ...(metadata && { metadata }), + } + } + if (!tool.hasArgs) { + tool.hasArgs = true + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: JSON.stringify(input ?? {}), + } + } + } + + yield { type: EventType.RUN_STARTED, threadId, runId } + + for await (const chunk of stream) { + yield* endTools( + undefined, + 'toolCallId' in chunk ? chunk.toolCallId : undefined, + ) + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- reset-step and custom parts are skipped; default maps data parts. + switch (chunk.type) { + case 'start': + if (chunk.messageId !== undefined) messageId = chunk.messageId + mergeMessageMetadata(chunk.messageMetadata) + break + case 'message-metadata': + mergeMessageMetadata(chunk.messageMetadata) + break + + case 'start-step': + yield { type: EventType.STEP_STARTED, stepName: `step-${++stepCount}` } + break + case 'finish-step': + yield { type: EventType.STEP_FINISHED, stepName: `step-${stepCount}` } + break + + // No TanStack part holds these. The app reads them in `onCustomEvent`. + case 'source-url': + case 'source-document': + case 'file': + case 'reasoning-file': { + const { type, ...value } = chunk + yield { type: EventType.CUSTOM, name: type, value } + break + } + + // Every AI SDK text part belongs to the one assistant message. The + // TanStack client starts a new text segment on each START. + case 'text-start': + // The TanStack client puts the text after this event in a + // `structured-output` part, as for `chat({ outputSchema })`. + if (outputSchema !== undefined && structuredRaw === undefined) { + structuredRaw = '' + yield { + type: EventType.CUSTOM, + name: 'structured-output.start', + value: { messageId }, + } + } + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + } + break + case 'text-delta': + if (structuredRaw !== undefined) structuredRaw += chunk.delta + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: chunk.delta, + } + break + case 'text-end': + yield { type: EventType.TEXT_MESSAGE_END, messageId } + if (chunk.citations !== undefined) { + yield { + type: EventType.CUSTOM, + name: 'citations', + value: chunk.citations, + } + } + break + + case 'reasoning-start': { + const id = `${messageId}-reasoning-${reasoningCount++}` + reasoningIds.set(chunk.id, id) + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: id, + role: 'reasoning', + } + break + } + case 'reasoning-delta': { + const id = reasoningIds.get(chunk.id) + if (id !== undefined) { + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: id, + delta: chunk.delta, + } + } + break + } + case 'reasoning-end': { + const id = reasoningIds.get(chunk.id) + if (id === undefined) break + // The provider metadata holds the reasoning signature. The client + // keeps it as an opaque value and sends it back unchanged. + if (chunk.providerMetadata !== undefined) { + yield { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'message', + entityId: id, + encryptedValue: JSON.stringify(chunk.providerMetadata), + } + } + yield { type: EventType.REASONING_MESSAGE_END, messageId: id } + break + } + + case 'tool-input-start': { + yield* callMetadata(chunk) + const metadata = toolMetadata.get(chunk.toolCallId) + tools.set(chunk.toolCallId, { + hasArgs: false, + text: chunk.inputEncoding === 'text', + }) + yield { + type: EventType.TOOL_CALL_START, + toolCallId: chunk.toolCallId, + toolCallName: chunk.toolName, + parentMessageId: messageId, + ...(metadata && { metadata }), + } + break + } + case 'tool-input-delta': { + const tool = tools.get(chunk.toolCallId) + if (tool?.text) break + if (tool) tool.hasArgs = true + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: chunk.toolCallId, + delta: chunk.inputTextDelta, + } + break + } + case 'tool-input-available': + inputs.set(chunk.toolCallId, { + toolName: chunk.toolName, + input: chunk.input, + }) + yield* callMetadata(chunk) + yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) + pendingEnds.add(chunk.toolCallId) + break + case 'tool-approval-request': { + // The route approved or denied the call itself. The output or the + // denial follows, so there is nothing to ask. + if (chunk.isAutomatic === true) break + // The tool part keeps the fields the AI SDK route checks on the answer. + const approval: AISDKApprovalFields = { + ...(chunk.approvalDescriptor != null && { + descriptor: chunk.approvalDescriptor, + }), + ...(Object.hasOwn(chunk, 'inputSchemaInput') && { + inputSchemaInput: chunk.inputSchemaInput, + }), + ...(chunk.reason != null && { requestReason: chunk.reason }), + ...(chunk.signature != null && { signature: chunk.signature }), + } + if (Object.keys(approval).length > 0) + yield* updateToolMetadata(chunk.toolCallId, undefined, { approval }) + yield* endTools(chunk.toolCallId) + // `kind: 'approval'` shows the approval on the tool call. + const call = inputs.get(chunk.toolCallId) + const descriptor: Interrupt = { + id: chunk.approvalId, + reason: 'tool_call', + toolCallId: chunk.toolCallId, + metadata: { kind: 'approval', ...call }, + } + const tool = clientTools?.find((t) => t.name === call?.toolName) + if (call && tool?.needsApproval === true) { + // The binding chat() makes. The client accepts it only when the + // schema hashes match its own copy of the tool. + const schema = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, + ) + approvals.push( + withInterruptBinding( + { ...descriptor, responseSchema: schema.responseSchema }, + { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: chunk.approvalId, + toolName: call.toolName, + toolCallId: chunk.toolCallId, + originalArgs: call.input, + inputSchemaHash: hashSchemaInput(tool.inputSchema), + approvalSchemaHash: schema.approvalSchemaHash, + responseSchemaHash: schema.responseSchemaHash, + interruptedRunId: runId, + generation: 0, + }, + ), + ) + break + } + // A generic binding makes the interrupt resolvable without the + // client's copy of the tool. + approvals.push( + withInterruptBinding(descriptor, { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: chunk.approvalId, + interruptedRunId: runId, + generation: 0, + }), + ) + break + } + case 'tool-output-denied': + yield* endTools(chunk.toolCallId) + yield toolError( + chunk.toolCallId, + 'The user denied this tool call', + 'denied', + ) + break + case 'tool-input-error': + yield* callMetadata(chunk) + yield* ensureToolStarted(chunk.toolCallId, chunk.toolName, chunk.input) + yield { type: EventType.TOOL_CALL_END, toolCallId: chunk.toolCallId } + yield toolError(chunk.toolCallId, chunk.errorText) + break + case 'tool-output-available': + // A preliminary output is a progress update. Only the last one is the result. + if (chunk.preliminary === true) break + yield* resultMetadata(chunk) + yield* endTools(chunk.toolCallId) + yield { + type: EventType.TOOL_CALL_RESULT, + messageId: `${chunk.toolCallId}-result`, + toolCallId: chunk.toolCallId, + content: + typeof chunk.output === 'string' + ? chunk.output + : JSON.stringify(chunk.output ?? null), + role: 'tool', + } + break + case 'tool-output-error': + yield* resultMetadata(chunk) + yield* endTools(chunk.toolCallId) + yield toolError(chunk.toolCallId, chunk.errorText) + break + + case 'error': + ended = true + yield { type: EventType.RUN_ERROR, message: chunk.errorText } + break + case 'abort': { + ended = true + const metadata = runMetadata() + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + outcome: { type: 'cancelled' }, + ...(metadata && { metadata }), + } + break + } + case 'finish': { + ended = true + mergeMessageMetadata(chunk.messageMetadata) + const finishReason = + chunk.finishReason === undefined + ? undefined + : FROM_AI_SDK_FINISH[chunk.finishReason] + const end = await structuredEnd() + if (end) yield end + if (end?.type === EventType.RUN_ERROR) return + yield* finished( + runMetadata(finishReason == null ? undefined : { finishReason }), + ) + break + } + + default: + if (isDataChunk(chunk)) yield dataChunkEvent(chunk) + break + } + if (ended) return + } + + yield* endTools() + const end = await structuredEnd() + if (end) yield end + if (end?.type === EventType.RUN_ERROR) return + yield* finished(runMetadata()) +} + +function toolError( + toolCallId: string, + errorText: string, + toolResultOutcome?: ToolResultOutcome, +): StreamChunk { + return { + type: EventType.TOOL_CALL_RESULT, + messageId: `${toolCallId}-result`, + toolCallId, + content: errorText, + role: 'tool', + metadata: { + tanstack: { + state: 'output-error', + ...(toolResultOutcome && { toolResultOutcome }), + }, + }, + } +} diff --git a/packages/ai-ui-message-stream/tests/approvals.test.ts b/packages/ai-ui-message-stream/tests/approvals.test.ts new file mode 100644 index 0000000000..71aecca01b --- /dev/null +++ b/packages/ai-ui-message-stream/tests/approvals.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType, chat } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { fromChatTransport, toUIMessageStream } from '../src' +import { chatParamsFromAISDKRequest } from '../src/request' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +describe('TanStack server, AI SDK client: approvals', () => { + async function run(approved: boolean) { + const fake = fakeText() + fake.setResponses([ + { toolCalls: [{ name: 'deleteFile', input: { path: 'a.txt' } }] }, + ({ request }) => ({ + text: `Result: ${String(request.messages.at(-1)?.content)}`, + }), + ]) + const execute = vi.fn(() => ({ deleted: true })) + const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, + }).server(execute) + + // The run ids chat() sees, from a middleware. + const runs: Array<{ runId: string; parentRunId?: string }> = [] + const middleware = [ + { + onStart: (ctx: { runId: string; parentRunId?: string }) => { + runs.push({ runId: ctx.runId, parentRunId: ctx.parentRunId }) + }, + }, + ] + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Delete a.txt' }], + } + + // Turn 1: the AI SDK client sends the user message. + const firstParams = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { + method: 'POST', + body: JSON.stringify({ id: 'chat-1', messages: [user] }), + }), + ) + let first: UIMessage | undefined + for await (const message of readUIMessageStream({ + stream: toUIMessageStream( + chat({ + adapter: fake, + tools: [deleteFile], + middleware, + ...firstParams, + }), + ), + })) + first = message + // The message keeps the paused TanStack run id. + expect(first?.metadata).toMatchObject({ + tanstack: { runId: runs[0]?.runId }, + }) + const requested = first?.parts.find((p) => p.type === 'tool-deleteFile') + expect(requested).toMatchObject({ + state: 'approval-requested', + input: { path: 'a.txt' }, + approval: { id: expect.any(String) }, + }) + expect(execute).not.toHaveBeenCalled() + + // The AI SDK client answers: `addToolApprovalResponse` sets this state. + const answered: UIMessage = { + ...first!, + parts: first!.parts.map((part) => + part.type === 'tool-deleteFile' && part.state === 'approval-requested' + ? { + ...part, + state: 'approval-responded', + approval: { id: part.approval.id, approved }, + } + : part, + ), + } + + // Turn 2: the history goes back, and chat() resumes the paused call. + const params = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { + method: 'POST', + body: JSON.stringify({ id: 'chat-1', messages: [user, answered] }), + }), + ) + expect(params.resume).toEqual([ + { + interruptId: answered.parts.flatMap((p) => + p.type === 'tool-deleteFile' && p.state === 'approval-responded' + ? [p.approval.id] + : [], + )[0], + status: 'resolved', + payload: { approved }, + }, + ]) + // The resume names the paused run, not the chat id. + expect(params.parentRunId).toBe(runs[0]?.runId) + expect(params.parentRunId).not.toBe('chat-1') + let second: UIMessage | undefined + for await (const message of readUIMessageStream({ + // The AI SDK client continues the last assistant message. + message: answered, + stream: toUIMessageStream( + chat({ adapter: fake, tools: [deleteFile], middleware, ...params }), + ), + })) + second = message + expect(runs[1]?.parentRunId).toBe(runs[0]?.runId) + return { execute, second: second! } + } + + it('runs the tool after the user approves', async () => { + const { execute, second } = await run(true) + expect(execute).toHaveBeenCalledTimes(1) + expect(second.parts).toMatchObject([ + { type: 'step-start' }, + { + type: 'tool-deleteFile', + state: 'output-available', + output: { deleted: true }, + }, + { type: 'step-start' }, + { type: 'text', text: 'Result: {"deleted":true}' }, + ]) + }) + + it('skips the tool after the user denies', async () => { + const { execute, second } = await run(false) + expect(execute).not.toHaveBeenCalled() + expect(second.parts).toMatchObject([ + { type: 'step-start' }, + { + type: 'tool-deleteFile', + state: 'output-denied', + approval: { approved: false }, + }, + { type: 'step-start' }, + { type: 'text', text: expect.stringContaining('Result:') }, + ]) + }) + + it('shows a subagent approval on its card, not as a top-level request', async () => { + const events: Array = [ + { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'researcher', + parentMessageId: 'm', + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' }, + { + type: EventType.SUBAGENT_STARTED, + subagentRunId: 's-1', + name: 'researcher', + parentToolCallId: 'call-1', + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'c-1', + toolCallName: 'deleteFile', + subagentRunId: 's-1', + }, + { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 's-1', + outcome: { type: 'suspended', interruptIds: ['ap-1'] }, + }, + { + type: EventType.RUN_FINISHED, + threadId: 't', + runId: 'r', + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'ap-1', + reason: 'tool_call', + toolCallId: 'c-1', + subagentRunId: 's-1', + metadata: { kind: 'approval' }, + }, + ], + }, + }, + ] + async function* source() { + yield* events + } + let message: UIMessage | undefined + // The AI SDK client throws on an approval for an unknown tool call. + for await (const m of readUIMessageStream({ + stream: toUIMessageStream(source()), + })) + message = m + expect(message?.parts).toMatchObject([ + { type: 'step-start' }, + { type: 'tool-researcher', toolCallId: 'call-1' }, + { + type: 'data-subagent', + id: 's-1', + data: { status: 'suspended', interruptIds: ['ap-1'] }, + }, + ]) + }) + + it('rejects a body that is not an AI SDK chat request', async () => { + const error: unknown = await chatParamsFromAISDKRequest( + new Request('http://x/api/chat', { method: 'POST', body: '{}' }), + ).catch((e: unknown) => e) + expect(error).toBeInstanceOf(Response) + expect(error instanceof Response && error.status).toBe(400) + }) +}) + +describe('AI SDK server, TanStack client: approvals', () => { + const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, + }) + + const tools = [deleteFile] as const + + /** `bound`: the adapter gets the tools too, as the docs show. */ + async function run( + approved: boolean, + answer: (client: ChatClient) => void, + bound = true, + ) { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve( + chunkStream( + requests.length === 1 + ? [ + { type: 'start', messageId: 'a-1' }, + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'deleteFile', + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'deleteFile', + input: { path: 'a.txt' }, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + }, + { type: 'finish', finishReason: 'tool-calls' }, + ] + : [ + { type: 'start', messageId: 'a-1' }, + approved + ? { + type: 'tool-output-available', + toolCallId: 'c-1', + output: { deleted: true }, + } + : { type: 'tool-output-denied', toolCallId: 'c-1' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'All done.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ], + ), + ) + }, + reconnectToStream: () => Promise.resolve(null), + } + const client = new ChatClient({ + threadId: 'thread-1', + tools, + connection: fromChatTransport(transport, bound ? { tools } : {}), + }) + + await client.sendMessage('Delete a.txt') + const toolCall = client + .getMessages() + .flatMap((m) => m.parts) + .find((p) => p.type === 'tool-call') + expect(toolCall).toMatchObject({ + state: 'approval-requested', + approval: { id: 'ap-1' }, + }) + + expect(client.getInterrupts()).toMatchObject([ + { + kind: bound ? 'tool-approval' : 'generic', + canResolve: true, + id: 'ap-1', + metadata: { toolName: 'deleteFile', input: { path: 'a.txt' } }, + }, + ]) + answer(client) + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(requests[1]!.find((m) => m.role === 'assistant')?.parts).toEqual([ + { + type: 'dynamic-tool', + toolName: 'deleteFile', + toolCallId: 'c-1', + input: { path: 'a.txt' }, + state: 'approval-responded', + approval: { id: 'ap-1', approved }, + }, + ]) + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('All done.'), + ) + expect(client.getInterrupts()).toEqual([]) + return client + } + + it('sends the approval and shows the tool output', async () => { + const client = await run(true, (c) => { + const [interrupt] = c.getInterrupts() + if (interrupt?.kind !== 'tool-approval') throw new Error('not bound') + interrupt.resolveInterrupt(true) + }) + const parts = client.getMessages().flatMap((m) => m.parts) + expect(parts).toContainEqual( + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'c-1', + content: '{"deleted":true}', + }), + ) + }) + + it('sends the denial and marks the tool result as denied', async () => { + const client = await run(false, (c) => c.resolveInterrupts(false)) + const parts = client.getMessages().flatMap((m) => m.parts) + expect(parts).toContainEqual( + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'c-1', + state: 'error', + outcome: 'denied', + }), + ) + }) + + it('falls back to a generic interrupt when the adapter has no tools', async () => { + const client = await run( + true, + (c) => { + const [interrupt] = c.getInterrupts() + if (interrupt?.kind !== 'generic') throw new Error('expected generic') + interrupt.resolveInterrupt({ approved: true }) + }, + false, + ) + expect(client.getMessages().flatMap((m) => m.parts)).toContainEqual( + expect.objectContaining({ type: 'tool-result', toolCallId: 'c-1' }), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/client-extras.test.ts b/packages/ai-ui-message-stream/tests/client-extras.test.ts new file mode 100644 index 0000000000..ec26bb2704 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/client-extras.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType } from '@tanstack/ai' +import { ChatClient } from '@tanstack/ai-client' +import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import { convertToModelMessages } from 'ai' +import { z } from 'zod' +import { + fromAISDKMessages, + fromChatTransport, + fromUIMessageStream, +} from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +/** A transport that answers request N with `turns[N]`, after `gate` if set. */ +function fakeTransport( + turns: Array>, + gate?: Promise, +) { + const requests: Array> = [] + const transport: ChatTransport = { + async sendMessages({ messages }) { + requests.push(messages) + const turn = turns[requests.length - 1] ?? [] + if (requests.length === 1) await gate + return chunkStream(turn) + }, + reconnectToStream: () => Promise.resolve(null), + } + return { requests, transport } +} + +/** One AI SDK reply with message id `messageId`, streaming `deltas`. */ +function textTurn( + messageId: string, + ...deltas: Array +): Array { + return [ + { type: 'start', messageId }, + { type: 'text-start', id: '0' }, + ...deltas.map( + (delta): UIMessageChunk => ({ + type: 'text-delta', + id: '0', + delta, + }), + ), + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ] +} + +const recipe = z.object({ name: z.string(), steps: z.array(z.string()) }) + +async function collect( + list: Array, + outputSchema?: typeof recipe, +) { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(list), { + threadId: 't', + runId: 'r', + outputSchema, + })) + out.push(event) + for (const event of out) EventSchemas.parse(event) + return out +} + +describe('AI SDK server, TanStack client: client features', () => { + it('shows an Output.object answer as a typed structured-output part', async () => { + const { transport } = fakeTransport([ + textTurn('a-1', '{"name":"Pan', 'cakes","steps":["Mix",', '"Fry"]}'), + ]) + const partials: Array = [] + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport, { outputSchema: recipe }), + onMessagesChange: (messages) => { + const part = messages + .at(-1) + ?.parts.find((p) => p.type === 'structured-output') + if (part?.status === 'streaming') partials.push(part.partial) + }, + }) + + await client.sendMessage('A recipe') + expect(partials).toContainEqual({ name: 'Pan' }) + expect(partials).toContainEqual({ name: 'Pancakes', steps: ['Mix'] }) + const assistant = client.getMessages().at(-1) + expect(assistant?.parts).toEqual([ + { + type: 'structured-output', + status: 'complete', + raw: '{"name":"Pancakes","steps":["Mix","Fry"]}', + data: { name: 'Pancakes', steps: ['Mix', 'Fry'] }, + partial: { name: 'Pancakes', steps: ['Mix', 'Fry'] }, + }, + ]) + }) + + it('wraps the answer text in structured-output events', async () => { + const out = await collect( + textTurn('a-1', '{"name":"A","steps":[]}'), + recipe, + ) + expect( + out.map((e) => (e.type === EventType.CUSTOM ? e.name : e.type)), + ).toEqual([ + EventType.RUN_STARTED, + 'structured-output.start', + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + 'structured-output.complete', + EventType.RUN_FINISHED, + ]) + expect( + out.find( + (e) => + e.type === EventType.CUSTOM && + e.name === 'structured-output.complete', + ), + ).toMatchObject({ + value: { object: { name: 'A', steps: [] }, messageId: 'a-1' }, + }) + }) + + it('ends with a RUN_ERROR when the answer fails the schema', async () => { + for (const text of ['{"name":1}', 'not json']) { + const out = await collect(textTurn('a-1', text), recipe) + expect(out.at(-1)).toMatchObject({ + type: EventType.RUN_ERROR, + code: 'structured-output-validation-failed', + }) + expect(out.some((e) => e.type === EventType.RUN_FINISHED)).toBe(false) + } + }) + + it('keeps plain text without outputSchema', async () => { + const out = await collect(textTurn('a-1', '{"name":"A"}')) + expect(out.some((e) => e.type === EventType.CUSTOM)).toBe(false) + }) + + it('queues a message sent while a reply streams and sends it after', async () => { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const { requests, transport } = fakeTransport( + [textTurn('a-1', 'First.'), textTurn('a-2', 'Second.')], + gate, + ) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + }) + + const first = client.sendMessage('one') + await vi.waitFor(() => expect(client.getIsLoading()).toBe(true)) + await client.sendMessage('two') + expect(client.getQueue().map((q) => q.content)).toEqual(['two']) + expect(requests).toHaveLength(1) + + release() + await first + await vi.waitFor(() => expect(requests).toHaveLength(2)) + await vi.waitFor(() => expect(client.getIsLoading()).toBe(false)) + expect(client.getQueue()).toEqual([]) + expect( + requests[1]!.map((m) => + m.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join(''), + ), + ).toEqual(['one', 'First.', 'two']) + }) + + it('emits devtools events for a bridged run', async () => { + const { transport } = fakeTransport([textTurn('a-1', 'Hi.')]) + // The bridge useChat installs, as the framework hooks pass it. + let bridge: ChatDevtoolsBridge | undefined + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + devtoolsBridgeFactory: (options) => + (bridge = createChatDevtoolsBridge(options)), + }) + if (!bridge) throw new Error('expected a devtools bridge') + const messageSent = vi.spyOn(bridge.events, 'messageSent') + const textUpdated = vi.spyOn(bridge.events, 'textUpdated') + const loadingChanged = vi.spyOn(bridge.events, 'loadingChanged') + + await client.sendMessage('Hello') + expect(messageSent).toHaveBeenCalledWith(expect.any(String), 'Hello') + expect(textUpdated).toHaveBeenCalledWith(expect.any(String), 'a-1', 'Hi.') + expect(loadingChanged.mock.calls.map(([loading]) => loading)).toEqual([ + true, + false, + ]) + }) + + it('loads saved AI SDK history and sends it back unchanged', async () => { + const saved: Array = [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', text: 'Weather?' }] }, + { + id: 'a-1', + role: 'assistant', + // As AI SDK useChat saves a two-step answer. + parts: [ + { type: 'step-start' }, + { + type: 'dynamic-tool', + toolName: 'weather', + toolCallId: 'c-1', + input: { city: 'Paris' }, + state: 'output-available', + output: { temp: 20 }, + }, + { type: 'step-start' }, + { type: 'text', text: 'It is 20 degrees.' }, + ], + }, + ] + const { requests, transport } = fakeTransport([textTurn('a-3', 'Yes.')]) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + initialMessages: fromAISDKMessages(saved), + }) + expect(client.getMessages().map((m) => m.id)).toEqual(['u-1', 'a-1']) + + await client.sendMessage('Warm?') + // The leading step-start is dropped. The route's model messages match. + expect(await convertToModelMessages(requests[0]!.slice(0, 2))).toEqual( + await convertToModelMessages(saved), + ) + expect(requests[0]![1]!.parts.map((p) => p.type)).toEqual([ + 'dynamic-tool', + 'step-start', + 'text', + ]) + expect(requests[0]![2]).toMatchObject({ + role: 'user', + parts: [{ type: 'text', text: 'Warm?' }], + }) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/client-tools.test.ts b/packages/ai-ui-message-stream/tests/client-tools.test.ts new file mode 100644 index 0000000000..1cda39a063 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/client-tools.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { convertToModelMessages, streamText, tool } from 'ai' +import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test' +import { z } from 'zod' +import { clientToolsFromAISDKBody, fromChatTransport } from '../src' +import type { ChatTransport, UIMessage } from 'ai' + +const usage = { + inputTokens: { + total: 1, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 1, text: undefined, reasoning: undefined }, +} + +describe('client tools over fromChatTransport', () => { + it('declares the client tools to an AI SDK route, which stops at the call', async () => { + const model = new MockLanguageModelV4({ + doStream: [ + { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getLocation', + input: '{"precise":true}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: undefined }, + usage, + }, + ]), + }, + { + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'You are in Sydney.' }, + { type: 'text-end', id: 't' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: undefined }, + usage, + }, + ]), + }, + ], + }) + const bodies: Array = [] + // The AI SDK route, called in process. + const transport: ChatTransport = { + async sendMessages({ messages, body }) { + bodies.push(body) + const result = streamText({ + model, + messages: await convertToModelMessages(messages), + tools: clientToolsFromAISDKBody(body, {}), + }) + return result.toUIMessageStream() + }, + reconnectToStream: () => Promise.resolve(null), + } + const getLocation = vi.fn(() => ({ city: 'Sydney' })) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ + toolDefinition({ + name: 'getLocation', + description: 'Get the user location', + inputSchema: z.object({ precise: z.boolean() }), + }).client(getLocation), + ], + }) + + await client.sendMessage('Where am I?') + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Sydney.'), + ) + + expect(bodies[0]).toMatchObject({ + clientTools: [ + { + name: 'getLocation', + description: 'Get the user location', + parameters: { type: 'object' }, + }, + ], + }) + // The route offered the tool to the model and did not run it. + expect(model.doStreamCalls[0]?.tools).toMatchObject([ + { type: 'function', name: 'getLocation' }, + ]) + expect(getLocation).toHaveBeenCalledWith( + { precise: true }, + expect.anything(), + ) + // The second request carried the client's output to the model. + expect(JSON.stringify(model.doStreamCalls[1]?.prompt)).toContain('Sydney') + }) + + it('lets a server tool win a name clash and rejects a bad declaration', () => { + const server = tool({ + description: 'Server weather', + inputSchema: z.object({}), + execute: () => 'sunny', + }) + const declared = (name: string) => ({ + name, + description: 'Client', + parameters: { type: 'object' }, + }) + const tools = clientToolsFromAISDKBody( + { clientTools: [declared('weather'), declared('__proto__')] }, + { weather: server }, + ) + expect(tools.weather).toBe(server) + expect(Object.keys(tools).sort()).toEqual(['__proto__', 'weather']) + expect(Object.getPrototypeOf(tools)).toBe(Object.prototype) + + expect(clientToolsFromAISDKBody({ id: 'x' }, { weather: server })).toEqual({ + weather: server, + }) + expect(() => clientToolsFromAISDKBody({ clientTools: 'all' }, {})).toThrow() + expect(() => + clientToolsFromAISDKBody({ clientTools: [{ name: 'x' }] }, {}), + ).toThrow() + }) +}) diff --git a/packages/ai-ui-message-stream/tests/data-parts.test.ts b/packages/ai-ui-message-stream/tests/data-parts.test.ts new file mode 100644 index 0000000000..5c9f5af8ba --- /dev/null +++ b/packages/ai-ui-message-stream/tests/data-parts.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType } from '@tanstack/ai' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { fromChatTransport } from '../src' +import { DataParts, dataChunkEvent, isDataChunk } from '../src/data-parts' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +/** The last AI SDK message built from these events' data chunks. */ +async function readMessage(list: Array): Promise { + const parts = new DataParts() + const chunks: Array = [ + { type: 'start', messageId: 'm' }, + ...list.flatMap((event) => parts.chunks(event)), + { type: 'finish' }, + ] + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }) + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) last = message + if (!last) throw new Error('no message') + return last +} + +describe('DataParts (TanStack server, AI SDK client)', () => { + it('replaces an activity part in place on each delta', async () => { + const message = await readMessage([ + { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'plan-1', + activityType: 'plan', + content: { steps: ['a'], done: false }, + }, + { + type: EventType.ACTIVITY_DELTA, + messageId: 'plan-1', + activityType: 'plan', + patch: [ + { op: 'add', path: '/steps/-', value: 'b' }, + { op: 'replace', path: '/done', value: true }, + ], + }, + // A failed patch keeps the last content. + { + type: EventType.ACTIVITY_DELTA, + messageId: 'plan-1', + activityType: 'plan', + patch: [{ op: 'remove', path: '/missing' }], + }, + // No snapshot for this id. + { + type: EventType.ACTIVITY_DELTA, + messageId: 'other', + activityType: 'plan', + patch: [], + }, + ]) + expect(message.parts).toEqual([ + { + type: 'data-plan', + id: 'plan-1', + data: { steps: ['a', 'b'], done: true }, + }, + ]) + }) + + it('keeps an activity when replace is false', async () => { + const snapshot = { + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'a', + activityType: 'plan', + content: { v: 1 }, + } as const + const message = await readMessage([ + snapshot, + { ...snapshot, content: { v: 2 }, replace: false }, + ]) + expect(message.parts).toEqual([ + { type: 'data-plan', id: 'a', data: { v: 1 } }, + ]) + }) + + it('sends the full shared state on snapshot and delta', async () => { + const message = await readMessage([ + { type: EventType.STATE_SNAPSHOT, snapshot: { count: 1, tags: ['x'] } }, + { + type: EventType.STATE_DELTA, + delta: [ + { op: 'test', path: '/tags', value: ['x'] }, + { op: 'copy', from: '/count', path: '/previous' }, + { op: 'replace', path: '/count', value: 2 }, + { op: 'move', from: '/tags/0', path: '/first' }, + ], + }, + ]) + expect(message.parts).toEqual([ + { + type: 'data-state', + id: 'state', + data: { count: 2, tags: [], previous: 1, first: 'x' }, + }, + ]) + }) + + it('shows a subagent card going from running to finished', async () => { + const parts = new DataParts() + const tagged = { subagentRunId: 'sub-1' } + const list: Array = [ + { + type: EventType.SUBAGENT_STARTED, + ...tagged, + name: 'researcher', + description: 'Finds facts', + parentToolCallId: 'call-1', + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'c1', + role: 'assistant', + ...tagged, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'c1', + delta: 'Found ', + ...tagged, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'c1', + delta: 'it.', + ...tagged, + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'c1', ...tagged }, + { type: EventType.SUBAGENT_FINISHED, ...tagged }, + ] + for (const event of list) EventSchemas.parse(event) + + const statuses = list.flatMap((event) => + parts + .chunks(event) + .map((chunk) => + 'data' in chunk && typeof chunk.data === 'object' && chunk.data + ? 'status' in chunk.data && chunk.data.status + : undefined, + ), + ) + expect(statuses).toEqual(['running', 'running', 'running', 'finished']) + + const message = await readMessage(list) + expect(message.parts).toEqual([ + { + type: 'data-subagent', + id: 'sub-1', + data: { + id: 'sub-1', + name: 'researcher', + description: 'Finds facts', + parentToolCallId: 'call-1', + status: 'finished', + text: 'Found it.', + }, + }, + ]) + }) + + it('marks a subagent card as errored or suspended', async () => { + const message = await readMessage([ + { type: EventType.SUBAGENT_STARTED, subagentRunId: 'a', name: 'a' }, + { + type: EventType.SUBAGENT_ERROR, + subagentRunId: 'a', + message: 'boom', + code: 'E1', + }, + // An error is final. + { type: EventType.SUBAGENT_FINISHED, subagentRunId: 'a' }, + { type: EventType.SUBAGENT_STARTED, subagentRunId: 'b', name: 'b' }, + { + type: EventType.SUBAGENT_FINISHED, + subagentRunId: 'b', + outcome: { type: 'suspended', interruptIds: ['i1'] }, + }, + ]) + expect(message.parts.map((p) => 'data' in p && p.data)).toEqual([ + { + id: 'a', + name: 'a', + status: 'error', + error: { message: 'boom', code: 'E1' }, + text: '', + }, + { + id: 'b', + name: 'b', + status: 'suspended', + interruptIds: ['i1'], + text: '', + }, + ]) + }) +}) + +describe('dataChunkEvent (AI SDK server, TanStack client)', () => { + it('maps a persistent data chunk with an id to an activity snapshot', () => { + const chunk: UIMessageChunk = { + type: 'data-weather', + id: 'w-1', + data: { city: 'Oslo' }, + } + if (!isDataChunk(chunk)) throw new Error('not a data chunk') + const event = dataChunkEvent(chunk) + EventSchemas.parse(event) + expect(event).toEqual({ + type: EventType.ACTIVITY_SNAPSHOT, + messageId: 'w-1', + activityType: 'weather', + content: { city: 'Oslo' }, + replace: true, + }) + }) + + it.each([ + { type: 'data-ping', id: 'p', data: { a: 1 }, transient: true }, + { type: 'data-ping', data: { a: 1 } }, + { type: 'data-ping', id: 'p', data: 'text' }, + { type: 'data-ping', id: 'p', data: [1, 2] }, + ])('keeps $type as a CUSTOM event: %o', (chunk) => { + if (!isDataChunk(chunk)) throw new Error('not a data chunk') + const event = dataChunkEvent(chunk) + EventSchemas.parse(event) + expect(event).toEqual({ + type: EventType.CUSTOM, + name: 'data-ping', + value: chunk.data, + }) + }) + + it('shows the activity in the TanStack client and replaces it by id', async () => { + const chunks: Array = [ + { type: 'start', messageId: 'a' }, + { type: 'text-start', id: '0' }, + { type: 'data-weather', id: 'w-1', data: { city: 'Oslo' } }, + { type: 'data-weather', id: 'w-1', data: { city: 'Bergen' } }, + { type: 'text-delta', id: '0', delta: 'Sunny.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ] + const transport: ChatTransport = { + sendMessages: () => + Promise.resolve( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }), + ), + reconnectToStream: () => Promise.resolve(null), + } + const client = new ChatClient({ + threadId: 't', + connection: fromChatTransport(transport), + }) + await client.sendMessage('weather?') + await vi.waitFor(() => + expect(client.getMessages().map((m) => [m.role, m.parts])).toEqual([ + ['user', [expect.objectContaining({ type: 'text' })]], + [ + 'assistant', + [expect.objectContaining({ type: 'text', content: 'Sunny.' })], + ], + [ + 'activity', + [ + { + type: 'activity', + activityType: 'weather', + content: { city: 'Bergen' }, + }, + ], + ], + ]), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/fidelity-client.test.ts b/packages/ai-ui-message-stream/tests/fidelity-client.test.ts new file mode 100644 index 0000000000..f5c777c971 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/fidelity-client.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { convertToModelMessages } from 'ai' +import { z } from 'zod' +import { fromChatTransport, fromUIMessageStream, toAISDKMessages } from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +/** A transport that answers request N with `turns[N]`. */ +function fakeTransport(turns: Array>) { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve(chunkStream(turns[requests.length - 1] ?? [])) + }, + reconnectToStream: () => Promise.resolve(null), + } + return { requests, transport } +} + +async function collect(list: Array) { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(list), { + threadId: 't', + runId: 'r', + })) + out.push(event) + return out +} + +describe('AI SDK server, TanStack client: fidelity', () => { + it('keeps provider metadata, provider-executed calls, steps and message metadata', async () => { + const { requests, transport } = fakeTransport([ + [ + { type: 'start', messageId: 'a-1', messageMetadata: { model: 'm-1' } }, + { type: 'start-step' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Searching.' }, + { type: 'text-end', id: '0' }, + { + type: 'tool-input-start', + toolCallId: 'ws-1', + toolName: 'web_search', + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-input-available', + toolCallId: 'ws-1', + toolName: 'web_search', + input: { query: 'tanstack' }, + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-output-available', + toolCallId: 'ws-1', + output: { results: [] }, + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws_result' } }, + }, + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'ping', + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + // Google sends the signature with the finished call. + providerMetadata: { google: { thoughtSignature: 'ts-1' } }, + }, + { type: 'finish-step' }, + { + type: 'finish', + finishReason: 'tool-calls', + messageMetadata: { usage: 5 }, + }, + ], + [ + { type: 'start', messageId: 'a-1' }, + { type: 'start-step' }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: 'Done.' }, + { type: 'text-end', id: '1' }, + { type: 'finish-step' }, + { type: 'finish', finishReason: 'stop' }, + ], + [{ type: 'start' }, { type: 'finish', finishReason: 'stop' }], + ]) + const ping = vi.fn(() => ({ pong: true })) + const webSearch = vi.fn(() => ({ results: ['wrong'] })) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ + toolDefinition({ + name: 'ping', + description: 'Ping', + inputSchema: z.object({}), + }).client(ping), + toolDefinition({ + name: 'web_search', + description: 'Search', + inputSchema: z.object({ query: z.string() }), + }).client(webSearch), + ], + }) + + await client.sendMessage('Search, then ping') + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Done.'), + ) + expect(ping).toHaveBeenCalledTimes(1) + // The provider ran the search. + expect(webSearch).not.toHaveBeenCalled() + const assistant = client.getMessages().find((m) => m.role === 'assistant') + expect(assistant?.metadata).toMatchObject({ + aiSdk: { model: 'm-1', usage: 5 }, + }) + expect( + assistant?.parts.find((p) => p.type === 'tool-call' && p.id === 'ws-1'), + ).toMatchObject({ metadata: { providerExecuted: true } }) + + // Request 2 sends the client tool output back. + expect(requests[1]!.find((m) => m.role === 'assistant')?.parts).toEqual([ + { type: 'text', text: 'Searching.' }, + { + type: 'dynamic-tool', + toolName: 'web_search', + toolCallId: 'ws-1', + input: { query: 'tanstack' }, + providerExecuted: true, + callProviderMetadata: { openai: { itemId: 'ws_call' } }, + resultProviderMetadata: { openai: { itemId: 'ws_result' } }, + state: 'output-available', + output: { results: [] }, + }, + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + input: {}, + callProviderMetadata: { google: { thoughtSignature: 'ts-1' } }, + state: 'output-available', + output: { pong: true }, + }, + ]) + + // Request 3: the answer after the tool result is the next step. + await client.sendMessage('Thanks') + await vi.waitFor(() => expect(requests).toHaveLength(3)) + const sent = requests[2]!.find((m) => m.role === 'assistant') + expect(sent?.metadata).toEqual({ model: 'm-1', usage: 5 }) + expect(sent?.parts.map((p) => p.type)).toEqual([ + 'text', + 'dynamic-tool', + 'dynamic-tool', + 'step-start', + 'text', + ]) + expect(await convertToModelMessages(requests[2]!)).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Search, then ping' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { + type: 'tool-call', + toolCallId: 'ws-1', + toolName: 'web_search', + input: { query: 'tanstack' }, + providerExecuted: true, + providerOptions: { openai: { itemId: 'ws_call' } }, + }, + { + type: 'tool-result', + toolCallId: 'ws-1', + toolName: 'web_search', + output: { type: 'json', value: { results: [] } }, + providerOptions: { openai: { itemId: 'ws_result' } }, + }, + { + type: 'tool-call', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + providerExecuted: undefined, + providerOptions: { google: { thoughtSignature: 'ts-1' } }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c-1', + toolName: 'ping', + output: { type: 'json', value: { pong: true } }, + providerOptions: { google: { thoughtSignature: 'ts-1' } }, + }, + ], + }, + { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }, + { role: 'user', content: [{ type: 'text', text: 'Thanks' }] }, + ]) + }) + + it('sends the approval signature back and skips automatic approvals', async () => { + const { requests, transport } = fakeTransport([ + [ + { type: 'start', messageId: 'a-1' }, + { + type: 'tool-input-available', + toolCallId: 'c-auto', + toolName: 'readFile', + input: { path: 'a.txt' }, + }, + // The route approved this call itself and ran it. + { + type: 'tool-approval-request', + approvalId: 'ap-auto', + toolCallId: 'c-auto', + isAutomatic: true, + }, + { + type: 'tool-approval-response', + approvalId: 'ap-auto', + approved: true, + }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'deleteFile', + input: { path: 'a.txt' }, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + reason: 'Deletes data', + signature: 'sig-1', + }, + { + type: 'tool-output-available', + toolCallId: 'c-auto', + output: 'hello', + }, + { type: 'finish', finishReason: 'tool-calls' }, + ], + [{ type: 'start' }, { type: 'finish', finishReason: 'stop' }], + ]) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + }) + + await client.sendMessage('Read and delete a.txt') + const interrupts = client.getInterrupts() + expect(interrupts.map((i) => i.id)).toEqual(['ap-1']) + const [interrupt] = interrupts + if (interrupt?.kind !== 'generic') throw new Error('expected generic') + interrupt.resolveInterrupt({ approved: true }) + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + const sent = requests[1]!.find((m) => m.role === 'assistant') + expect(sent?.parts).toEqual([ + { + type: 'dynamic-tool', + toolName: 'readFile', + toolCallId: 'c-auto', + input: { path: 'a.txt' }, + state: 'output-available', + output: 'hello', + }, + { + type: 'dynamic-tool', + toolName: 'deleteFile', + toolCallId: 'c-1', + input: { path: 'a.txt' }, + state: 'approval-responded', + approval: { + id: 'ap-1', + approved: true, + requestReason: 'Deletes data', + signature: 'sig-1', + }, + }, + ]) + expect(await convertToModelMessages(requests[1]!)).toContainEqual( + expect.objectContaining({ + role: 'assistant', + content: expect.arrayContaining([ + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + isAutomatic: undefined, + reason: 'Deletes data', + signature: 'sig-1', + }, + ]), + }), + ) + }) + + it('sends a text-encoded tool input as JSON once', async () => { + const out = await collect([ + { + type: 'tool-input-start', + toolCallId: 'c-1', + toolName: 'patch', + inputEncoding: 'text', + }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '*** ' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: 'diff' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'patch', + input: '*** diff', + }, + ]) + expect( + out.flatMap((e) => + e.type === EventType.TOOL_CALL_ARGS ? [e.delta] : [], + ), + ).toEqual(['"*** diff"']) + }) + + it('sends sources, files and citations as CUSTOM events', async () => { + const out = await collect([ + { type: 'source-url', sourceId: 's-1', url: 'https://tanstack.com' }, + { + type: 'source-document', + sourceId: 's-2', + mediaType: 'application/pdf', + title: 'Spec', + }, + { + type: 'file', + url: 'data:image/png;base64,AA==', + mediaType: 'image/png', + }, + { type: 'text-start', id: '0' }, + { + type: 'text-end', + id: '0', + citations: [ + { + source: { + type: 'source', + sourceType: 'url', + id: 's-1', + url: 'https://tanstack.com', + }, + citedText: 'TanStack', + }, + ], + }, + ]) + expect( + out.flatMap((e) => + e.type === EventType.CUSTOM ? [{ name: e.name, value: e.value }] : [], + ), + ).toEqual([ + { + name: 'source-url', + value: { sourceId: 's-1', url: 'https://tanstack.com' }, + }, + { + name: 'source-document', + value: { sourceId: 's-2', mediaType: 'application/pdf', title: 'Spec' }, + }, + { + name: 'file', + value: { url: 'data:image/png;base64,AA==', mediaType: 'image/png' }, + }, + { + name: 'citations', + value: [expect.objectContaining({ citedText: 'TanStack' })], + }, + ]) + }) +}) + +describe('toAISDKMessages', () => { + it('drops system messages and sends a structured answer as text', () => { + expect( + toAISDKMessages([ + { + id: 's', + role: 'system', + parts: [{ type: 'text', content: 'Be brief.' }], + }, + { + id: 'a', + role: 'assistant', + parts: [ + { + type: 'structured-output', + status: 'complete', + raw: '{"ok":true}', + data: { ok: true }, + }, + ], + }, + ]), + ).toEqual([ + { + id: 'a', + role: 'assistant', + parts: [{ type: 'text', text: '{"ok":true}' }], + }, + ]) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/fidelity-server.test.ts b/packages/ai-ui-message-stream/tests/fidelity-server.test.ts new file mode 100644 index 0000000000..8c2cba7f39 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/fidelity-server.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest' +import { EventType, chat, convertMessagesToModelMessages } from '@tanstack/ai' +import { toolDefinition } from '@tanstack/ai/client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { fromAISDKMessages, toUIMessageStream } from '../src' +import type { + AdapterYieldChunk, + AnyTextAdapter, + ModelMessage, + StreamChunk, +} from '@tanstack/ai' +import type { UIMessage } from 'ai' + +type Step = ( + ids: { runId: string; threadId: string; messageId: string }, + messages: Array, +) => Array + +/** A text adapter that plays one step per model call and keeps each request. */ +function scriptedAdapter(steps: Array) { + const requests: Array> = [] + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'scripted', + model: 'scripted', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: {}, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + async *chatStream(options) { + requests.push(options.messages) + const step = steps[requests.length - 1] + if (!step) throw new Error('No more steps') + const runId = options.runId ?? 'run' + const threadId = options.threadId ?? 'thread' + yield { type: EventType.RUN_STARTED, runId, threadId, model: 'scripted' } + yield* step( + { runId, threadId, messageId: `${runId}-${requests.length}` }, + options.messages, + ) + }, + structuredOutput: () => Promise.resolve({ data: {}, rawText: '{}' }), + } + return { adapter, requests } +} + +// Gemini: a grounded search the provider ran, then a function call that +// carries a thought signature the next request must send back. +const toolStep: Step = ({ runId, threadId, messageId }) => [ + { + type: EventType.TOOL_CALL_START, + toolCallId: 'search-1', + toolCallName: 'google_search', + parentMessageId: messageId, + metadata: { + providerExecuted: true, + sources: [{ url: 'https://weather.example', title: 'Weather' }], + gemini: { groundingMetadata: { queries: ['nyc weather'] } }, + }, + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'search-1', input: {} }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + parentMessageId: messageId, + metadata: { thoughtSignature: 'sig-gemini' }, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"city":"NYC"}', + }, + { + type: EventType.TOOL_CALL_END, + toolCallId: 'call-1', + input: { city: 'NYC' }, + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + model: 'scripted', + finishReason: 'tool_calls', + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + }, +] + +function textStep(text: string): Step { + return ({ runId, threadId, messageId }) => [ + { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + model: 'scripted', + }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: text }, + { type: EventType.TEXT_MESSAGE_END, messageId }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + model: 'scripted', + finishReason: 'stop', + responseId: `resp-${text}`, + usage: { promptTokens: 4, completionTokens: 5, totalTokens: 9 }, + metadata: { + tanstack: { + responseItems: [{ id: `msg-${text}` }], + reasoningItem: true, + }, + }, + }, + ] +} + +const weather = toolDefinition({ + name: 'weather', + description: 'Weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(() => ({ temp: 72 })) + +const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather in NYC?' }], +} + +async function aiSdkMessage( + stream: AsyncIterable, +): Promise { + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ + stream: toUIMessageStream(stream), + })) + last = message + return last! +} + +describe('TanStack server, AI SDK client: fidelity', () => { + it('keeps tool-call metadata, provider-executed calls, sources, steps and message metadata', async () => { + const { adapter, requests } = scriptedAdapter([ + toolStep, + textStep('Sunny.'), + textStep('Again.'), + ]) + const first = await aiSdkMessage( + chat({ + adapter, + threadId: 'chat-1', + messages: fromAISDKMessages([user]), + tools: [weather], + }), + ) + + expect(first.parts).toMatchObject([ + { type: 'step-start' }, + { + type: 'tool-google_search', + state: 'output-available', + providerExecuted: true, + output: [{ url: 'https://weather.example', title: 'Weather' }], + callProviderMetadata: { tanstack: { providerExecuted: true } }, + }, + { + type: 'source-url', + sourceId: 'search-1-source-0', + url: 'https://weather.example', + title: 'Weather', + }, + { + type: 'tool-weather', + state: 'output-available', + output: { temp: 72 }, + callProviderMetadata: { tanstack: { thoughtSignature: 'sig-gemini' } }, + }, + { type: 'step-start' }, + { type: 'text', text: 'Sunny.' }, + ]) + expect(first.metadata).toMatchObject({ + tanstack: { + model: 'scripted', + responseId: 'resp-Sunny.', + finishReason: 'stop', + responseItems: [{ id: 'msg-Sunny.' }], + reasoningItem: true, + tokenUsage: [{ totalTokens: 3 }, { totalTokens: 9 }], + }, + }) + + // The AI SDK client posts the history back. + const history = fromAISDKMessages([ + user, + first, + { id: 'u-2', role: 'user', parts: [{ type: 'text', text: 'Again?' }] }, + ]) + const assistant = convertMessagesToModelMessages(history).slice(1, -1) + expect(assistant).toMatchObject([ + { + role: 'assistant', + toolCalls: [ + { + id: 'search-1', + metadata: { providerExecuted: true, gemini: {} }, + }, + { + id: 'call-1', + function: { name: 'weather', arguments: '{"city":"NYC"}' }, + metadata: { thoughtSignature: 'sig-gemini' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call-1', content: '{"temp":72}' }, + { role: 'assistant', content: 'Sunny.' }, + ]) + // The response items name one model call, so a two-step message drops them. + expect(assistant[0]?.metadata?.tanstack).not.toHaveProperty('responseItems') + expect(assistant[0]?.metadata?.tanstack).toMatchObject({ + reasoningItem: true, + }) + + // chat() replays it: the signature goes to the provider, and the + // provider-executed call is not a pending call. + const second = await aiSdkMessage( + chat({ + adapter, + threadId: 'chat-1', + messages: history, + tools: [weather], + }), + ) + expect(second.parts).toMatchObject([ + { type: 'step-start' }, + { type: 'text', text: 'Again.' }, + ]) + const replayed = requests[2]! + expect(replayed.filter((m) => m.role === 'tool')).toHaveLength(1) + expect( + replayed.flatMap((m) => m.toolCalls ?? []).map((c) => c.metadata), + ).toMatchObject([ + { providerExecuted: true }, + { thoughtSignature: 'sig-gemini' }, + ]) + }) + + it('keeps the response items of a one-step message', async () => { + const { adapter } = scriptedAdapter([textStep('Hi.')]) + const message = await aiSdkMessage( + chat({ adapter, threadId: 't', messages: fromAISDKMessages([user]) }), + ) + const [, assistant] = fromAISDKMessages([user, message]) + expect(assistant?.metadata).toMatchObject({ + tanstack: { responseItems: [{ id: 'msg-Hi.' }], responseId: 'resp-Hi.' }, + }) + }) + + it('adds a tool-call signature that comes as a REASONING_ENCRYPTED_VALUE', async () => { + async function* events(): AsyncGenerator { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + } + yield { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'tool-call', + entityId: 'call-1', + encryptedValue: 'sig-late', + } + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{}', + } + yield { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' } + } + const message = await aiSdkMessage(events()) + const [assistant] = fromAISDKMessages([message]) + expect(assistant?.parts).toMatchObject([ + { + type: 'tool-call', + id: 'call-1', + metadata: { thoughtSignature: 'sig-late' }, + }, + ]) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/reconnect.test.ts b/packages/ai-ui-message-stream/tests/reconnect.test.ts new file mode 100644 index 0000000000..39b0ca6291 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/reconnect.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { StreamReconnectLimitError } from '@tanstack/ai-client' +import { fromChatTransport } from '../src' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +const REPLY: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'One ' }, + { type: 'text-delta', id: 't', delta: 'two ' }, + { type: 'text-delta', id: 't', delta: 'three.' }, + { type: 'text-end', id: 't' }, + { type: 'finish', finishReason: 'stop' }, +] + +/** The reply from the start, failing after `dropAfter` chunks. */ +function replay(dropAfter = Infinity): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index === dropAfter) controller.error(new TypeError('network error')) + else if (index === REPLY.length) controller.close() + else controller.enqueue(REPLY[index++]!) + }, + }) +} + +async function run( + drops: Array, + reconnect: { maxAttempts?: number } = {}, +): Promise<{ text: string; reconnects: number }> { + let reconnects = 0 + const transport: ChatTransport = { + resumeStreamIsReplay: true, + sendMessages: () => Promise.resolve(replay(drops[0])), + reconnectToStream: () => { + reconnects++ + return Promise.resolve(replay(drops[reconnects])) + }, + } + const connection = fromChatTransport(transport, { + reconnect: { initialDelayMs: 1, ...reconnect }, + }) + let text = '' + for await (const event of connection.connect([], undefined, undefined, { + threadId: 'chat-1', + runId: 'run-1', + })) { + if (event.type === 'TEXT_MESSAGE_CONTENT') text += event.delta + } + return { text, reconnects } +} + +describe('fromChatTransport reconnect', () => { + it('reconnects with backoff and skips the replayed prefix', async () => { + // Drops after 3 chunks, then the first replay drops after 4. + const result = await run([3, 4]) + expect(result).toEqual({ text: 'One two three.', reconnects: 2 }) + }) + + it('stops at the limit when reconnects make no progress', async () => { + // Every replay drops before it passes the chunks already delivered. + await expect(run([3, 2, 2, 2], { maxAttempts: 2 })).rejects.toBeInstanceOf( + StreamReconnectLimitError, + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tests/resume.test.ts b/packages/ai-ui-message-stream/tests/resume.test.ts new file mode 100644 index 0000000000..53bcaac9d8 --- /dev/null +++ b/packages/ai-ui-message-stream/tests/resume.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { chat, memoryStream } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { ChatClient } from '@tanstack/ai-client' +import { DefaultChatTransport, readUIMessageStream } from 'ai' +import { + fromAISDKMessages, + fromChatTransport, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '../src' +import type { ChatPersistedState } from '@tanstack/ai-client' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +const REPLY: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'text-start', id: 't' }, + { type: 'text-delta', id: 't', delta: 'Hel' }, + { type: 'text-delta', id: 't', delta: 'lo.' }, + { type: 'text-end', id: 't' }, + { type: 'finish', finishReason: 'stop' }, +] + +/** A stream of `list` that fails with a dropped connection after `dropAfter` chunks. */ +function chunkStream( + list: Array, + dropAfter = Infinity, +): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index === dropAfter) controller.error(new TypeError('network error')) + else if (index === list.length) controller.close() + else controller.enqueue(list[index++]!) + }, + }) +} + +function fakeTransport( + reconnect: () => ReadableStream | null, +): ChatTransport & { reconnects: Array } { + const reconnects: Array = [] + return { + resumeStreamIsReplay: true, + reconnects, + sendMessages: () => Promise.resolve(chunkStream(REPLY, 3)), + reconnectToStream: ({ chatId }) => { + reconnects.push(chatId) + return Promise.resolve(reconnect()) + }, + } +} + +function assistantText(client: ChatClient): string { + const last = client.getMessages().at(-1) + if (last?.role !== 'assistant') return '' + return last.parts.map((p) => (p.type === 'text' ? p.content : '')).join('') +} + +describe('TanStack client, AI SDK route: resume', () => { + it('reconnects a dropped stream and skips the replayed prefix', async () => { + const transport = fakeTransport(() => chunkStream(REPLY)) + const client = new ChatClient({ + threadId: 'chat-1', + connection: fromChatTransport(transport), + }) + + await client.sendMessage('hi') + + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getMessages().map((m) => m.role)).toEqual([ + 'user', + 'assistant', + ]) + expect(assistantText(client)).toBe('Hello.') + expect(client.getError()).toBeUndefined() + }) + + it('rejoins the run after a page reload from the persisted resume pointer', async () => { + const transport = fakeTransport(() => chunkStream(REPLY)) + const saved: ChatPersistedState = { + messages: [ + { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', content: 'hi' }], + }, + { + id: 'a-1', + role: 'assistant', + parts: [{ type: 'text', content: 'Hel' }], + }, + ], + resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } }, + } + const client = new ChatClient({ + threadId: 'chat-1', + persistence: { + getItem: () => saved, + setItem: () => {}, + removeItem: () => {}, + }, + connection: fromChatTransport(transport, { threadId: 'chat-1' }), + }) + + client.attach() + + await vi.waitFor(() => expect(assistantText(client)).toBe('Hello.')) + await vi.waitFor(() => expect(client.getIsLoading()).toBe(false)) + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getMessages().map((m) => m.role)).toEqual([ + 'user', + 'assistant', + ]) + }) + + it('drops the resume pointer when the route has nothing to resume', async () => { + const transport = fakeTransport(() => null) + const writes: Array = [] + const client = new ChatClient({ + threadId: 'chat-1', + persistence: { + getItem: () => ({ + messages: [ + { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', content: 'hi' }], + }, + ], + resume: { resumeState: { threadId: 'chat-1', runId: 'run-1' } }, + }), + setItem: (_id, state) => void writes.push(state), + removeItem: () => {}, + }, + connection: fromChatTransport(transport, { threadId: 'chat-1' }), + }) + + client.attach() + + await vi.waitFor(() => { + expect(writes).not.toHaveLength(0) + expect(writes.at(-1)?.resume).toBeUndefined() + }) + expect(transport.reconnects).toEqual(['chat-1']) + expect(client.getError()).toBeUndefined() + }) +}) + +describe('AI SDK client, TanStack route: resume', () => { + function server() { + const fake = fakeText() + fake.setResponses([{ text: 'Resumed answer.' }]) + // The app records the chat's active run. With `@tanstack/ai-persistence` + // this is `stores.runs.findActiveRun(chatId)`. + const activeRuns = new Map() + + async function POST(request: Request): Promise { + const body: { id: string; messages: Array } = + await request.json() + const runId = crypto.randomUUID() + activeRuns.set(body.id, runId) + return toResumableUIMessageStreamResponse( + chat({ + adapter: fake, + threadId: body.id, + runId, + messages: fromAISDKMessages(body.messages), + }), + memoryStream({ runId }), + ) + } + + // GET /api/chat/:id/stream + function GET(chatId: string): Response { + const runId = activeRuns.get(chatId) + return resumeUIMessageStreamResponse( + runId === undefined ? null : memoryStream({ runId }), + ) + } + + return new DefaultChatTransport({ + api: 'http://test/api/chat', + fetch: async (input, init) => { + const url = new URL(String(input)) + const match = /^\/api\/chat\/(.+)\/stream$/.exec(url.pathname) + if (match) return GET(decodeURIComponent(match[1]!)) + return POST(new Request(url, init)) + }, + }) + } + + async function lastMessage( + stream: ReadableStream, + ): Promise { + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) last = message + return last + } + + it('replays the whole run on the resume GET after the POST is dropped', async () => { + const transport = server() + const posted = await transport.sendMessages({ + trigger: 'submit-message', + chatId: 'chat-1', + messageId: undefined, + messages: [ + { id: 'u-1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }, + ], + abortSignal: undefined, + }) + // The page reloads: the POST response is dropped after its first chunk. + const reader = posted.getReader() + await reader.read() + await reader.cancel() + + const resumed = await transport.reconnectToStream({ chatId: 'chat-1' }) + expect(resumed).not.toBeNull() + const message = await lastMessage(resumed!) + + expect(message?.parts.filter((p) => p.type === 'text')).toMatchObject([ + { type: 'text', text: 'Resumed answer.' }, + ]) + expect(JSON.stringify(message)).not.toContain('run.accepted') + }) + + it('answers 204 (no stream) when the chat has no active run', async () => { + const transport = server() + expect(await transport.reconnectToStream({ chatId: 'other' })).toBeNull() + }) +}) diff --git a/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts new file mode 100644 index 0000000000..0e96d1047c --- /dev/null +++ b/packages/ai-ui-message-stream/tests/ui-message-stream.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventSchemas } from '@ag-ui/core/schemas' +import { EventType, chat, convertMessagesToModelMessages } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { toolDefinition } from '@tanstack/ai/client' +import { ChatClient } from '@tanstack/ai-client' +import { readUIMessageStream } from 'ai' +import { z } from 'zod' +import { + fromAISDKMessages, + fromChatTransport, + fromUIMessageStream, + toUIMessageStream, +} from '../src' +import type { StreamChunk } from '@tanstack/ai' +import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' + +async function* events(list: Array): AsyncGenerator { + yield* list +} + +function chunkStream( + list: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of list) controller.enqueue(chunk) + controller.close() + }, + }) +} + +describe('TanStack server, AI SDK client', () => { + it('builds the AI SDK message and replays it into chat()', async () => { + const stream = toUIMessageStream( + events([ + { type: EventType.RUN_STARTED, threadId: 't', runId: 'r' }, + { + type: EventType.REASONING_MESSAGE_START, + messageId: 'think-1', + role: 'reasoning', + }, + { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: 'think-1', + delta: 'Plan.', + }, + { + type: EventType.REASONING_ENCRYPTED_VALUE, + subtype: 'message', + entityId: 'think-1', + encryptedValue: 'sig-1', + }, + { type: EventType.REASONING_MESSAGE_END, messageId: 'think-1' }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'msg-1', + role: 'assistant', + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + delta: 'Checking.', + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call-1', + toolCallName: 'weather', + parentMessageId: 'msg-1', + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call-1', + delta: '{"city":"NYC"}', + }, + { type: EventType.TOOL_CALL_END, toolCallId: 'call-1' }, + { + type: EventType.TOOL_CALL_RESULT, + messageId: 'res-1', + toolCallId: 'call-1', + content: '{"temp":72}', + role: 'tool', + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'msg-1', + role: 'assistant', + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + delta: 'It is 72F.', + }, + { type: EventType.TEXT_MESSAGE_END, messageId: 'msg-1' }, + { + type: EventType.RUN_FINISHED, + threadId: 't', + runId: 'r', + outcome: { type: 'success' }, + metadata: { tanstack: { finishReason: 'stop' } }, + }, + ]), + ) + + let assistant: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) { + assistant = message + } + + expect(assistant?.parts).toMatchObject([ + { type: 'step-start' }, + { + type: 'reasoning', + text: 'Plan.', + providerMetadata: { tanstack: { signature: 'sig-1' } }, + }, + { type: 'text', text: 'Checking.' }, + { + type: 'tool-weather', + toolCallId: 'call-1', + state: 'output-available', + input: { city: 'NYC' }, + output: { temp: 72 }, + }, + { type: 'text', text: 'It is 72F.' }, + ]) + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather in NYC?' }], + } + const history = convertMessagesToModelMessages( + fromAISDKMessages([user, assistant!]), + ) + expect(history).toMatchObject([ + { role: 'user', content: 'Weather in NYC?' }, + { + role: 'assistant', + content: 'Checking.', + thinking: [{ content: 'Plan.', signature: 'sig-1' }], + toolCalls: [ + { + id: 'call-1', + function: { name: 'weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + { role: 'tool', toolCallId: 'call-1', content: '{"temp":72}' }, + { role: 'assistant', content: 'It is 72F.' }, + ]) + }) + + it('serves real chat() output, then continues after an AI SDK client tool', async () => { + const fake = fakeText() + fake.setResponses([ + { + thinking: 'Plan.', + toolCalls: [{ name: 'weather', input: { city: 'NYC' } }], + }, + { toolCalls: [{ name: 'ask', input: { q: 'Units?' } }] }, + ({ request }) => ({ + text: `Answer: ${String(request.messages.at(-1)?.content)}`, + }), + ]) + const weather = toolDefinition({ + name: 'weather', + description: 'Weather for a city', + inputSchema: z.object({ city: z.string() }), + }).server(() => ({ temp: 72 })) + const ask = toolDefinition({ + name: 'ask', + description: 'Ask the user', + inputSchema: z.object({ q: z.string() }), + }) + + async function turn(messages: Array): Promise { + const stream = toUIMessageStream( + chat({ + adapter: fake, + threadId: 'chat-1', + messages: fromAISDKMessages(messages), + tools: [weather, ask], + }), + ) + let last: UIMessage | undefined + for await (const message of readUIMessageStream({ stream })) + last = message + return last! + } + + const user: UIMessage = { + id: 'u-1', + role: 'user', + parts: [{ type: 'text', text: 'Weather?' }], + } + const first = await turn([user]) + expect(first.parts).toMatchObject([ + { type: 'step-start' }, + { type: 'reasoning', text: 'Plan.' }, + { type: 'tool-weather', state: 'output-available', output: { temp: 72 } }, + { type: 'step-start' }, + { type: 'tool-ask', state: 'input-available', input: { q: 'Units?' } }, + ]) + + // The AI SDK client runs `ask` and posts the history with its output. + const answered: UIMessage = { + ...first, + parts: first.parts.map((part) => + part.type === 'tool-ask' && part.state === 'input-available' + ? { ...part, state: 'output-available', output: 'Fahrenheit' } + : part, + ), + } + const second = await turn([user, answered]) + expect(second.parts).toMatchObject([ + { type: 'step-start' }, + { type: 'text', text: 'Answer: Fahrenheit' }, + ]) + }) + + it('sends an AI SDK tool error back as an error result', () => { + const [assistant] = convertMessagesToModelMessages( + fromAISDKMessages([ + { + id: 'a-1', + role: 'assistant', + parts: [ + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + state: 'output-error', + input: {}, + errorText: 'offline', + }, + ], + }, + ]), + ).slice(1) + expect(assistant).toMatchObject({ + role: 'tool', + toolCallId: 'c-1', + error: 'offline', + }) + }) +}) + +describe('AI SDK server, TanStack client', () => { + const firstTurn: Array = [ + { type: 'start', messageId: 'a-1' }, + { type: 'start-step' }, + { type: 'reasoning-start', id: '0' }, + { type: 'reasoning-delta', id: '0', delta: 'Hmm.' }, + { + type: 'reasoning-end', + id: '0', + providerMetadata: { anthropic: { signature: 'sig' } }, + }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: 'Pinging.' }, + { type: 'text-end', id: '1' }, + { type: 'tool-input-start', toolCallId: 'c-1', toolName: 'ping' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '{}' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'ping', + input: {}, + }, + { type: 'finish-step' }, + { type: 'finish', finishReason: 'tool-calls' }, + ] + + it('emits valid AG-UI events', async () => { + const out: Array = [] + for await (const event of fromUIMessageStream(chunkStream(firstTurn), { + threadId: 't', + runId: 'r', + })) { + out.push(event) + } + for (const event of out) EventSchemas.parse(event) + expect(out.map((e) => e.type)).toEqual([ + 'RUN_STARTED', + 'STEP_STARTED', + 'REASONING_MESSAGE_START', + 'REASONING_MESSAGE_CONTENT', + 'REASONING_ENCRYPTED_VALUE', + 'REASONING_MESSAGE_END', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'TOOL_CALL_START', + 'TOOL_CALL_ARGS', + 'TOOL_CALL_END', + 'STEP_FINISHED', + 'RUN_FINISHED', + ]) + }) + + it('keeps the AG-UI tool call order when metadata comes late', async () => { + const out: Array = [] + for await (const event of fromUIMessageStream( + chunkStream([ + { type: 'start', messageId: 'a-1' }, + { type: 'tool-input-start', toolCallId: 'c-1', toolName: 'pay' }, + { type: 'tool-input-delta', toolCallId: 'c-1', inputTextDelta: '{}' }, + { + type: 'tool-input-available', + toolCallId: 'c-1', + toolName: 'pay', + input: {}, + }, + { + type: 'tool-approval-request', + approvalId: 'ap-1', + toolCallId: 'c-1', + signature: 'sig', + }, + { + type: 'tool-input-available', + toolCallId: 'c-2', + toolName: 'search', + input: {}, + providerExecuted: true, + }, + { + type: 'tool-output-available', + toolCallId: 'c-2', + output: 'found', + providerExecuted: true, + providerMetadata: { openai: { itemId: 'ws-1' } }, + }, + { type: 'finish' }, + ]), + { threadId: 't', runId: 'r' }, + )) { + out.push(event) + } + for (const event of out) EventSchemas.parse(event) + + // Per call: START, ARGS..., END, then RESULT. No ARGS after END. + for (const id of ['c-1', 'c-2']) { + const types = out + .filter((e) => 'toolCallId' in e && e.toolCallId === id) + .map((e): string => e.type) + const end = types.indexOf('TOOL_CALL_END') + expect(types[0]).toBe('TOOL_CALL_START') + expect(end).toBeGreaterThan(0) + expect(types.lastIndexOf('TOOL_CALL_ARGS')).toBeLessThan(end) + const result = types.indexOf('TOOL_CALL_RESULT') + if (result !== -1) expect(result).toBeGreaterThan(end) + } + }) + + it('runs a client tool and sends its output and reasoning back', async () => { + const requests: Array> = [] + const transport: ChatTransport = { + sendMessages({ messages }) { + requests.push(messages) + return Promise.resolve( + chunkStream( + requests.length === 1 + ? firstTurn + : [ + { type: 'start' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Got pong.' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: 'stop' }, + ], + ), + ) + }, + reconnectToStream: () => Promise.resolve(null), + } + const execute = vi.fn(() => ({ reply: 'pong' })) + const ping = toolDefinition({ + name: 'ping', + description: 'Reply with pong', + inputSchema: z.object({}), + }).client(execute) + const client = new ChatClient({ + threadId: 'thread-1', + connection: fromChatTransport(transport), + tools: [ping], + }) + + await client.sendMessage('ping me') + + await vi.waitFor(() => expect(requests).toHaveLength(2)) + expect(execute).toHaveBeenCalledTimes(1) + const sent = requests[1]!.find((m) => m.role === 'assistant') + expect(sent?.parts).toMatchObject([ + { + type: 'reasoning', + text: 'Hmm.', + providerMetadata: { anthropic: { signature: 'sig' } }, + }, + { type: 'text', text: 'Pinging.' }, + { + type: 'dynamic-tool', + toolName: 'ping', + toolCallId: 'c-1', + state: 'output-available', + output: { reply: 'pong' }, + }, + ]) + await vi.waitFor(() => + expect(JSON.stringify(client.getMessages())).toContain('Got pong.'), + ) + }) +}) diff --git a/packages/ai-ui-message-stream/tsconfig.json b/packages/ai-ui-message-stream/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-ui-message-stream/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-ui-message-stream/vite.config.ts b/packages/ai-ui-message-stream/vite.config.ts new file mode 100644 index 0000000000..11f5b20b70 --- /dev/null +++ b/packages/ai-ui-message-stream/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2f1b2686..1eb3fb4014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3703,6 +3703,31 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-ui-message-stream: + dependencies: + fast-json-patch: + specifier: ^3.1.1 + version: 3.1.1 + devDependencies: + '@ag-ui/core': + specifier: 1.0.0 + version: 1.0.0(zod@4.6.5) + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-client': + specifier: workspace:* + version: link:../ai-client + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.11) + ai: + specifier: 7.0.148 + version: 7.0.148(zod@4.6.5) + zod: + specifier: ^4.2.0 + version: 4.6.5 + packages/ai-utils: devDependencies: '@types/node': @@ -3745,7 +3770,7 @@ importers: devDependencies: '@google/genai': specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -3954,6 +3979,12 @@ importers: testing/e2e: dependencies: + '@ai-sdk/openai': + specifier: 4.0.99 + version: 4.0.99(zod@4.3.6) + '@ai-sdk/react': + specifier: 4.0.152 + version: 4.0.152(react@19.2.3)(zod@4.3.6) '@anthropic-ai/sdk': specifier: ^0.135.0 version: 0.135.0(zod@4.3.6) @@ -4080,6 +4111,9 @@ importers: '@tanstack/ai-typesafe': specifier: workspace:* version: link:../../packages/ai-typesafe + '@tanstack/ai-ui-message-stream': + specifier: workspace:* + version: link:../../packages/ai-ui-message-stream '@tanstack/ai-vercel-gateway': specifier: workspace:* version: link:../../packages/ai-vercel-gateway @@ -4104,6 +4138,9 @@ importers: '@tanstack/react-start': specifier: ^1.168.60 version: 1.168.60(crossws@0.4.6(srvx@0.11.22))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.9)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + ai: + specifier: 7.0.148 + version: 7.0.148(zod@4.3.6) arktype: specifier: ^2.1.28 version: 2.2.0 @@ -4351,6 +4388,40 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/gateway@4.0.121': + resolution: {integrity: sha512-cPNXvQ4B5OkmVAm98Dxe1fHdMthyqqqAG0HJx3D92NUa9FpcvkNxdAqiuZdeAhNAodFb9lBrslzzzEwxuVS53A==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mcp@2.0.81': + resolution: {integrity: sha512-2ZqfHLu1K93uPse+QWbcNjkznCtM6UHMoNWjUDpmM9kCr+y9bWcf5VWfNAWi4ziQoncZPJo2qUWeDIXgrqLsKg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@4.0.99': + resolution: {integrity: sha512-XRHUiXgxhVEe0smSp537lzCqMsOVg6jvGTWDbTZeGMTc+iHeOwgHgZMFBvOiNWXZ8n125Jta72m+64PtU1WNJA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.65': + resolution: {integrity: sha512-LCugcsP8oWmdeI3BASf4Fuo1wiC6Qx0YrefFssl7n12s+LV4DNi5RdwWQrtrB4oek3GGNQRN/h/NSwVZM2eHKw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.31': + resolution: {integrity: sha512-3wJnwVTFg8SwOyoZCv+ov7Re2GB+7IQnU5a5MYJACrtiWDDUHgqTBeeesq2MB2ddgojzL8hgWleltwpAgtUqfg==} + engines: {node: '>=22'} + + '@ai-sdk/react@4.0.152': + resolution: {integrity: sha512-2A4bttJJgDmdiCTXO6qM6J28q1C3ucAVAwbWzYuaQ4ZF1PUy7GwelNtMGMf2FoRl8krCeHYAdAhRnJlnfciKpQ==} + engines: {node: '>=22'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alcalzone/ansi-tokenize@0.3.1': resolution: {integrity: sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==} engines: {node: '>=18'} @@ -12182,6 +12253,9 @@ packages: '@webgpu/types@0.1.72': resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -12246,6 +12320,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@7.0.148: + resolution: {integrity: sha512-FPgfIAhz/FrlzcvqTN7pGGibahR5rD19NXZWX9WQ1tskTic+CN+jxvubpreyq8uN1maIEHPwvZmil/3IYN8FIw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -15225,6 +15305,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -18034,6 +18117,11 @@ packages: resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} engines: {node: '>=18'} + swr@2.5.1: + resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -18119,6 +18207,10 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -19490,10 +19582,76 @@ snapshots: optionalDependencies: zod: 4.2.1 + '@ag-ui/core@1.0.0(zod@4.6.5)': + optionalDependencies: + zod: 4.6.5 + '@agentclientprotocol/sdk@1.8.0(zod@4.6.5)': dependencies: zod: 4.6.5 + '@ai-sdk/gateway@4.0.121(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + '@vercel/oidc': 3.2.0 + zod: 4.3.6 + + '@ai-sdk/gateway@4.0.121(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/mcp@2.0.81(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + cross-spawn: 7.0.6 + pkce-challenge: 5.0.1 + zod: 4.3.6 + + '@ai-sdk/openai@4.0.99(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@5.0.65(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.3.6 + + '@ai-sdk/provider-utils@5.0.65(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.6.5 + + '@ai-sdk/provider@4.0.31': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@4.0.152(react@19.2.3)(zod@4.3.6)': + dependencies: + '@ai-sdk/mcp': 2.0.81(zod@4.3.6) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + ai: 7.0.148(zod@4.3.6) + react: 19.2.3 + swr: 2.5.1(react@19.2.3) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@alcalzone/ansi-tokenize@0.3.1': dependencies: ansi-styles: 6.2.3 @@ -22720,6 +22878,19 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.6.4 + ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: graphql: 16.14.2 @@ -23214,7 +23385,7 @@ snapshots: '@modelcontextprotocol/core@2.1.0': dependencies: - zod: 4.3.6 + zod: 4.6.5 '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.2.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@3.25.76)': dependencies: @@ -23297,6 +23468,31 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.8) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1)(supports-color@7.2.0) + hono: 4.13.8 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/server@2.1.0': dependencies: '@modelcontextprotocol/core': 2.1.0 @@ -29503,6 +29699,8 @@ snapshots: '@webgpu/types@0.1.72': {} + '@workflow/serde@4.1.0': {} + '@workflow/serde@4.1.0-beta.2': {} '@xmldom/xmldom@0.8.13': {} @@ -29551,6 +29749,20 @@ snapshots: agent-base@7.1.4: {} + ai@7.0.148(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 4.0.121(zod@4.3.6) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.3.6) + zod: 4.3.6 + + ai@7.0.148(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.121(zod@4.6.5) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.65(zod@4.6.5) + zod: 4.6.5 + ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -30322,7 +30534,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.29.0 + undici: 7.29.1 whatwg-mimetype: 4.0.0 chokidar@4.0.3: @@ -32957,6 +33169,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -37418,6 +37632,12 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' + swr@2.5.1(react@19.2.3): + dependencies: + dequal: 2.0.3 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + symbol-tree@3.2.4: {} synckit@0.11.13: @@ -37537,6 +37757,8 @@ snapshots: throat@5.0.0: {} + throttleit@2.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} diff --git a/skills/tanstack-ai-migration/SKILL.md b/skills/tanstack-ai-migration/SKILL.md index 165da6dde7..12528d384a 100644 --- a/skills/tanstack-ai-migration/SKILL.md +++ b/skills/tanstack-ai-migration/SKILL.md @@ -36,6 +36,24 @@ Read the guide, then load the target API's own skill with `npx @tanstack/intent@latest load @tanstack/ai#ai-core`. Do not port from memory: a half-remembered mapping produces code that type-checks in neither SDK. +To port one side at a time, `@tanstack/ai-ui-message-stream` connects the two +SDKs over the AI SDK UI message stream. It is a stop gap while porting, not a +place to stay: finish the port of the other side, then remove it. + +- Server first: keep AI SDK `useChat` and serve it from `chat()` with + `chat({ adapter, ...(await chatParamsFromAISDKRequest(request)) })` and + `toUIMessageStream`. + https://tanstack.com/ai/latest/docs/migration/ai-sdk-client-tanstack-server +- Client first: keep the `streamText` route and pass + `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })` + as the `connection` of TanStack `useChat`. On the route, + `clientToolsFromAISDKBody(body, serverTools)` reads the client's tools, so + they need no copy there. + https://tanstack.com/ai/latest/docs/migration/tanstack-client-ai-sdk-server + +Tool approvals, client tools, activity, subagents and stream resume cross the +bridge. Shared state goes from a TanStack server to an AI SDK client only. + ## Upgrading TanStack AI itself Start with the version the app has, and the current release: diff --git a/testing/e2e/fixtures/ai-sdk/approval.json b/testing/e2e/fixtures/ai-sdk/approval.json new file mode 100644 index 0000000000..37bab7bffc --- /dev/null +++ b/testing/e2e/fixtures/ai-sdk/approval.json @@ -0,0 +1,27 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[ai-sdk-approval] buy the stratocaster", + "hasToolResult": false + }, + "response": { + "toolCalls": [ + { + "name": "addToCart", + "arguments": "{\"guitarId\":\"1\",\"quantity\":1}" + } + ] + } + }, + { + "match": { + "userMessage": "[ai-sdk-approval] buy the stratocaster", + "hasToolResult": true + }, + "response": { + "content": "The Stratocaster is in your cart." + } + } + ] +} diff --git a/testing/e2e/package.json b/testing/e2e/package.json index ca1b4ed91f..d86be524b5 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -12,6 +12,8 @@ "postinstall": "playwright install chromium" }, "dependencies": { + "@ai-sdk/openai": "4.0.99", + "@ai-sdk/react": "4.0.152", "@anthropic-ai/sdk": "^0.135.0", "@copilotkit/aimock": "^1.34.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -54,6 +56,7 @@ "@tanstack/ai-skills": "workspace:*", "@tanstack/ai-solid": "workspace:*", "@tanstack/ai-typesafe": "workspace:*", + "@tanstack/ai-ui-message-stream": "workspace:*", "@tanstack/ai-vercel-gateway": "workspace:*", "@tanstack/ai-vertex": "workspace:*", "@tanstack/ai-worldlabs": "workspace:*", @@ -62,6 +65,7 @@ "@tanstack/react-ai-devtools": "workspace:*", "@tanstack/react-router": "^1.170.41", "@tanstack/react-start": "^1.168.60", + "ai": "7.0.148", "arktype": "^2.1.28", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 35cb311a76..de5a1682dc 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -11,6 +11,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ActivityTestRouteImport } from './routes/activity-test' +import { Route as AiSdkClientRouteImport } from './routes/ai-sdk-client' +import { Route as AiSdkResumeRouteImport } from './routes/ai-sdk-resume' +import { Route as AiSdkServerRouteImport } from './routes/ai-sdk-server' import { Route as ByokRouteImport } from './routes/byok' import { Route as CallOwnershipRouteImport } from './routes/call-ownership' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' @@ -52,6 +55,8 @@ import { Route as ProviderIndexRouteImport } from './routes/$provider/index' import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature' import { Route as ApiActivityTestRouteImport } from './routes/api.activity-test' import { Route as ApiAdapterInputModalitiesRouteImport } from './routes/api.adapter-input-modalities' +import { Route as ApiAiSdkClientRouteImport } from './routes/api.ai-sdk-client' +import { Route as ApiAiSdkServerRouteImport } from './routes/api.ai-sdk-server' import { Route as ApiAnthropicAuthWireRouteImport } from './routes/api.anthropic-auth-wire' import { Route as ApiAnthropicBugTestRouteImport } from './routes/api.anthropic-bug-test' import { Route as ApiAnthropicComputerToolsetWireRouteImport } from './routes/api.anthropic-computer-toolset-wire' @@ -199,11 +204,12 @@ import { Route as ApiVoiceRouteImport } from './routes/api.voice' import { Route as ApiWebMcpPageToolsRouteImport } from './routes/api.web-mcp-page-tools' import { Route as ApiWorldRouteImport } from './routes/api.world' import { Route as ApiWrapFetchWireRouteImport } from './routes/api.wrap-fetch-wire' +import { Route as ApiAiSdkResumeSplatRouteImport } from './routes/api.ai-sdk-resume.$' import { Route as ApiAudioStreamRouteImport } from './routes/api.audio.stream' -import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' -import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessHandoffSplatRouteImport } from './routes/api.harness-handoff.$' import { Route as ApiHarnessObservabilitySplatRouteImport } from './routes/api.harness-observability.$' +import { Route as ApiHarnessProtocolSplatRouteImport } from './routes/api.harness-protocol.$' +import { Route as ApiHarnessSecretAnswerSplatRouteImport } from './routes/api.harness-secret-answer.$' import { Route as ApiHarnessWaitsSplatRouteImport } from './routes/api.harness-waits.$' import { Route as ApiImageStreamRouteImport } from './routes/api.image.stream' import { Route as ApiTranscriptionStreamRouteImport } from './routes/api.transcription.stream' @@ -220,6 +226,21 @@ const ActivityTestRoute = ActivityTestRouteImport.update({ path: '/activity-test', getParentRoute: () => rootRouteImport, } as any) +const AiSdkClientRoute = AiSdkClientRouteImport.update({ + id: '/ai-sdk-client', + path: '/ai-sdk-client', + getParentRoute: () => rootRouteImport, +} as any) +const AiSdkResumeRoute = AiSdkResumeRouteImport.update({ + id: '/ai-sdk-resume', + path: '/ai-sdk-resume', + getParentRoute: () => rootRouteImport, +} as any) +const AiSdkServerRoute = AiSdkServerRouteImport.update({ + id: '/ai-sdk-server', + path: '/ai-sdk-server', + getParentRoute: () => rootRouteImport, +} as any) const ByokRoute = ByokRouteImport.update({ id: '/byok', path: '/byok', @@ -429,6 +450,16 @@ const ApiAdapterInputModalitiesRoute = path: '/api/adapter-input-modalities', getParentRoute: () => rootRouteImport, } as any) +const ApiAiSdkClientRoute = ApiAiSdkClientRouteImport.update({ + id: '/api/ai-sdk-client', + path: '/api/ai-sdk-client', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAiSdkServerRoute = ApiAiSdkServerRouteImport.update({ + id: '/api/ai-sdk-server', + path: '/api/ai-sdk-server', + getParentRoute: () => rootRouteImport, +} as any) const ApiAnthropicAuthWireRoute = ApiAnthropicAuthWireRouteImport.update({ id: '/api/anthropic-auth-wire', path: '/api/anthropic-auth-wire', @@ -1207,22 +1238,16 @@ const ApiWrapFetchWireRoute = ApiWrapFetchWireRouteImport.update({ path: '/api/wrap-fetch-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiAiSdkResumeSplatRoute = ApiAiSdkResumeSplatRouteImport.update({ + id: '/api/ai-sdk-resume/$', + path: '/api/ai-sdk-resume/$', + getParentRoute: () => rootRouteImport, +} as any) const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ id: '/stream', path: '/stream', getParentRoute: () => ApiAudioRoute, } as any) -const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ - id: '/api/harness-protocol/$', - path: '/api/harness-protocol/$', - getParentRoute: () => rootRouteImport, -} as any) -const ApiHarnessSecretAnswerSplatRoute = - ApiHarnessSecretAnswerSplatRouteImport.update({ - id: '/api/harness-secret-answer/$', - path: '/api/harness-secret-answer/$', - getParentRoute: () => rootRouteImport, - } as any) const ApiHarnessHandoffSplatRoute = ApiHarnessHandoffSplatRouteImport.update({ id: '/api/harness-handoff/$', path: '/api/harness-handoff/$', @@ -1234,6 +1259,17 @@ const ApiHarnessObservabilitySplatRoute = path: '/api/harness-observability/$', getParentRoute: () => rootRouteImport, } as any) +const ApiHarnessProtocolSplatRoute = ApiHarnessProtocolSplatRouteImport.update({ + id: '/api/harness-protocol/$', + path: '/api/harness-protocol/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHarnessSecretAnswerSplatRoute = + ApiHarnessSecretAnswerSplatRouteImport.update({ + id: '/api/harness-secret-answer/$', + path: '/api/harness-secret-answer/$', + getParentRoute: () => rootRouteImport, + } as any) const ApiHarnessWaitsSplatRoute = ApiHarnessWaitsSplatRouteImport.update({ id: '/api/harness-waits/$', path: '/api/harness-waits/$', @@ -1263,6 +1299,9 @@ const ApiVideoStreamRoute = ApiVideoStreamRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1303,6 +1342,8 @@ export interface FileRoutesByFullPath { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1451,11 +1492,12 @@ export interface FileRoutesByFullPath { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1465,6 +1507,9 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1505,6 +1550,8 @@ export interface FileRoutesByTo { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1653,11 +1700,12 @@ export interface FileRoutesByTo { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1668,6 +1716,9 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/activity-test': typeof ActivityTestRoute + '/ai-sdk-client': typeof AiSdkClientRoute + '/ai-sdk-resume': typeof AiSdkResumeRoute + '/ai-sdk-server': typeof AiSdkServerRoute '/byok': typeof ByokRoute '/call-ownership': typeof CallOwnershipRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute @@ -1708,6 +1759,8 @@ export interface FileRoutesById { '/$provider/$feature': typeof ProviderFeatureRoute '/api/activity-test': typeof ApiActivityTestRoute '/api/adapter-input-modalities': typeof ApiAdapterInputModalitiesRoute + '/api/ai-sdk-client': typeof ApiAiSdkClientRoute + '/api/ai-sdk-server': typeof ApiAiSdkServerRoute '/api/anthropic-auth-wire': typeof ApiAnthropicAuthWireRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-computer-toolset-wire': typeof ApiAnthropicComputerToolsetWireRoute @@ -1856,11 +1909,12 @@ export interface FileRoutesById { '/api/world': typeof ApiWorldRoute '/api/wrap-fetch-wire': typeof ApiWrapFetchWireRoute '/$provider/': typeof ProviderIndexRoute + '/api/ai-sdk-resume/$': typeof ApiAiSdkResumeSplatRoute '/api/audio/stream': typeof ApiAudioStreamRoute - '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute - '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-handoff/$': typeof ApiHarnessHandoffSplatRoute '/api/harness-observability/$': typeof ApiHarnessObservabilitySplatRoute + '/api/harness-protocol/$': typeof ApiHarnessProtocolSplatRoute + '/api/harness-secret-answer/$': typeof ApiHarnessSecretAnswerSplatRoute '/api/harness-waits/$': typeof ApiHarnessWaitsSplatRoute '/api/image/stream': typeof ApiImageStreamRoute '/api/transcription/stream': typeof ApiTranscriptionStreamRoute @@ -1872,6 +1926,9 @@ export interface FileRouteTypes { fullPaths: | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -1912,6 +1969,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2060,11 +2119,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider/' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2074,6 +2134,9 @@ export interface FileRouteTypes { to: | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -2114,6 +2177,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2262,11 +2327,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2276,6 +2342,9 @@ export interface FileRouteTypes { | '__root__' | '/' | '/activity-test' + | '/ai-sdk-client' + | '/ai-sdk-resume' + | '/ai-sdk-server' | '/byok' | '/call-ownership' | '/chat-client-default-bridge' @@ -2316,6 +2385,8 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/activity-test' | '/api/adapter-input-modalities' + | '/api/ai-sdk-client' + | '/api/ai-sdk-server' | '/api/anthropic-auth-wire' | '/api/anthropic-bug-test' | '/api/anthropic-computer-toolset-wire' @@ -2464,11 +2535,12 @@ export interface FileRouteTypes { | '/api/world' | '/api/wrap-fetch-wire' | '/$provider/' + | '/api/ai-sdk-resume/$' | '/api/audio/stream' - | '/api/harness-protocol/$' - | '/api/harness-secret-answer/$' | '/api/harness-handoff/$' | '/api/harness-observability/$' + | '/api/harness-protocol/$' + | '/api/harness-secret-answer/$' | '/api/harness-waits/$' | '/api/image/stream' | '/api/transcription/stream' @@ -2479,6 +2551,9 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ActivityTestRoute: typeof ActivityTestRoute + AiSdkClientRoute: typeof AiSdkClientRoute + AiSdkResumeRoute: typeof AiSdkResumeRoute + AiSdkServerRoute: typeof AiSdkServerRoute ByokRoute: typeof ByokRoute CallOwnershipRoute: typeof CallOwnershipRoute ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute @@ -2519,6 +2594,8 @@ export interface RootRouteChildren { ProviderFeatureRoute: typeof ProviderFeatureRoute ApiActivityTestRoute: typeof ApiActivityTestRoute ApiAdapterInputModalitiesRoute: typeof ApiAdapterInputModalitiesRoute + ApiAiSdkClientRoute: typeof ApiAiSdkClientRoute + ApiAiSdkServerRoute: typeof ApiAiSdkServerRoute ApiAnthropicAuthWireRoute: typeof ApiAnthropicAuthWireRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute ApiAnthropicComputerToolsetWireRoute: typeof ApiAnthropicComputerToolsetWireRoute @@ -2667,10 +2744,11 @@ export interface RootRouteChildren { ApiWorldRoute: typeof ApiWorldRoute ApiWrapFetchWireRoute: typeof ApiWrapFetchWireRoute ProviderIndexRoute: typeof ProviderIndexRoute - ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute - ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute + ApiAiSdkResumeSplatRoute: typeof ApiAiSdkResumeSplatRoute ApiHarnessHandoffSplatRoute: typeof ApiHarnessHandoffSplatRoute ApiHarnessObservabilitySplatRoute: typeof ApiHarnessObservabilitySplatRoute + ApiHarnessProtocolSplatRoute: typeof ApiHarnessProtocolSplatRoute + ApiHarnessSecretAnswerSplatRoute: typeof ApiHarnessSecretAnswerSplatRoute ApiHarnessWaitsSplatRoute: typeof ApiHarnessWaitsSplatRoute } @@ -2690,6 +2768,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ActivityTestRouteImport parentRoute: typeof rootRouteImport } + '/ai-sdk-client': { + id: '/ai-sdk-client' + path: '/ai-sdk-client' + fullPath: '/ai-sdk-client' + preLoaderRoute: typeof AiSdkClientRouteImport + parentRoute: typeof rootRouteImport + } + '/ai-sdk-resume': { + id: '/ai-sdk-resume' + path: '/ai-sdk-resume' + fullPath: '/ai-sdk-resume' + preLoaderRoute: typeof AiSdkResumeRouteImport + parentRoute: typeof rootRouteImport + } + '/ai-sdk-server': { + id: '/ai-sdk-server' + path: '/ai-sdk-server' + fullPath: '/ai-sdk-server' + preLoaderRoute: typeof AiSdkServerRouteImport + parentRoute: typeof rootRouteImport + } '/byok': { id: '/byok' path: '/byok' @@ -2977,6 +3076,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAdapterInputModalitiesRouteImport parentRoute: typeof rootRouteImport } + '/api/ai-sdk-client': { + id: '/api/ai-sdk-client' + path: '/api/ai-sdk-client' + fullPath: '/api/ai-sdk-client' + preLoaderRoute: typeof ApiAiSdkClientRouteImport + parentRoute: typeof rootRouteImport + } + '/api/ai-sdk-server': { + id: '/api/ai-sdk-server' + path: '/api/ai-sdk-server' + fullPath: '/api/ai-sdk-server' + preLoaderRoute: typeof ApiAiSdkServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/anthropic-auth-wire': { id: '/api/anthropic-auth-wire' path: '/api/anthropic-auth-wire' @@ -4006,6 +4119,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWrapFetchWireRouteImport parentRoute: typeof rootRouteImport } + '/api/ai-sdk-resume/$': { + id: '/api/ai-sdk-resume/$' + path: '/api/ai-sdk-resume/$' + fullPath: '/api/ai-sdk-resume/$' + preLoaderRoute: typeof ApiAiSdkResumeSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/audio/stream': { id: '/api/audio/stream' path: '/stream' @@ -4013,20 +4133,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAudioStreamRouteImport parentRoute: typeof ApiAudioRoute } - '/api/harness-protocol/$': { - id: '/api/harness-protocol/$' - path: '/api/harness-protocol/$' - fullPath: '/api/harness-protocol/$' - preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport - parentRoute: typeof rootRouteImport - } - '/api/harness-secret-answer/$': { - id: '/api/harness-secret-answer/$' - path: '/api/harness-secret-answer/$' - fullPath: '/api/harness-secret-answer/$' - preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport - parentRoute: typeof rootRouteImport - } '/api/harness-handoff/$': { id: '/api/harness-handoff/$' path: '/api/harness-handoff/$' @@ -4041,6 +4147,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHarnessObservabilitySplatRouteImport parentRoute: typeof rootRouteImport } + '/api/harness-protocol/$': { + id: '/api/harness-protocol/$' + path: '/api/harness-protocol/$' + fullPath: '/api/harness-protocol/$' + preLoaderRoute: typeof ApiHarnessProtocolSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/harness-secret-answer/$': { + id: '/api/harness-secret-answer/$' + path: '/api/harness-secret-answer/$' + fullPath: '/api/harness-secret-answer/$' + preLoaderRoute: typeof ApiHarnessSecretAnswerSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/harness-waits/$': { id: '/api/harness-waits/$' path: '/api/harness-waits/$' @@ -4140,6 +4260,9 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ActivityTestRoute: ActivityTestRoute, + AiSdkClientRoute: AiSdkClientRoute, + AiSdkResumeRoute: AiSdkResumeRoute, + AiSdkServerRoute: AiSdkServerRoute, ByokRoute: ByokRoute, CallOwnershipRoute: CallOwnershipRoute, ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, @@ -4180,6 +4303,8 @@ const rootRouteChildren: RootRouteChildren = { ProviderFeatureRoute: ProviderFeatureRoute, ApiActivityTestRoute: ApiActivityTestRoute, ApiAdapterInputModalitiesRoute: ApiAdapterInputModalitiesRoute, + ApiAiSdkClientRoute: ApiAiSdkClientRoute, + ApiAiSdkServerRoute: ApiAiSdkServerRoute, ApiAnthropicAuthWireRoute: ApiAnthropicAuthWireRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, ApiAnthropicComputerToolsetWireRoute: ApiAnthropicComputerToolsetWireRoute, @@ -4334,10 +4459,11 @@ const rootRouteChildren: RootRouteChildren = { ApiWorldRoute: ApiWorldRoute, ApiWrapFetchWireRoute: ApiWrapFetchWireRoute, ProviderIndexRoute: ProviderIndexRoute, - ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, - ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, + ApiAiSdkResumeSplatRoute: ApiAiSdkResumeSplatRoute, ApiHarnessHandoffSplatRoute: ApiHarnessHandoffSplatRoute, ApiHarnessObservabilitySplatRoute: ApiHarnessObservabilitySplatRoute, + ApiHarnessProtocolSplatRoute: ApiHarnessProtocolSplatRoute, + ApiHarnessSecretAnswerSplatRoute: ApiHarnessSecretAnswerSplatRoute, ApiHarnessWaitsSplatRoute: ApiHarnessWaitsSplatRoute, } export const routeTree = rootRouteImport diff --git a/testing/e2e/src/routes/ai-sdk-client.tsx b/testing/e2e/src/routes/ai-sdk-client.tsx new file mode 100644 index 0000000000..652b7d8f75 --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-client.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useChat } from '@ai-sdk/react' +import { + DefaultChatTransport, + isToolUIPart, + lastAssistantMessageIsCompleteWithApprovalResponses, +} from 'ai' + +// AI SDK `useChat` talks to the TanStack `chat()` route in +// `api.ai-sdk-client.ts`. `?prompt=` sets the first user message. +const transport = new DefaultChatTransport({ api: '/api/ai-sdk-client' }) + +export const Route = createFileRoute('/ai-sdk-client')({ + validateSearch: (search: Record) => ({ + prompt: typeof search.prompt === 'string' ? search.prompt : undefined, + }), + component: AISDKClientPage, +}) + +function AISDKClientPage() { + const { prompt = 'Weather in Oslo?' } = Route.useSearch() + const { messages, sendMessage, addToolApprovalResponse } = useChat({ + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + }) + + const parts = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.parts) + const text = parts.flatMap((part) => + part.type === 'text' ? [part.text] : [], + ) + const toolParts = parts.filter(isToolUIPart) + const toolOutputs = toolParts.flatMap((part) => + part.state === 'output-available' ? [String(part.output)] : [], + ) + + useEffect(() => { + void sendMessage({ text: prompt }) + // Send once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {toolParts.map((part) => + part.state === 'approval-requested' ? ( + + ) : null, + )} +
{toolOutputs.join(',')}
+
{text.join('')}
+
+ ) +} diff --git a/testing/e2e/src/routes/ai-sdk-resume.tsx b/testing/e2e/src/routes/ai-sdk-resume.tsx new file mode 100644 index 0000000000..d4588c44d4 --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-resume.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' + +// AI SDK `useChat({ resume: true })` against the resumable TanStack route in +// `api.ai-sdk-resume.$.ts`. `?chat=` is the chat id, so a reload rejoins it. +const transport = new DefaultChatTransport({ api: '/api/ai-sdk-resume/chat' }) + +export const Route = createFileRoute('/ai-sdk-resume')({ + validateSearch: (search: Record) => ({ + chat: typeof search.chat === 'string' ? search.chat : 'ai-sdk-resume', + }), + component: AISDKResumePage, +}) + +function AISDKResumePage() { + const { chat } = Route.useSearch() + const { messages, sendMessage } = useChat({ + id: chat, + transport, + resume: true, + }) + + const assistant = messages.filter((message) => message.role === 'assistant') + + return ( +
+ +
{assistant.length}
+
+ {assistant + .flatMap((message) => message.parts) + .flatMap((part) => (part.type === 'text' ? [part.text] : [])) + .join('')} +
+
+ ) +} diff --git a/testing/e2e/src/routes/ai-sdk-server.tsx b/testing/e2e/src/routes/ai-sdk-server.tsx new file mode 100644 index 0000000000..09890f4c2d --- /dev/null +++ b/testing/e2e/src/routes/ai-sdk-server.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { toolDefinition } from '@tanstack/ai' +import { useChat } from '@tanstack/ai-react' +import { fromChatTransport } from '@tanstack/ai-ui-message-stream' +import { DefaultChatTransport } from 'ai' +import { z } from 'zod' + +// The route's tool, so its approval is a bound `tool-approval` interrupt. +const addToCart = toolDefinition({ + name: 'addToCart', + description: 'Add a guitar to the cart', + inputSchema: z.object({ guitarId: z.string(), quantity: z.number() }), + needsApproval: true, +}) + +const tools = [addToCart] as const + +// TanStack `useChat` talks to the AI SDK `streamText` route in +// `api.ai-sdk-server.ts`. `?prompt=` sets the first user message. +const connection = fromChatTransport( + new DefaultChatTransport({ api: '/api/ai-sdk-server' }), + { tools }, +) + +export const Route = createFileRoute('/ai-sdk-server')({ + validateSearch: (search: Record) => ({ + prompt: typeof search.prompt === 'string' ? search.prompt : undefined, + }), + component: AISDKServerPage, +}) + +function AISDKServerPage() { + const { prompt = '[chat] recommend a guitar' } = Route.useSearch() + const { messages, sendMessage, interrupts } = useChat({ + threadId: 'ai-sdk-server', + connection, + tools, + }) + + const assistantText = messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => + message.parts.flatMap((part) => + part.type === 'text' ? [part.content] : [], + ), + ) + .join('') + const toolResults = messages + .flatMap((message) => message.parts) + .flatMap((part) => (part.type === 'tool-result' ? [part.content] : [])) + const activities = messages.flatMap((message) => + message.role === 'activity' + ? message.parts.flatMap((part) => + part.type === 'activity' ? [JSON.stringify(part.content)] : [], + ) + : [], + ) + + useEffect(() => { + void sendMessage(prompt) + // Send once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( +
+ {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + )} + {activities.map((content, index) => ( +
+ {content} +
+ ))} +
{toolResults.join(',')}
+
{assistantText}
+
+ ) +} diff --git a/testing/e2e/src/routes/api.ai-sdk-client.ts b/testing/e2e/src/routes/api.ai-sdk-client.ts new file mode 100644 index 0000000000..4bf994d80d --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-client.ts @@ -0,0 +1,75 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, toolDefinition } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { + chatParamsFromAISDKRequest, + toUIMessageStream, +} from '@tanstack/ai-ui-message-stream' +import { createUIMessageStreamResponse } from 'ai' +import { z } from 'zod' +import type { FakeResponseStep } from '@tanstack/ai/testing' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), +}).server(({ city }) => `sunny in ${city}`) + +const deleteFile = toolDefinition({ + name: 'deleteFile', + description: 'Delete a file', + inputSchema: z.object({ path: z.string() }), + needsApproval: true, +}).server(({ path }) => `deleted ${path}`) + +const answerWithToolResult: FakeResponseStep = ({ request }) => ({ + text: `Tool said: ${String(request.messages.at(-1)?.content)}`, +}) + +// A TanStack `chat()` route that AI SDK `useChat` reads. No network: +// `fakeText` calls a server tool, then answers with the tool result. A user +// message that starts with "Delete" calls `deleteFile`, which needs approval; +// the approval answer comes back as a second request that resumes the run. +export const Route = createFileRoute('/api/ai-sdk-client')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await chatParamsFromAISDKRequest(request) + const lastUser = params.messages + .filter((m) => m.role === 'user') + .at(-1) + ?.parts.flatMap((p) => (p.type === 'text' ? [p.content] : [])) + .join('') + + // A fake per request: pick the script from the request. + const fake = fakeText() + fake.setResponses( + params.resume + ? [answerWithToolResult] + : lastUser?.startsWith('Delete') + ? [ + { + toolCalls: [ + { name: 'deleteFile', input: { path: 'a.txt' } }, + ], + }, + ] + : [ + { + toolCalls: [ + { name: 'getWeather', input: { city: 'Oslo' } }, + ], + }, + answerWithToolResult, + ], + ) + + return createUIMessageStreamResponse({ + stream: toUIMessageStream( + chat({ adapter: fake, tools: [getWeather, deleteFile], ...params }), + ), + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/api.ai-sdk-resume.$.ts b/testing/e2e/src/routes/api.ai-sdk-resume.$.ts new file mode 100644 index 0000000000..13404fc88a --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-resume.$.ts @@ -0,0 +1,45 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, memoryStream } from '@tanstack/ai' +import { fakeText } from '@tanstack/ai/testing' +import { + chatParamsFromAISDKRequest, + resumeUIMessageStreamResponse, + toResumableUIMessageStreamResponse, +} from '@tanstack/ai-ui-message-stream' + +const RESUME_REPLY = + 'River stones rest under clear cold water while the slow current carries leaves toward the quiet village mill.' + +// The active run of each chat. The e2e app is one process. +const activeRuns = new Map() + +// A resumable TanStack `chat()` route for AI SDK `useChat({ resume: true })`. +// - `POST /api/ai-sdk-resume/chat`: a slow `fakeText` reply, written to a +// `memoryStream` log, so a page reload lands mid-reply. +// - `GET /api/ai-sdk-resume/chat//stream`: replays the chat's run. +export const Route = createFileRoute('/api/ai-sdk-resume/$')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await chatParamsFromAISDKRequest(request) + const runId = crypto.randomUUID() + activeRuns.set(params.threadId, runId) + + const fake = fakeText({ tokensPerSecond: 10 }) + fake.setResponses([{ text: RESUME_REPLY }]) + + return toResumableUIMessageStreamResponse( + chat({ adapter: fake, runId, ...params }), + memoryStream({ runId }), + ) + }, + GET: ({ request }) => { + const chatId = new URL(request.url).pathname.split('/').at(-2) ?? '' + const runId = activeRuns.get(chatId) + return resumeUIMessageStreamResponse( + runId === undefined ? null : memoryStream({ runId }), + ) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/api.ai-sdk-server.ts b/testing/e2e/src/routes/api.ai-sdk-server.ts new file mode 100644 index 0000000000..42e67a43d6 --- /dev/null +++ b/testing/e2e/src/routes/api.ai-sdk-server.ts @@ -0,0 +1,71 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createOpenAI } from '@ai-sdk/openai' +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + streamText, + tool, + validateUIMessages, +} from 'ai' +import { z } from 'zod' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' + +// An AI SDK `streamText` route that TanStack `useChat` reads through +// `fromChatTransport`. The model is aimock's OpenAI chat completions API. +// The user message `[ai-sdk-data]` skips the model: the route writes the same +// `data-search` part twice, with one id. +export const Route = createFileRoute('/api/ai-sdk-server')({ + server: { + handlers: { + POST: async ({ request }) => { + const body: { messages: unknown } = await request.json() + const messages = await validateUIMessages({ messages: body.messages }) + + if (JSON.stringify(messages).includes('[ai-sdk-data]')) { + return createUIMessageStreamResponse({ + stream: createUIMessageStream({ + execute({ writer }) { + const id = 'search-1' + writer.write({ + type: 'data-search', + id, + data: { status: 'running' }, + }) + writer.write({ + type: 'data-search', + id, + data: { status: 'done', hits: 3 }, + }) + writer.write({ type: 'text-start', id: 't' }) + writer.write({ type: 'text-delta', id: 't', delta: 'Found 3.' }) + writer.write({ type: 'text-end', id: 't' }) + }, + }), + }) + } + + const openai = createOpenAI({ + apiKey: 'sk-e2e-test-dummy-key', + baseURL: `${LLMOCK_DEFAULT_BASE}/v1`, + }) + return streamText({ + model: openai.chat('gpt-4o'), + messages: await convertToModelMessages(messages), + tools: { + addToCart: tool({ + description: 'Add a guitar to the cart', + inputSchema: z.object({ + guitarId: z.string(), + quantity: z.number(), + }), + needsApproval: true, + execute: ({ guitarId }) => ({ added: guitarId }), + }), + }, + }).toUIMessageStreamResponse() + }, + }, + }, +}) diff --git a/testing/e2e/tests/ui-message-stream.spec.ts b/testing/e2e/tests/ui-message-stream.spec.ts new file mode 100644 index 0000000000..5502e19305 --- /dev/null +++ b/testing/e2e/tests/ui-message-stream.spec.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' + +/** + * `@tanstack/ai-ui-message-stream` connects TanStack AI and the AI SDK UI + * message stream in both directions. + */ +test.describe('AI SDK UI message stream', () => { + test('AI SDK useChat reads a TanStack chat() route', async ({ page }) => { + await page.goto('/ai-sdk-client') + + await expect(page.getByTestId('tool-outputs')).toHaveText('sunny in Oslo') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'Tool said: sunny in Oslo', + ) + }) + + test('AI SDK useChat approves a TanStack server tool', async ({ page }) => { + await page.goto( + `/ai-sdk-client?${new URLSearchParams({ prompt: 'Delete a.txt' })}`, + ) + + await page.getByTestId('approve-button').click() + + await expect(page.getByTestId('tool-outputs')).toHaveText('deleted a.txt') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'Tool said: deleted a.txt', + ) + }) + + test('AI SDK useChat resumes a TanStack run after a reload', async ({ + page, + }) => { + const reply = + 'River stones rest under clear cold water while the slow current carries leaves toward the quiet village mill.' + // On mount, `resume: true` asks for the active run. Wait for that 204 + // before sending, so the send does not race it. + const resumeCheck = page.waitForResponse((r) => r.url().endsWith('/stream')) + await page.goto(`/ai-sdk-resume?chat=resume-${randomUUID()}`) + await resumeCheck + await page.getByTestId('send-button').click() + await expect(page.getByTestId('assistant-text')).toContainText('River') + + await page.reload() + + await expect(page.getByTestId('assistant-text')).toHaveText(reply) + await expect(page.getByTestId('assistant-count')).toHaveText('1') + }) + + test('TanStack useChat reads an AI SDK streamText route', async ({ + page, + }) => { + await page.goto('/ai-sdk-server') + + await expect(page.getByTestId('assistant-text')).toContainText( + 'Fender Stratocaster', + ) + }) + + test('TanStack useChat approves an AI SDK tool', async ({ page }) => { + await page.goto( + `/ai-sdk-server?${new URLSearchParams({ prompt: '[ai-sdk-approval] buy the stratocaster' })}`, + ) + + await page.getByTestId('approve-button').click() + + await expect(page.getByTestId('tool-results')).toHaveText('{"added":"1"}') + await expect(page.getByTestId('assistant-text')).toHaveText( + 'The Stratocaster is in your cart.', + ) + }) + + test('TanStack useChat shows an AI SDK data part as one activity', async ({ + page, + }) => { + await page.goto( + `/ai-sdk-server?${new URLSearchParams({ prompt: '[ai-sdk-data]' })}`, + ) + + await expect(page.getByTestId('assistant-text')).toHaveText('Found 3.') + await expect(page.getByTestId('activity-content')).toHaveText([ + '{"status":"done","hits":3}', + ]) + }) +})