diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx
index a20d8caa1..6ac55f745 100644
--- a/docs/content/docs/agent/reference/adapters-and-formats.mdx
+++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx
@@ -34,15 +34,17 @@ import {
openAIAdapter,
openAIReadableStreamAdapter,
openAIResponsesAdapter,
+ vercelAIAdapter,
langGraphAdapter,
openAIMessageFormat,
openAIConversationMessageFormat,
+ vercelAIMessageFormat,
langGraphMessageFormat,
identityMessageFormat,
} from "@openuidev/react-ui";
```
-All five stream adapters are **factory functions** — always call them with `()`. `streamAdapter: openAIAdapter()` is correct; a bare `streamAdapter: openAIAdapter` is wrong.
+All six stream adapters are **factory functions** — always call them with `()`. `streamAdapter: openAIAdapter()` is correct; a bare `streamAdapter: openAIAdapter` is wrong.
## The two channels
@@ -349,9 +351,9 @@ interface StreamProtocolAdapter {
}
```
-`fetchLLM` calls `parse` on the `Response` your route returns and consumes the yielded AG-UI events. All five bundled adapters are factories you call to get an instance — `agUIAdapter()`, `openAIAdapter()`, `openAIReadableStreamAdapter()`, `openAIResponsesAdapter()`, `langGraphAdapter()`; only `langGraphAdapter()` accepts options.
+`fetchLLM` calls `parse` on the `Response` your route returns and consumes the yielded AG-UI events. All six bundled adapters are factories you call to get an instance — `agUIAdapter()`, `openAIAdapter()`, `openAIReadableStreamAdapter()`, `openAIResponsesAdapter()`, `vercelAIAdapter()`, `langGraphAdapter()`; only `langGraphAdapter()` accepts options.
-A malformed line in the stream is logged to the console and skipped — it does not abort the whole stream. Provider-level failures should be surfaced as a `RUN_ERROR` event (the OpenAI Responses and LangGraph adapters do this for their error events).
+The line-oriented adapters log and skip malformed lines. `vercelAIAdapter()` delegates validation to the Vercel AI SDK and rejects invalid UIMessage chunks. Provider-level failures should be surfaced as a `RUN_ERROR` event (the OpenAI Responses, Vercel AI SDK, and LangGraph adapters do this for their error events).
### Selection guide
@@ -362,6 +364,7 @@ Start from your provider and how the route streams its reply. The stream adapter
| OpenAI **Chat Completions** SSE (`data: {…}` lines) | `openAIAdapter()` | `openAIMessageFormat` |
| OpenAI SDK `stream.toReadableStream()` (NDJSON, no `data:` prefix) | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
| OpenAI **Responses / Conversations** API SSE | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
+| **Vercel AI SDK v6** UIMessage SSE (`toUIMessageStreamResponse()`) | `vercelAIAdapter()` | `vercelAIMessageFormat` |
| **LangGraph** named-event SSE (`event: messages\ndata: …`) | `langGraphAdapter()` | `langGraphMessageFormat` |
| Already AG-UI events (your route emits AG-UI SSE directly) | `agUIAdapter()` | `identityMessageFormat` (default) |
@@ -427,6 +430,25 @@ const llm = fetchLLM({
- **What it handles:** `response.output_item.added` (assistant message start, `function_call` start, and server-side `function_call_output` → `TOOL_CALL_RESULT`), `response.output_text.delta` / `.done` for text, `response.function_call_arguments.delta` / `.done` for tool-call arguments (mapping `item_id` → `call_id`), and `error` / `response.failed` → `RUN_ERROR`. Lifecycle/metadata events (`response.created`, `response.completed`, etc.) are ignored.
- **When to use:** You call the newer Responses or Conversations API rather than Chat Completions. Pair with `openAIConversationMessageFormat`.
+### `vercelAIAdapter`
+
+```ts
+import { fetchLLM, vercelAIAdapter, vercelAIMessageFormat } from "@openuidev/react-ui";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: vercelAIAdapter(),
+ messageFormat: vercelAIMessageFormat,
+});
+```
+
+- **Wire shape:** Vercel AI SDK v6 UIMessage SSE, as returned by `result.toUIMessageStreamResponse()`.
+- **What it handles:** streamed text, streamed or complete tool input, successful, failed, or denied tool output, and SDK error chunks. The adapter delegates SSE decoding and chunk validation to the Vercel AI SDK, then projects the validated chunks into AG-UI events.
+- **Current scope:** reasoning, sources, assistant files, custom data, metadata, and tool-approval-request chunks have no equivalent in the current AgentInterface stream processor and are ignored. Approval-gated tools therefore need a custom flow today. Provider-executed tool chunks (`providerExecuted: true`) are rejected because AG-UI cannot preserve their assistant-contained result semantics.
+- **When to use:** Your route uses Vercel AI SDK `streamText()` and returns its UIMessage stream response. Pair with `vercelAIMessageFormat` so the route receives `UIMessage[]` that can be passed to `convertToModelMessages()`.
+
+Install `ai@^6` alongside `@openuidev/react-ui` or `@openuidev/react-headless` when using this integration. It is an optional peer dependency, so apps using other adapters do not need the Vercel AI SDK.
+
### `langGraphAdapter`
```ts
@@ -518,6 +540,17 @@ import { openAIConversationMessageFormat } from "@openuidev/react-ui";
- **Conversions:** assistant messages are *flattened* into sibling items — a text `message` plus one `function_call` item per tool call; tool results become `function_call_output` items. Inbound, adjacent assistant `message` + `function_call` items are regrouped into one `AssistantMessage`, and Conversations-specific content parts (`reasoning_text`, `summary_text`, `refusal`, …) are folded into text.
- **When to use:** Pair with `openAIResponsesAdapter()`.
+### `vercelAIMessageFormat`
+
+```ts
+import { vercelAIMessageFormat } from "@openuidev/react-ui";
+```
+
+- **Shape:** Vercel AI SDK v6 `UIMessage[]`, whose content is represented as typed `parts`.
+- **Conversions:** text and file inputs become UIMessage parts; AG-UI tool calls become `dynamic-tool` parts; separate AG-UI tool-result messages are folded into the matching tool part as an available or errored output. Inbound conversion accepts both `dynamic-tool` and statically named `tool-*` parts and expands completed outputs back into separate tool messages. (`developer` maps to `system`.)
+- **Unsupported and lossy fields:** this integration does not currently preserve reasoning, sources, custom data, assistant files, or approvals when converting stored messages. Provider-executed tool parts (`providerExecuted: true`) are rejected rather than converted; other provider metadata is not preserved.
+- **When to use:** Pair with `vercelAIAdapter()` and pass the received messages to Vercel AI SDK's `convertToModelMessages()` in your route.
+
### `langGraphMessageFormat`
```ts
diff --git a/docs/content/docs/agent/reference/self-hosting.mdx b/docs/content/docs/agent/reference/self-hosting.mdx
index a34ae837e..8fad92a11 100644
--- a/docs/content/docs/agent/reference/self-hosting.mdx
+++ b/docs/content/docs/agent/reference/self-hosting.mdx
@@ -48,6 +48,7 @@ Your route always receives a JSON body of `{ threadId, messages }` and returns a
| OpenAI Chat Completions, NDJSON from `stream.toReadableStream()` | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
| OpenAI Chat Completions, raw SSE (`data: {…}\n\n`, `data: [DONE]`) | `openAIAdapter()` | `openAIMessageFormat` |
| OpenAI **Responses / Conversations** API stream | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
+| Vercel AI SDK v6 UIMessage stream from `toUIMessageStreamResponse()` | `vercelAIAdapter()` | `vercelAIMessageFormat` |
| LangGraph stream (named SSE events) | `langGraphAdapter()` | `langGraphMessageFormat` |
| A backend that already emits AG-UI events | `agUIAdapter()` | depends on what your route expects |
diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx
index 68e40ef49..4c195d281 100644
--- a/docs/content/docs/api-reference/index.mdx
+++ b/docs/content/docs/api-reference/index.mdx
@@ -9,7 +9,7 @@ The OpenUI SDK is split into packages that build on each other:
- **`@openuidev/react-lang`** — React runtime. Define component libraries with Zod schemas, generate system prompts, parse OpenUI Lang, and render streamed output to React. This is the foundation for React integrations.
-- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, AG-UI), and message format converters. Use this when you want full control over your chat UI.
+- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, Vercel AI SDK, AG-UI), and message format converters. Use this when you want full control over your chat UI.
- **`@openuidev/react-ui`** — `AgentInterface`, a ready-to-use artifact chat surface with thread history, plus two built-in component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface.
diff --git a/docs/content/docs/api-reference/react-headless.mdx b/docs/content/docs/api-reference/react-headless.mdx
index 22b2906bf..f191562fd 100644
--- a/docs/content/docs/api-reference/react-headless.mdx
+++ b/docs/content/docs/api-reference/react-headless.mdx
@@ -15,9 +15,11 @@ import {
openAIAdapter,
openAIResponsesAdapter,
openAIReadableStreamAdapter,
+ vercelAIAdapter,
agUIAdapter,
openAIMessageFormat,
openAIConversationMessageFormat,
+ vercelAIMessageFormat,
identityMessageFormat,
processStreamedMessage,
MessageProvider,
@@ -105,6 +107,7 @@ Adapters referenced in integration guides:
function openAIAdapter(): StreamProtocolAdapter; // OpenAI Chat Completions stream
function openAIResponsesAdapter(): StreamProtocolAdapter; // OpenAI Responses stream
function openAIReadableStreamAdapter(): StreamProtocolAdapter; // OpenAI ReadableStream
+function vercelAIAdapter(): StreamProtocolAdapter; // Vercel AI SDK v6 UIMessage stream
function agUIAdapter(): StreamProtocolAdapter; // AG-UI protocol stream
```
@@ -123,6 +126,7 @@ Converters referenced in integration guides:
```ts
const openAIMessageFormat: MessageFormat; // Chat Completions format
const openAIConversationMessageFormat: MessageFormat; // Responses/Conversations item format
+const vercelAIMessageFormat: MessageFormat; // Vercel AI SDK v6 UIMessage format
const identityMessageFormat: MessageFormat; // Pass-through (no conversion)
```
@@ -135,6 +139,10 @@ interface MessageFormat {
}
```
+The Vercel AI SDK integration supports app-executed tools. Provider-executed tools
+(`providerExecuted: true`) are rejected because AG-UI messages cannot preserve
+their assistant-contained result semantics.
+
## Message types
```ts
diff --git a/packages/react-headless/README.md b/packages/react-headless/README.md
index 046526c15..c79650864 100644
--- a/packages/react-headless/README.md
+++ b/packages/react-headless/README.md
@@ -18,13 +18,19 @@ pnpm add @openuidev/react-headless
**Peer dependencies:** `react >=19.0.0`, `react-dom >=19.0.0`, `zustand ^4.5.5`
+The Vercel AI SDK integration has one optional peer dependency:
+
+```bash
+npm install ai@^6
+```
+
## Overview
Use `@openuidev/react-headless` when you want OpenUI's chat behavior without OpenUI's visual components:
- **`ChatProvider`** manages threads, messages, and streaming state through a Zustand store.
- **Selector hooks** expose thread and thread-list state without coupling you to a layout.
-- **Streaming adapters** parse SSE or SDK responses from OpenAI, AG-UI, or custom backends.
+- **Streaming adapters** parse SSE or SDK responses from OpenAI, Vercel AI SDK, AG-UI, or custom backends.
- **Message formats** convert between your API shape and OpenUI's internal AG-UI shape.
## Quick Start
@@ -179,6 +185,23 @@ const llm = fetchLLM({ url: "/api/chat", streamAdapter: openAIAdapter() });
| `openAIAdapter()` | Parses OpenAI Chat Completions streaming (`ChatCompletionChunk`) |
| `openAIResponsesAdapter()` | Parses OpenAI Responses API streaming (`ResponseStreamEvent`) |
| `openAIReadableStreamAdapter()` | Parses OpenAI SDK's `Stream.toReadableStream()` NDJSON output |
+| `vercelAIAdapter()` | Parses Vercel AI SDK v6 UIMessage streams from `toUIMessageStreamResponse()` |
+
+For a Vercel AI SDK route, use its stream adapter and message format together:
+
+```tsx
+import { fetchLLM, vercelAIAdapter, vercelAIMessageFormat } from "@openuidev/react-headless";
+
+const llm = fetchLLM({
+ url: "/api/chat",
+ streamAdapter: vercelAIAdapter(),
+ messageFormat: vercelAIMessageFormat,
+});
+```
+
+This integration supports app-executed tools. Provider-executed tools
+(`providerExecuted: true`, such as provider-hosted built-ins) throw an error because
+the AG-UI message model cannot preserve their assistant-contained result semantics.
### Custom adapter
@@ -213,6 +236,7 @@ const llm = fetchLLM({
| `identityMessageFormat` | Default format when messages are already AG-UI shaped |
| `openAIMessageFormat` | Converts to/from OpenAI `ChatCompletionMessageParam[]` |
| `openAIConversationMessageFormat` | Converts to/from OpenAI Responses API `ResponseInputItem[]` |
+| `vercelAIMessageFormat` | Converts to/from Vercel AI SDK v6 `UIMessage[]` |
### Custom format
diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json
index 221c39c3a..f2cec3ce8 100644
--- a/packages/react-headless/package.json
+++ b/packages/react-headless/package.json
@@ -1,7 +1,7 @@
{
"name": "@openuidev/react-headless",
"version": "0.9.6",
- "description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps",
+ "description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI, Vercel AI SDK, and AG-UI, message format converters, and thread management for OpenUI generative UI apps",
"license": "MIT",
"type": "module",
"main": "dist/index.cjs",
@@ -39,11 +39,18 @@
"ci": "pnpm run lint:check && pnpm run format:check"
},
"peerDependencies": {
+ "ai": "^6.0.0",
"react": "catalog:",
"zustand": "catalog:"
},
+ "peerDependenciesMeta": {
+ "ai": {
+ "optional": true
+ }
+ },
"devDependencies": {
"@types/react": "catalog:",
+ "ai": "^6.0.236",
"openai": "^6.22.0",
"vitest": "^4.1.0"
},
@@ -57,6 +64,7 @@
"generative-ui",
"llm",
"openai",
+ "vercel-ai-sdk",
"zustand",
"state-management",
"sse",
diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts
index 6866ab82c..c5e02383f 100644
--- a/packages/react-headless/src/index.ts
+++ b/packages/react-headless/src/index.ts
@@ -29,11 +29,13 @@ export {
openAIAdapter,
openAIReadableStreamAdapter,
openAIResponsesAdapter,
+ vercelAIAdapter,
} from "./stream/adapters";
export {
langGraphMessageFormat,
openAIConversationMessageFormat,
openAIMessageFormat,
+ vercelAIMessageFormat,
} from "./stream/formats";
export { processStreamedMessage } from "./stream/processStreamedMessage";
diff --git a/packages/react-headless/src/stream/__tests__/vercel-ai-sdk.integration.test.ts b/packages/react-headless/src/stream/__tests__/vercel-ai-sdk.integration.test.ts
new file mode 100644
index 000000000..d7010ef22
--- /dev/null
+++ b/packages/react-headless/src/stream/__tests__/vercel-ai-sdk.integration.test.ts
@@ -0,0 +1,140 @@
+import { convertToModelMessages, type UIMessage } from "ai";
+import { beforeAll, describe, expect, it } from "vitest";
+import type { Message } from "../../types";
+import { vercelAIAdapter } from "../adapters/vercel-ai-sdk";
+import { vercelAIMessageFormat } from "../formats/vercel-ai-message-format";
+import { processStreamedMessage } from "../processStreamedMessage";
+
+beforeAll(() => {
+ const globalWithAnimationFrame = globalThis as unknown as {
+ requestAnimationFrame?: (callback: FrameRequestCallback) => number;
+ cancelAnimationFrame?: (id: number) => void;
+ };
+
+ if (typeof globalWithAnimationFrame.requestAnimationFrame !== "function") {
+ globalWithAnimationFrame.requestAnimationFrame = (callback: FrameRequestCallback) =>
+ setTimeout(() => callback(performance.now()), 0) as unknown as number;
+ globalWithAnimationFrame.cancelAnimationFrame = (id: number) => clearTimeout(id);
+ }
+});
+
+function sse(chunk: unknown): string {
+ return `data: ${JSON.stringify(chunk)}\n\n`;
+}
+
+function responseFromChunks(...chunks: unknown[]): Response {
+ return new Response(chunks.map(sse).join(""), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+
+describe("Vercel AI SDK stream integration", () => {
+ it("keeps multiple text parts in one model step as one assistant message", async () => {
+ const messages: Message[] = [];
+
+ await processStreamedMessage({
+ response: responseFromChunks(
+ { type: "start-step" },
+ { type: "text-start", id: "text-1" },
+ { type: "text-delta", id: "text-1", delta: "one" },
+ { type: "text-end", id: "text-1" },
+ { type: "text-start", id: "text-2" },
+ { type: "text-delta", id: "text-2", delta: "two" },
+ { type: "text-end", id: "text-2" },
+ { type: "finish-step" },
+ ),
+ adapter: vercelAIAdapter(),
+ createMessage: (message) => messages.push(message),
+ updateMessage: (message) => {
+ const index = messages.findIndex((candidate) => candidate.id === message.id);
+ if (index !== -1) messages[index] = message;
+ },
+ });
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toMatchObject({ role: "assistant", content: "onetwo" });
+
+ expect(
+ vercelAIMessageFormat.fromApi([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ { type: "text", text: "one" },
+ { type: "text", text: "two" },
+ ],
+ },
+ ]),
+ ).toEqual([{ id: "assistant-1", role: "assistant", content: "onetwo" }]);
+ });
+
+ it("preserves tool-only step order and emits no empty model text blocks", async () => {
+ const messages: Message[] = [];
+
+ await processStreamedMessage({
+ response: responseFromChunks(
+ { type: "start", messageId: "assistant-1" },
+ { type: "start-step" },
+ {
+ type: "tool-input-available",
+ toolCallId: "call-1",
+ toolName: "lookup",
+ input: { id: "first" },
+ },
+ {
+ type: "tool-output-available",
+ toolCallId: "call-1",
+ output: { nextId: "second" },
+ },
+ { type: "finish-step" },
+ { type: "start-step" },
+ {
+ type: "tool-input-available",
+ toolCallId: "call-2",
+ toolName: "lookup",
+ input: { id: "second" },
+ },
+ {
+ type: "tool-output-available",
+ toolCallId: "call-2",
+ output: { value: "done" },
+ },
+ { type: "finish-step" },
+ { type: "finish", finishReason: "stop" },
+ ),
+ adapter: vercelAIAdapter(),
+ createMessage: (message) => messages.push(message),
+ updateMessage: (message) => {
+ const index = messages.findIndex((candidate) => candidate.id === message.id);
+ if (index !== -1) messages[index] = message;
+ },
+ });
+
+ expect(messages.map((message) => message.role)).toEqual([
+ "assistant",
+ "tool",
+ "assistant",
+ "tool",
+ ]);
+
+ const uiMessages = vercelAIMessageFormat.toApi(messages) as UIMessage[];
+ expect(
+ uiMessages.flatMap((message) => message.parts).filter((part) => part.type === "text"),
+ ).toEqual([]);
+
+ const modelMessages = await convertToModelMessages(uiMessages);
+ expect(modelMessages.map((message) => message.role)).toEqual([
+ "assistant",
+ "tool",
+ "assistant",
+ "tool",
+ ]);
+ expect(
+ modelMessages
+ .filter((message) => message.role === "assistant")
+ .flatMap((message) => message.content)
+ .filter((part) => part.type === "text"),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/react-headless/src/stream/adapters/__tests__/vercel-ai-sdk.test.ts b/packages/react-headless/src/stream/adapters/__tests__/vercel-ai-sdk.test.ts
new file mode 100644
index 000000000..e517ca882
--- /dev/null
+++ b/packages/react-headless/src/stream/adapters/__tests__/vercel-ai-sdk.test.ts
@@ -0,0 +1,507 @@
+import { describe, expect, it, vi } from "vitest";
+import { vercelAIAdapter } from "../../../index";
+import { EventType, type AGUIEvent } from "../../../types";
+
+function sse(chunk: unknown): string {
+ return `data: ${JSON.stringify(chunk)}\n\n`;
+}
+
+function makeResponse(body: string, fragmentEveryByte = false): Response {
+ const bytes = new TextEncoder().encode(body);
+ const stream = new ReadableStream({
+ start(controller) {
+ if (fragmentEveryByte) {
+ for (const byte of bytes) controller.enqueue(Uint8Array.of(byte));
+ } else {
+ controller.enqueue(bytes);
+ }
+ controller.close();
+ },
+ });
+
+ return new Response(stream, {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+
+async function collect(iterable: AsyncIterable): Promise {
+ const events: AGUIEvent[] = [];
+ for await (const event of iterable) events.push(event);
+ return events;
+}
+
+async function parse(body: string): Promise {
+ return collect(vercelAIAdapter().parse(makeResponse(body)));
+}
+
+describe("vercelAIAdapter", () => {
+ it("maps AI SDK model step boundaries", async () => {
+ const events = await parse(
+ sse({ type: "start-step" }) +
+ sse({ type: "finish-step" }) +
+ sse({ type: "start-step" }) +
+ sse({ type: "finish-step" }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.STEP_STARTED,
+ stepName: "vercel-ai-step-1",
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-1",
+ },
+ {
+ type: EventType.STEP_STARTED,
+ stepName: "vercel-ai-step-2",
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-2",
+ },
+ ]);
+ });
+
+ it("normalizes all text parts in a model step into one AG-UI message", async () => {
+ const events = await parse(
+ sse({ type: "start-step" }) +
+ sse({ type: "text-start", id: "text-1" }) +
+ sse({ type: "text-delta", id: "text-1", delta: "one" }) +
+ sse({ type: "text-end", id: "text-1" }) +
+ sse({ type: "text-start", id: "text-2" }) +
+ sse({ type: "text-delta", id: "text-2", delta: "two" }) +
+ sse({ type: "text-end", id: "text-2" }) +
+ sse({ type: "finish-step" }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.STEP_STARTED,
+ stepName: "vercel-ai-step-1",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: "text-1",
+ role: "assistant",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "text-1",
+ delta: "one",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "text-1",
+ delta: "two",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: "text-1",
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-1",
+ },
+ ]);
+ });
+
+ it("assigns a standard AG-UI parent message to a tool-only model step", async () => {
+ const events = await parse(
+ sse({ type: "start-step" }) +
+ sse({
+ type: "tool-input-available",
+ toolCallId: "tool-step-1",
+ toolName: "search",
+ input: { query: "OpenUI" },
+ }) +
+ sse({ type: "finish-step" }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.STEP_STARTED,
+ stepName: "vercel-ai-step-1",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: "vercel-ai-message-1",
+ role: "assistant",
+ },
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "tool-step-1",
+ toolCallName: "search",
+ parentMessageId: "vercel-ai-message-1",
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-step-1",
+ delta: '{"query":"OpenUI"}',
+ },
+ {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: "tool-step-1",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: "vercel-ai-message-1",
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-1",
+ },
+ ]);
+ });
+
+ it("maps text start, delta, and end chunks", async () => {
+ const events = await parse(
+ sse({ type: "text-start", id: "text-1" }) +
+ sse({ type: "text-delta", id: "text-1", delta: "Hello" }) +
+ sse({ type: "text-delta", id: "text-1", delta: " world" }) +
+ sse({ type: "text-end", id: "text-1" }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: "text-1",
+ role: "assistant",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "text-1",
+ delta: "Hello",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "text-1",
+ delta: " world",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: "text-1",
+ },
+ ]);
+ });
+
+ it("maps streamed tool input without repeating the completed input", async () => {
+ const events = await parse(
+ sse({ type: "tool-input-start", toolCallId: "tool-1", toolName: "weather" }) +
+ sse({
+ type: "tool-input-delta",
+ toolCallId: "tool-1",
+ inputTextDelta: '{"city":"',
+ }) +
+ sse({ type: "tool-input-delta", toolCallId: "tool-1", inputTextDelta: 'Paris"}' }) +
+ sse({
+ type: "tool-input-available",
+ toolCallId: "tool-1",
+ toolName: "weather",
+ input: { city: "Paris" },
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "tool-1",
+ toolCallName: "weather",
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-1",
+ delta: '{"city":"',
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-1",
+ delta: 'Paris"}',
+ },
+ {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: "tool-1",
+ },
+ ]);
+ });
+
+ it("synthesizes a complete tool lifecycle for non-streamed input", async () => {
+ const events = await parse(
+ sse({
+ type: "tool-input-available",
+ toolCallId: "tool-2",
+ toolName: "search",
+ input: { query: "OpenUI" },
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "tool-2",
+ toolCallName: "search",
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-2",
+ delta: '{"query":"OpenUI"}',
+ },
+ {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: "tool-2",
+ },
+ ]);
+ });
+
+ it("does not duplicate synthesized args or end events for repeated available input", async () => {
+ const available = {
+ type: "tool-input-available",
+ toolCallId: "tool-duplicate",
+ toolName: "search",
+ input: { query: "OpenUI" },
+ };
+ const events = await parse(sse(available) + sse(available));
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "tool-duplicate",
+ toolCallName: "search",
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-duplicate",
+ delta: '{"query":"OpenUI"}',
+ },
+ {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: "tool-duplicate",
+ },
+ ]);
+ });
+
+ it("maps successful tool output", async () => {
+ const events = await parse(
+ sse({
+ type: "tool-output-available",
+ toolCallId: "tool-3",
+ output: { temperature: 18, unit: "C" },
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: "tool-result-tool-3",
+ toolCallId: "tool-3",
+ content: '{"temperature":18,"unit":"C"}',
+ role: "tool",
+ },
+ ]);
+ });
+
+ it("maps tool input errors to an ended call and errored result", async () => {
+ const events = await parse(
+ sse({
+ type: "tool-input-error",
+ toolCallId: "tool-4",
+ toolName: "weather",
+ input: { city: 42 },
+ errorText: "city must be a string",
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: "tool-4",
+ toolCallName: "weather",
+ },
+ {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: "tool-4",
+ delta: '{"city":42}',
+ },
+ {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: "tool-4",
+ },
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: "tool-result-tool-4",
+ toolCallId: "tool-4",
+ content: "city must be a string",
+ role: "tool",
+ isError: true,
+ error: "city must be a string",
+ },
+ ]);
+ });
+
+ it("maps tool output errors", async () => {
+ const events = await parse(
+ sse({
+ type: "tool-output-error",
+ toolCallId: "tool-5",
+ errorText: "weather service unavailable",
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: "tool-result-tool-5",
+ toolCallId: "tool-5",
+ content: "weather service unavailable",
+ role: "tool",
+ isError: true,
+ error: "weather service unavailable",
+ },
+ ]);
+ });
+
+ it("preserves the error signal when tool output error text is empty", async () => {
+ const events = await parse(
+ sse({
+ type: "tool-output-error",
+ toolCallId: "tool-empty-error",
+ errorText: "",
+ }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: "tool-result-tool-empty-error",
+ toolCallId: "tool-empty-error",
+ content: "",
+ role: "tool",
+ isError: true,
+ error: "",
+ },
+ ]);
+ });
+
+ it("maps denied tool output to an errored result", async () => {
+ const events = await parse(sse({ type: "tool-output-denied", toolCallId: "tool-6" }));
+
+ expect(events).toEqual([
+ {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: "tool-result-tool-6",
+ toolCallId: "tool-6",
+ content: "Tool execution was denied",
+ role: "tool",
+ isError: true,
+ error: "Tool execution was denied",
+ },
+ ]);
+ });
+
+ it("handles SSE and multibyte text fragmented across response chunks", async () => {
+ const body =
+ sse({ type: "text-start", id: "text-fragmented" }) +
+ sse({ type: "text-delta", id: "text-fragmented", delta: "Hello 🌍" }) +
+ sse({ type: "text-end", id: "text-fragmented" });
+
+ const events = await collect(vercelAIAdapter().parse(makeResponse(body, true)));
+
+ expect(events).toEqual([
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: "text-fragmented",
+ role: "assistant",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: "text-fragmented",
+ delta: "Hello 🌍",
+ },
+ {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: "text-fragmented",
+ },
+ ]);
+ });
+
+ it("maps AI SDK error chunks to RUN_ERROR", async () => {
+ const events = await parse(sse({ type: "error", errorText: "The model stream failed" }));
+
+ expect(events).toEqual([
+ {
+ type: EventType.RUN_ERROR,
+ message: "The model stream failed",
+ },
+ ]);
+ });
+
+ it("does not emit chunks after the terminal RUN_ERROR event", async () => {
+ const events = await parse(
+ sse({ type: "error", errorText: "The model stream failed" }) +
+ sse({ type: "text-start", id: "text-after-error" }) +
+ sse({ type: "text-delta", id: "text-after-error", delta: "ignored" }) +
+ sse({ type: "text-end", id: "text-after-error" }),
+ );
+
+ expect(events).toEqual([
+ {
+ type: EventType.RUN_ERROR,
+ message: "The model stream failed",
+ },
+ ]);
+ });
+
+ it("rejects provider-executed tools when the flag arrives with the input", async () => {
+ await expect(
+ parse(
+ sse({
+ type: "tool-input-start",
+ toolCallId: "provider-tool-1",
+ toolName: "web_search",
+ providerExecuted: true,
+ }),
+ ),
+ ).rejects.toThrow(
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.",
+ );
+ });
+
+ it("rejects provider-executed tools when the flag first arrives with the output", async () => {
+ await expect(
+ parse(
+ sse({
+ type: "tool-output-available",
+ toolCallId: "provider-tool-2",
+ output: { results: [] },
+ providerExecuted: true,
+ }),
+ ),
+ ).rejects.toThrow(
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.",
+ );
+ });
+
+ it("rejects invalid UIMessage chunks using the AI SDK parser", async () => {
+ await expect(
+ parse(sse({ type: "text-delta", id: "text-invalid", delta: 42 })),
+ ).rejects.toThrow();
+ });
+
+ it("reports the optional peer clearly when a bundler replaces it with an empty module", async () => {
+ vi.doMock("ai", () => ({}));
+
+ try {
+ await expect(collect(vercelAIAdapter().parse(makeResponse("")))).rejects.toThrow(
+ 'vercelAIAdapter requires the optional peer dependency "ai" (Vercel AI SDK v6).',
+ );
+ } finally {
+ vi.doUnmock("ai");
+ }
+ });
+
+ it("throws when the response has no body", async () => {
+ await expect(collect(vercelAIAdapter().parse(new Response(null)))).rejects.toThrow(
+ "No response body",
+ );
+ });
+});
diff --git a/packages/react-headless/src/stream/adapters/index.ts b/packages/react-headless/src/stream/adapters/index.ts
index 9bfda1a25..21411ca7d 100644
--- a/packages/react-headless/src/stream/adapters/index.ts
+++ b/packages/react-headless/src/stream/adapters/index.ts
@@ -3,3 +3,4 @@ export * from "./langgraph";
export * from "./openai-completions";
export * from "./openai-readable-stream";
export * from "./openai-responses";
+export * from "./vercel-ai-sdk";
diff --git a/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
new file mode 100644
index 000000000..fea294e4c
--- /dev/null
+++ b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
@@ -0,0 +1,271 @@
+import type { UIMessage, UIMessageChunk } from "ai";
+import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types";
+
+const MISSING_AI_SDK_MESSAGE =
+ 'vercelAIAdapter requires the optional peer dependency "ai" (Vercel AI SDK v6).';
+const PROVIDER_EXECUTED_TOOLS_UNSUPPORTED_MESSAGE =
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.";
+const TOOL_EXECUTION_DENIED_MESSAGE = "Tool execution was denied";
+
+function serialize(value: unknown): string {
+ if (typeof value === "string") return value;
+ return JSON.stringify(value) ?? String(value);
+}
+
+async function parseUIMessageStream(
+ body: ReadableStream,
+): Promise> {
+ let DefaultChatTransport: typeof import("ai").DefaultChatTransport;
+
+ try {
+ ({ DefaultChatTransport } = await import("ai"));
+ } catch (cause) {
+ throw new Error(MISSING_AI_SDK_MESSAGE, { cause });
+ }
+
+ // Some bundlers replace a missing optional peer with an empty module rather
+ // than rejecting the dynamic import. Keep that path on the same actionable
+ // error instead of failing later with "Class extends undefined".
+ if (typeof DefaultChatTransport !== "function") {
+ throw new Error(MISSING_AI_SDK_MESSAGE);
+ }
+
+ class UIMessageStreamParser extends DefaultChatTransport {
+ parseBody(stream: ReadableStream): ReadableStream {
+ return this.processResponseStream(stream);
+ }
+ }
+
+ return new UIMessageStreamParser().parseBody(body);
+}
+
+async function* readChunks(stream: ReadableStream): AsyncIterable {
+ const reader = stream.getReader();
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) return;
+ yield value;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function toolResult(toolCallId: string, content: string, error?: string): AGUIEvent {
+ return {
+ type: EventType.TOOL_CALL_RESULT,
+ messageId: `tool-result-${toolCallId}`,
+ toolCallId,
+ content,
+ role: "tool",
+ ...(error !== undefined ? { isError: true, error } : {}),
+ } as AGUIEvent;
+}
+
+/**
+ * Adapter for Vercel AI SDK v6 UIMessage streams, such as responses returned by
+ * `toUIMessageStreamResponse()`.
+ *
+ * The AI SDK is loaded only when parsing begins so it can remain an optional
+ * peer dependency for consumers that use other stream adapters. Its
+ * `DefaultChatTransport` performs the native SSE decoding and chunk validation;
+ * this adapter only maps validated UIMessage chunks to AG-UI events.
+ */
+export const vercelAIAdapter = (): StreamProtocolAdapter => ({
+ async *parse(response): AsyncIterable {
+ if (!response.body) throw new Error("No response body");
+
+ const chunks = await parseUIMessageStream(response.body);
+ const startedTools = new Set();
+ const streamedToolArgs = new Set();
+ const endedTools = new Set();
+ let stepIndex = 0;
+
+ // AG-UI step events are lifecycle-only. Normalize each AI SDK model step
+ // into one assistant message lifecycle here so downstream consumers do not
+ // need provider-specific step handling. Open it lazily to avoid producing
+ // empty messages for steps whose only chunks are ignored by this adapter.
+ let activeStep:
+ | {
+ stepName: string;
+ messageId?: string;
+ messageStarted: boolean;
+ }
+ | undefined;
+
+ const startStepMessage = (preferredMessageId?: string): AGUIEvent | undefined => {
+ if (!activeStep || activeStep.messageStarted) return;
+
+ activeStep.messageId ??= preferredMessageId ?? `vercel-ai-message-${stepIndex}`;
+ activeStep.messageStarted = true;
+ return {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: activeStep.messageId,
+ role: "assistant",
+ };
+ };
+
+ const toolParent = () =>
+ activeStep?.messageId ? { parentMessageId: activeStep.messageId } : {};
+
+ for await (const chunk of readChunks(chunks)) {
+ if ("providerExecuted" in chunk && chunk.providerExecuted === true) {
+ throw new Error(PROVIDER_EXECUTED_TOOLS_UNSUPPORTED_MESSAGE);
+ }
+
+ switch (chunk.type) {
+ case "start-step": {
+ const stepName = `vercel-ai-step-${++stepIndex}`;
+ activeStep = { stepName, messageStarted: false };
+ yield {
+ type: EventType.STEP_STARTED,
+ stepName,
+ };
+ break;
+ }
+
+ case "finish-step": {
+ const stepName = activeStep?.stepName ?? `vercel-ai-step-${++stepIndex}`;
+ if (activeStep?.messageStarted && activeStep.messageId) {
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: activeStep.messageId,
+ };
+ }
+ yield {
+ type: EventType.STEP_FINISHED,
+ stepName,
+ };
+ activeStep = undefined;
+ break;
+ }
+
+ case "text-start": {
+ const event = startStepMessage(chunk.id);
+ if (event) yield event;
+ if (!activeStep) {
+ yield {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: chunk.id,
+ role: "assistant",
+ };
+ }
+ break;
+ }
+
+ case "text-delta": {
+ const event = startStepMessage(chunk.id);
+ if (event) yield event;
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: activeStep?.messageId ?? chunk.id,
+ delta: chunk.delta,
+ };
+ break;
+ }
+
+ case "text-end":
+ if (!activeStep) {
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: chunk.id,
+ };
+ }
+ break;
+
+ case "tool-input-start":
+ if (!startedTools.has(chunk.toolCallId)) {
+ const event = startStepMessage();
+ if (event) yield event;
+ startedTools.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: chunk.toolCallId,
+ toolCallName: chunk.toolName,
+ ...toolParent(),
+ };
+ }
+ break;
+
+ case "tool-input-delta":
+ if (chunk.inputTextDelta) {
+ streamedToolArgs.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: chunk.toolCallId,
+ delta: chunk.inputTextDelta,
+ };
+ }
+ break;
+
+ case "tool-input-available":
+ case "tool-input-error": {
+ if (!startedTools.has(chunk.toolCallId)) {
+ const event = startStepMessage();
+ if (event) yield event;
+ startedTools.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: chunk.toolCallId,
+ toolCallName: chunk.toolName,
+ ...toolParent(),
+ };
+ }
+
+ if (!streamedToolArgs.has(chunk.toolCallId)) {
+ streamedToolArgs.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_ARGS,
+ toolCallId: chunk.toolCallId,
+ delta: serialize(chunk.input),
+ };
+ }
+
+ if (!endedTools.has(chunk.toolCallId)) {
+ endedTools.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_END,
+ toolCallId: chunk.toolCallId,
+ };
+ }
+
+ if (chunk.type === "tool-input-error") {
+ yield toolResult(chunk.toolCallId, chunk.errorText, chunk.errorText);
+ }
+ break;
+ }
+
+ case "tool-output-available":
+ yield toolResult(chunk.toolCallId, serialize(chunk.output));
+ break;
+
+ case "tool-output-error":
+ yield toolResult(chunk.toolCallId, chunk.errorText, chunk.errorText);
+ break;
+
+ case "tool-output-denied":
+ yield toolResult(
+ chunk.toolCallId,
+ TOOL_EXECUTION_DENIED_MESSAGE,
+ TOOL_EXECUTION_DENIED_MESSAGE,
+ );
+ break;
+
+ case "error":
+ yield {
+ type: EventType.RUN_ERROR,
+ message: chunk.errorText,
+ };
+ return;
+ }
+ }
+
+ if (activeStep?.messageStarted && activeStep.messageId) {
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: activeStep.messageId,
+ };
+ }
+ },
+});
diff --git a/packages/react-headless/src/stream/formats/__tests__/vercel-ai-message-format.test.ts b/packages/react-headless/src/stream/formats/__tests__/vercel-ai-message-format.test.ts
new file mode 100644
index 000000000..fdb9d1339
--- /dev/null
+++ b/packages/react-headless/src/stream/formats/__tests__/vercel-ai-message-format.test.ts
@@ -0,0 +1,679 @@
+import { convertToModelMessages, type UIMessage } from "ai";
+import { describe, expect, it } from "vitest";
+import { vercelAIMessageFormat } from "../../../index";
+import type { Message } from "../../../types";
+
+describe("vercelAIMessageFormat", () => {
+ describe("toApi", () => {
+ it("converts user text to a UIMessage text part", () => {
+ expect(
+ vercelAIMessageFormat.toApi([{ id: "user-1", role: "user", content: "Hello" }]),
+ ).toEqual([{ id: "user-1", role: "user", parts: [{ type: "text", text: "Hello" }] }]);
+ });
+
+ it("converts AG-UI multimodal content to UIMessage file parts", () => {
+ const messages: Message[] = [
+ {
+ id: "user-1",
+ role: "user",
+ content: [
+ { type: "text", text: "Describe these" },
+ {
+ type: "binary",
+ mimeType: "image/png",
+ data: "iVBORw0KGgo=",
+ filename: "chart.png",
+ },
+ {
+ type: "binary",
+ mimeType: "application/pdf",
+ url: "https://example.com/report.pdf",
+ filename: "report.pdf",
+ },
+ {
+ type: "audio",
+ source: { type: "data", value: "YXVkaW8=", mimeType: "audio/mpeg" },
+ },
+ {
+ type: "video",
+ source: { type: "url", value: "https://example.com/demo.mp4" },
+ },
+ ],
+ },
+ ];
+
+ expect(vercelAIMessageFormat.toApi(messages)).toEqual([
+ {
+ id: "user-1",
+ role: "user",
+ parts: [
+ { type: "text", text: "Describe these" },
+ {
+ type: "file",
+ mediaType: "image/png",
+ url: "data:image/png;base64,iVBORw0KGgo=",
+ filename: "chart.png",
+ },
+ {
+ type: "file",
+ mediaType: "application/pdf",
+ url: "https://example.com/report.pdf",
+ filename: "report.pdf",
+ },
+ {
+ type: "file",
+ mediaType: "audio/mpeg",
+ url: "data:audio/mpeg;base64,YXVkaW8=",
+ },
+ {
+ type: "file",
+ mediaType: "video/*",
+ url: "https://example.com/demo.mp4",
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("converts assistant text", () => {
+ expect(
+ vercelAIMessageFormat.toApi([
+ { id: "assistant-1", role: "assistant", content: "The answer is 42." },
+ ]),
+ ).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [{ type: "text", text: "The answer is 42." }],
+ },
+ ]);
+ });
+
+ it("omits an empty text part from a tool-only assistant message", () => {
+ expect(
+ vercelAIMessageFormat.toApi([
+ {
+ id: "assistant-tool-only",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "weather", arguments: '{"city":"Delhi"}' },
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: "assistant-tool-only",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "weather",
+ toolCallId: "call-1",
+ state: "input-available",
+ input: { city: "Delhi" },
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("defensively rejects provider-executed metadata on AG-UI-shaped input", () => {
+ const messages = [
+ {
+ id: "assistant-provider-tool",
+ role: "assistant",
+ toolCalls: [
+ {
+ id: "call-provider",
+ type: "function",
+ function: { name: "web_search", arguments: "{}" },
+ providerExecuted: true,
+ },
+ ],
+ },
+ ] as unknown as Message[];
+
+ expect(() => vercelAIMessageFormat.toApi(messages)).toThrow(
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.",
+ );
+ });
+
+ it("folds separate AG-UI tool results into dynamic tool parts", () => {
+ const messages: Message[] = [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "Checking the weather.",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "weather", arguments: '{"city":"Delhi"}' },
+ },
+ {
+ id: "call-2",
+ type: "function",
+ function: { name: "clock", arguments: "not-json" },
+ },
+ ],
+ },
+ {
+ id: "result-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"temperature":31}',
+ },
+ ];
+
+ expect(vercelAIMessageFormat.toApi(messages)).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "Checking the weather." },
+ {
+ type: "dynamic-tool",
+ toolName: "weather",
+ toolCallId: "call-1",
+ state: "output-available",
+ input: { city: "Delhi" },
+ output: '{"temperature":31}',
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "clock",
+ toolCallId: "call-2",
+ state: "input-available",
+ input: "not-json",
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("maps AG-UI tool errors to output-error parts", () => {
+ const messages: Message[] = [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "weather", arguments: "{}" },
+ },
+ ],
+ },
+ {
+ id: "result-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: "upstream failed",
+ error: "Weather service unavailable",
+ },
+ ];
+
+ expect(vercelAIMessageFormat.toApi(messages)).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "weather",
+ toolCallId: "call-1",
+ state: "output-error",
+ input: {},
+ errorText: "Weather service unavailable",
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("maps system and developer messages to the UIMessage system role", () => {
+ expect(
+ vercelAIMessageFormat.toApi([
+ { id: "system-1", role: "system", content: "System instructions" },
+ { id: "developer-1", role: "developer", content: "Developer instructions" },
+ ]),
+ ).toEqual([
+ {
+ id: "system-1",
+ role: "system",
+ parts: [{ type: "text", text: "System instructions" }],
+ },
+ {
+ id: "developer-1",
+ role: "system",
+ parts: [{ type: "text", text: "Developer instructions" }],
+ },
+ ]);
+ });
+ });
+
+ describe("fromApi", () => {
+ it("converts user text and file parts to AG-UI content", () => {
+ expect(
+ vercelAIMessageFormat.fromApi([
+ {
+ id: "user-1",
+ role: "user",
+ parts: [
+ { type: "text", text: "Read this" },
+ {
+ type: "file",
+ mediaType: "image/png",
+ filename: "image.png",
+ url: "data:image/png;base64,aW1hZ2U=",
+ },
+ {
+ type: "file",
+ mediaType: "application/pdf",
+ filename: "report.pdf",
+ url: "https://example.com/report.pdf",
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: "user-1",
+ role: "user",
+ content: [
+ { type: "text", text: "Read this" },
+ {
+ type: "binary",
+ mimeType: "image/png",
+ filename: "image.png",
+ data: "aW1hZ2U=",
+ },
+ {
+ type: "binary",
+ mimeType: "application/pdf",
+ filename: "report.pdf",
+ url: "https://example.com/report.pdf",
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("converts dynamic and static tool parts, including results and errors", () => {
+ const messages = vercelAIMessageFormat.fromApi([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "Done" },
+ {
+ type: "dynamic-tool",
+ toolName: "weather",
+ toolCallId: "call-1",
+ state: "output-available",
+ input: { city: "Delhi" },
+ output: { temperature: 31 },
+ },
+ {
+ type: "tool-search",
+ toolCallId: "call-2",
+ state: "output-error",
+ input: { query: "OpenUI" },
+ errorText: "Search failed",
+ },
+ {
+ type: "tool-email",
+ toolCallId: "call-3",
+ state: "input-available",
+ input: { to: "user@example.com" },
+ },
+ ],
+ },
+ ]);
+
+ expect(messages).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "Done",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "weather", arguments: '{"city":"Delhi"}' },
+ },
+ {
+ id: "call-2",
+ type: "function",
+ function: { name: "search", arguments: '{"query":"OpenUI"}' },
+ },
+ {
+ id: "call-3",
+ type: "function",
+ function: { name: "email", arguments: '{"to":"user@example.com"}' },
+ },
+ ],
+ },
+ {
+ id: "tool-result-call-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"temperature":31}',
+ },
+ {
+ id: "tool-result-call-2",
+ role: "tool",
+ toolCallId: "call-2",
+ content: "Search failed",
+ error: "Search failed",
+ },
+ ]);
+ });
+
+ it("preserves multi-step assistant, tool-result, assistant ordering", () => {
+ expect(
+ vercelAIMessageFormat.fromApi([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ { type: "text", text: "Let me search." },
+ {
+ type: "dynamic-tool",
+ toolName: "search",
+ toolCallId: "call-1",
+ state: "output-available",
+ input: { query: "OpenUI" },
+ output: { hits: 1 },
+ },
+ { type: "step-start" },
+ { type: "text", text: "I found the answer." },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "Let me search.",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "search", arguments: '{"query":"OpenUI"}' },
+ },
+ ],
+ },
+ {
+ id: "tool-result-call-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"hits":1}',
+ },
+ {
+ id: "assistant-1-segment-2",
+ role: "assistant",
+ content: "I found the answer.",
+ },
+ ]);
+ });
+
+ it("preserves sequential tool-only step ordering through convertToModelMessages", async () => {
+ const restored = vercelAIMessageFormat.fromApi([
+ {
+ id: "assistant-tool-steps",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ {
+ type: "dynamic-tool",
+ toolName: "lookup",
+ toolCallId: "call-1",
+ state: "output-available",
+ input: { id: "first" },
+ output: { nextId: "second" },
+ },
+ { type: "step-start" },
+ {
+ type: "dynamic-tool",
+ toolName: "lookup",
+ toolCallId: "call-2",
+ state: "output-available",
+ input: { id: "second" },
+ output: { value: "done" },
+ },
+ ],
+ },
+ ]);
+
+ expect(restored.map((message) => message.role)).toEqual([
+ "assistant",
+ "tool",
+ "assistant",
+ "tool",
+ ]);
+
+ const uiMessages = vercelAIMessageFormat.toApi(restored) as UIMessage[];
+ const modelMessages = await convertToModelMessages(uiMessages);
+
+ expect(modelMessages.map((message) => message.role)).toEqual([
+ "assistant",
+ "tool",
+ "assistant",
+ "tool",
+ ]);
+ expect(modelMessages[0]).toMatchObject({
+ role: "assistant",
+ content: [{ type: "tool-call", toolCallId: "call-1" }],
+ });
+ expect(modelMessages[2]).toMatchObject({
+ role: "assistant",
+ content: [{ type: "tool-call", toolCallId: "call-2" }],
+ });
+ });
+
+ it.each(["dynamic-tool", "tool-web_search"])(
+ "rejects provider-executed %s parts instead of changing their model-message role",
+ (type) => {
+ const part = {
+ type,
+ ...(type === "dynamic-tool" ? { toolName: "web_search" } : {}),
+ toolCallId: "call-provider",
+ state: "output-available",
+ input: { query: "OpenUI" },
+ output: { results: [] },
+ providerExecuted: true,
+ };
+
+ expect(() =>
+ vercelAIMessageFormat.fromApi([
+ { id: "assistant-provider-tool", role: "assistant", parts: [part] },
+ ]),
+ ).toThrow(
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.",
+ );
+ },
+ );
+
+ it("splits consecutive text parts while grouping consecutive tools with their segment", () => {
+ expect(
+ vercelAIMessageFormat.fromApi([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "First section." },
+ { type: "text", text: "Second section." },
+ {
+ type: "tool-weather",
+ toolCallId: "call-1",
+ state: "output-available",
+ input: { city: "Delhi" },
+ output: { temperature: 31 },
+ },
+ {
+ type: "dynamic-tool",
+ toolName: "clock",
+ toolCallId: "call-2",
+ state: "output-available",
+ input: { timezone: "Asia/Kolkata" },
+ output: "12:00",
+ },
+ { type: "text", text: "Final section." },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "First section.",
+ },
+ {
+ id: "assistant-1-segment-2",
+ role: "assistant",
+ content: "Second section.",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "weather", arguments: '{"city":"Delhi"}' },
+ },
+ {
+ id: "call-2",
+ type: "function",
+ function: { name: "clock", arguments: '{"timezone":"Asia/Kolkata"}' },
+ },
+ ],
+ },
+ {
+ id: "tool-result-call-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"temperature":31}',
+ },
+ {
+ id: "tool-result-call-2",
+ role: "tool",
+ toolCallId: "call-2",
+ content: "12:00",
+ },
+ {
+ id: "assistant-1-segment-3",
+ role: "assistant",
+ content: "Final section.",
+ },
+ ]);
+ });
+
+ it("round-trips supported AG-UI messages with documented normalization", () => {
+ const messages: Message[] = [
+ { id: "system-1", role: "system", content: "Be helpful" },
+ {
+ id: "user-1",
+ role: "user",
+ content: [
+ { type: "text", text: "Inspect" },
+ {
+ type: "binary",
+ mimeType: "image/png",
+ data: "aW1hZ2U=",
+ filename: "image.png",
+ },
+ ],
+ },
+ {
+ id: "assistant-1",
+ role: "assistant",
+ content: "I inspected it.",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "inspect", arguments: '{"detailed":true}' },
+ },
+ ],
+ },
+ {
+ id: "result-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"safe":true}',
+ },
+ ];
+
+ expect(vercelAIMessageFormat.fromApi(vercelAIMessageFormat.toApi(messages))).toEqual([
+ messages[0],
+ messages[1],
+ messages[2],
+ {
+ id: "tool-result-call-1",
+ role: "tool",
+ toolCallId: "call-1",
+ content: '{"safe":true}',
+ },
+ ]);
+ });
+
+ it("returns an empty list for a non-array payload", () => {
+ expect(vercelAIMessageFormat.fromApi(null)).toEqual([]);
+ expect(vercelAIMessageFormat.fromApi({ messages: [] })).toEqual([]);
+ });
+
+ it("skips malformed messages and malformed parts without throwing", () => {
+ expect(
+ vercelAIMessageFormat.fromApi([
+ null,
+ "message",
+ { id: 123, role: "user", parts: [] },
+ { id: "missing-parts", role: "assistant" },
+ { id: "unknown-role", role: "tool", parts: [] },
+ {
+ id: "user-1",
+ role: "user",
+ parts: [
+ null,
+ { type: "text", text: 42 },
+ { type: "file", mediaType: "image/png" },
+ { type: "text", text: "valid" },
+ ],
+ },
+ {
+ id: "assistant-1",
+ role: "assistant",
+ parts: [
+ { type: "dynamic-tool", toolName: "", toolCallId: "bad", state: "input-available" },
+ { type: "tool-", toolCallId: "bad-2", state: "input-available" },
+ { type: "tool-valid", toolCallId: 123, state: "input-available" },
+ {
+ type: "tool-valid",
+ toolCallId: "call-1",
+ state: "output-error",
+ input: {},
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ { id: "user-1", role: "user", content: "valid" },
+ {
+ id: "assistant-1",
+ role: "assistant",
+ toolCalls: [
+ {
+ id: "call-1",
+ type: "function",
+ function: { name: "valid", arguments: "{}" },
+ },
+ ],
+ },
+ ]);
+ });
+ });
+});
diff --git a/packages/react-headless/src/stream/formats/index.ts b/packages/react-headless/src/stream/formats/index.ts
index 1d627b7b6..0741c77bb 100644
--- a/packages/react-headless/src/stream/formats/index.ts
+++ b/packages/react-headless/src/stream/formats/index.ts
@@ -1,3 +1,4 @@
export * from "./langgraph-message-format";
export * from "./openai-conversation-message-format";
export * from "./openai-message-format";
+export * from "./vercel-ai-message-format";
diff --git a/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts b/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
new file mode 100644
index 000000000..5a9d1315b
--- /dev/null
+++ b/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
@@ -0,0 +1,464 @@
+import type { DynamicToolUIPart, UIMessage } from "ai";
+import type { InputContent, Message, ToolCall, ToolMessage, UserMessage } from "../../types";
+import type { MessageFormat } from "../../types/messageFormat";
+
+const PROVIDER_EXECUTED_TOOLS_UNSUPPORTED_MESSAGE =
+ "Vercel AI SDK provider-executed tools are not supported because AG-UI messages cannot preserve providerExecuted semantics.";
+
+type UnknownRecord = Record & {
+ data?: unknown;
+ errorText?: unknown;
+ filename?: unknown;
+ id?: unknown;
+ input?: unknown;
+ mediaType?: unknown;
+ mimeType?: unknown;
+ output?: unknown;
+ parts?: unknown;
+ providerExecuted?: unknown;
+ role?: unknown;
+ source?: unknown;
+ state?: unknown;
+ text?: unknown;
+ toolCallId?: unknown;
+ toolName?: unknown;
+ type?: unknown;
+ url?: unknown;
+ value?: unknown;
+};
+
+type ValidUIMessage = UnknownRecord & {
+ id: string;
+ role: "system" | "user" | "assistant";
+ parts: unknown[];
+};
+
+function isRecord(value: unknown): value is UnknownRecord {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isProviderExecuted(value: unknown): boolean {
+ return isRecord(value) && value.providerExecuted === true;
+}
+
+function serialize(value: unknown): string {
+ if (typeof value === "string") return value;
+
+ try {
+ return JSON.stringify(value) ?? String(value);
+ } catch {
+ return String(value);
+ }
+}
+
+function parseToolInput(value: string): unknown {
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return value;
+ }
+}
+
+function dataUrl(mediaType: string, data: string): string {
+ return `data:${mediaType};base64,${data}`;
+}
+
+function mediaTypeForPart(part: UnknownRecord, source: UnknownRecord): string {
+ if (typeof source.mimeType === "string") return source.mimeType;
+
+ switch (part.type) {
+ case "image":
+ return "image/*";
+ case "audio":
+ return "audio/*";
+ case "video":
+ return "video/*";
+ default:
+ return "application/octet-stream";
+ }
+}
+
+function toUserParts(content: UserMessage["content"]): UIMessage["parts"] {
+ if (typeof content === "string") {
+ return [{ type: "text", text: content }];
+ }
+
+ if (!Array.isArray(content)) return [];
+
+ return content.flatMap((part): UIMessage["parts"] => {
+ if (!isRecord(part)) return [];
+
+ if (part.type === "text" && typeof part.text === "string") {
+ return [{ type: "text", text: part.text }];
+ }
+
+ if (part.type === "binary") {
+ const mediaType =
+ typeof part.mimeType === "string" ? part.mimeType : "application/octet-stream";
+ const url =
+ typeof part.url === "string"
+ ? part.url
+ : typeof part.data === "string"
+ ? dataUrl(mediaType, part.data)
+ : undefined;
+
+ if (!url) return [];
+
+ return [
+ {
+ type: "file",
+ mediaType,
+ url,
+ ...(typeof part.filename === "string" ? { filename: part.filename } : {}),
+ },
+ ];
+ }
+
+ switch (part.type) {
+ case "image":
+ case "audio":
+ case "video":
+ case "document": {
+ if (!isRecord(part.source) || typeof part.source.value !== "string") return [];
+
+ const mediaType = mediaTypeForPart(part, part.source);
+ const url =
+ part.source.type === "data"
+ ? dataUrl(mediaType, part.source.value)
+ : part.source.type === "url"
+ ? part.source.value
+ : undefined;
+
+ return url ? [{ type: "file", mediaType, url }] : [];
+ }
+ default:
+ return [];
+ }
+ });
+}
+
+function toToolPart(toolCall: ToolCall, result: ToolMessage | undefined): DynamicToolUIPart {
+ if (isProviderExecuted(toolCall) || isProviderExecuted(result)) {
+ throw new Error(PROVIDER_EXECUTED_TOOLS_UNSUPPORTED_MESSAGE);
+ }
+
+ const input = parseToolInput(toolCall.function.arguments);
+
+ if (result?.error) {
+ return {
+ type: "dynamic-tool",
+ toolName: toolCall.function.name,
+ toolCallId: toolCall.id,
+ state: "output-error",
+ input,
+ errorText: result.error,
+ };
+ }
+
+ if (result) {
+ return {
+ type: "dynamic-tool",
+ toolName: toolCall.function.name,
+ toolCallId: toolCall.id,
+ state: "output-available",
+ input,
+ output: result.content,
+ };
+ }
+
+ return {
+ type: "dynamic-tool",
+ toolName: toolCall.function.name,
+ toolCallId: toolCall.id,
+ state: "input-available",
+ input,
+ };
+}
+
+function toVercelMessages(messages: Message[]): UIMessage[] {
+ const toolResults = new Map();
+
+ for (const message of messages) {
+ if (message.role === "tool") toolResults.set(message.toolCallId, message);
+ }
+
+ const result: UIMessage[] = [];
+
+ for (const message of messages) {
+ switch (message.role) {
+ case "user":
+ result.push({ id: message.id, role: "user", parts: toUserParts(message.content) });
+ break;
+
+ case "assistant": {
+ const parts: UIMessage["parts"] = [];
+
+ if (typeof message.content === "string" && message.content.length > 0) {
+ parts.push({ type: "text", text: message.content });
+ }
+
+ for (const toolCall of message.toolCalls ?? []) {
+ parts.push(toToolPart(toolCall, toolResults.get(toolCall.id)));
+ }
+
+ result.push({ id: message.id, role: "assistant", parts });
+ break;
+ }
+
+ case "system":
+ case "developer":
+ result.push({
+ id: message.id,
+ role: "system",
+ parts: [{ type: "text", text: message.content }],
+ });
+ break;
+
+ // Vercel UIMessage has no standalone tool-result, reasoning, or activity role.
+ // Tool results were folded into their matching assistant parts above.
+ default:
+ break;
+ }
+ }
+
+ return result;
+}
+
+function validUIMessage(value: unknown): value is ValidUIMessage {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ (value.role === "system" || value.role === "user" || value.role === "assistant") &&
+ Array.isArray(value.parts)
+ );
+}
+
+function textFromParts(parts: unknown[]): string {
+ return parts
+ .filter(
+ (part): part is UnknownRecord & { type: "text"; text: string } =>
+ isRecord(part) && part.type === "text" && typeof part.text === "string",
+ )
+ .map((part) => part.text)
+ .join("");
+}
+
+function binaryFromFilePart(part: UnknownRecord): InputContent | undefined {
+ if (part.type !== "file" || typeof part.mediaType !== "string" || typeof part.url !== "string") {
+ return undefined;
+ }
+
+ const base64Prefix = `data:${part.mediaType};base64,`;
+ const source = part.url.startsWith(base64Prefix)
+ ? { data: part.url.slice(base64Prefix.length) }
+ : { url: part.url };
+
+ return {
+ type: "binary",
+ mimeType: part.mediaType,
+ ...source,
+ ...(typeof part.filename === "string" ? { filename: part.filename } : {}),
+ };
+}
+
+function fromVercelUser(message: ValidUIMessage): UserMessage {
+ const contentParts: InputContent[] = [];
+ let hasFile = false;
+
+ for (const part of message.parts) {
+ if (!isRecord(part)) continue;
+
+ if (part.type === "text" && typeof part.text === "string") {
+ contentParts.push({ type: "text", text: part.text });
+ continue;
+ }
+
+ const binary = binaryFromFilePart(part);
+ if (binary) {
+ hasFile = true;
+ contentParts.push(binary);
+ }
+ }
+
+ return {
+ id: message.id,
+ role: "user",
+ content: hasFile ? contentParts : textFromParts(message.parts),
+ };
+}
+
+function toolName(part: UnknownRecord): string | undefined {
+ if (part.type === "dynamic-tool") {
+ return typeof part.toolName === "string" && part.toolName.length > 0
+ ? part.toolName
+ : undefined;
+ }
+
+ if (typeof part.type !== "string" || !part.type.startsWith("tool-")) return undefined;
+ const name = part.type.slice("tool-".length);
+ return name || undefined;
+}
+
+function hasOwn(record: UnknownRecord, key: string): boolean {
+ return Object.prototype.hasOwnProperty.call(record, key);
+}
+
+function toolResultFromPart(part: UnknownRecord): ToolMessage | undefined {
+ if (typeof part.toolCallId !== "string") return undefined;
+
+ if (part.state === "output-available" && hasOwn(part, "output")) {
+ return {
+ id: `tool-result-${part.toolCallId}`,
+ role: "tool",
+ toolCallId: part.toolCallId,
+ content: serialize(part.output),
+ };
+ }
+
+ if (part.state === "output-error" && typeof part.errorText === "string") {
+ return {
+ id: `tool-result-${part.toolCallId}`,
+ role: "tool",
+ toolCallId: part.toolCallId,
+ content: part.errorText,
+ error: part.errorText,
+ };
+ }
+
+ if (part.state === "output-denied") {
+ const error = "Tool execution was denied";
+ return {
+ id: `tool-result-${part.toolCallId}`,
+ role: "tool",
+ toolCallId: part.toolCallId,
+ content: error,
+ error,
+ };
+ }
+
+ return undefined;
+}
+
+function appendAssistantSegments(message: ValidUIMessage, result: Message[]): void {
+ let segmentIndex = 0;
+ let segmentStarted = false;
+ let text = "";
+ let toolCalls: ToolCall[] = [];
+ let toolResults: ToolMessage[] = [];
+ const hasStepMarkers = message.parts.some((part) => isRecord(part) && part.type === "step-start");
+
+ const hasBody = () => text.length > 0 || toolCalls.length > 0;
+
+ const flush = (force = false) => {
+ if (!force && !segmentStarted) return;
+
+ segmentIndex += 1;
+ result.push({
+ id: segmentIndex === 1 ? message.id : `${message.id}-segment-${segmentIndex}`,
+ role: "assistant",
+ ...(text ? { content: text } : {}),
+ ...(toolCalls.length ? { toolCalls } : {}),
+ });
+ result.push(...toolResults);
+
+ text = "";
+ toolCalls = [];
+ toolResults = [];
+ segmentStarted = false;
+ };
+
+ for (const value of message.parts) {
+ if (!isRecord(value)) continue;
+
+ if (value.type === "step-start") {
+ if (hasBody()) flush();
+ continue;
+ }
+
+ if (value.type === "text" && typeof value.text === "string") {
+ // AI SDK step markers are authoritative. Older/custom UIMessage data may
+ // omit them, so retain the previous text-part boundary behavior there.
+ if (!hasStepMarkers && hasBody()) flush();
+ segmentStarted = true;
+ text += value.text;
+ continue;
+ }
+
+ const name = toolName(value);
+ if (!name || typeof value.toolCallId !== "string") continue;
+
+ if (value.providerExecuted === true) {
+ throw new Error(PROVIDER_EXECUTED_TOOLS_UNSUPPORTED_MESSAGE);
+ }
+
+ segmentStarted = true;
+ toolCalls.push({
+ id: value.toolCallId,
+ type: "function",
+ function: {
+ name,
+ arguments: hasOwn(value, "input") ? serialize(value.input) : "",
+ },
+ });
+
+ const toolResult = toolResultFromPart(value);
+ if (toolResult) toolResults.push(toolResult);
+ }
+
+ // AI SDK permits an assistant UIMessage with no parts. Preserve it as one
+ // empty assistant rather than dropping the source message entirely.
+ flush(segmentIndex === 0);
+}
+
+function fromVercelMessages(data: unknown): Message[] {
+ if (!Array.isArray(data)) return [];
+
+ const result: Message[] = [];
+
+ for (const value of data) {
+ if (!validUIMessage(value)) continue;
+ const message = value;
+
+ if (message.role === "user") {
+ result.push(fromVercelUser(message));
+ continue;
+ }
+
+ const text = textFromParts(message.parts);
+
+ if (message.role === "system") {
+ result.push({ id: message.id, role: "system", content: text });
+ continue;
+ }
+
+ appendAssistantSegments(message, result);
+ }
+
+ return result;
+}
+
+/**
+ * Converts messages between AG-UI and Vercel AI SDK v6 `UIMessage` format.
+ *
+ * AG-UI tool-result messages are folded into the matching assistant tool part,
+ * because `UIMessage` represents a tool invocation and its result as one part.
+ * Outbound tool calls use `dynamic-tool` parts because AG-UI messages do not
+ * carry the static tool schema needed to select a `tool-${name}` part. Inbound
+ * conversion accepts both dynamic and static tool parts.
+ *
+ * Vercel `UIMessage` has no developer role, so both AG-UI system and developer
+ * messages map to its system role.
+ *
+ * Provider-executed tools are rejected because the AG-UI message model has no
+ * execution-provenance field and therefore cannot preserve their requirement
+ * that results remain in the assistant message.
+ */
+export const vercelAIMessageFormat: MessageFormat = {
+ toApi(messages: Message[]): UIMessage[] {
+ return toVercelMessages(messages);
+ },
+
+ fromApi(data: unknown): Message[] {
+ return fromVercelMessages(data);
+ },
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c1404a3a..e8e2fad35 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1888,6 +1888,9 @@ importers:
'@types/react':
specifier: 'catalog:'
version: 19.2.17
+ ai:
+ specifier: ^6.0.236
+ version: 6.0.236(zod@4.4.3)
openai:
specifier: ^6.22.0
version: 6.49.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(ws@8.21.1)(zod@4.4.3)