From 08db96d09f0494cfd69625bc261bcc67cd18ef0a Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Wed, 5 Aug 2026 19:24:30 +0530
Subject: [PATCH 1/3] feat(react-headless): add Vercel AI SDK adapter
---
.../agent/reference/adapters-and-formats.mdx | 39 +-
.../docs/agent/reference/self-hosting.mdx | 1 +
packages/react-headless/README.md | 22 +-
packages/react-headless/package.json | 10 +-
packages/react-headless/src/index.ts | 2 +
.../adapters/__tests__/vercel-ai-sdk.test.ts | 342 +++++++++++
.../src/stream/adapters/index.ts | 1 +
.../src/stream/adapters/vercel-ai-sdk.ts | 188 ++++++
.../vercel-ai-message-format.test.ts | 547 ++++++++++++++++++
.../src/stream/formats/index.ts | 1 +
.../formats/vercel-ai-message-format.ts | 440 ++++++++++++++
pnpm-lock.yaml | 3 +
12 files changed, 1591 insertions(+), 5 deletions(-)
create mode 100644 packages/react-headless/src/stream/adapters/__tests__/vercel-ai-sdk.test.ts
create mode 100644 packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
create mode 100644 packages/react-headless/src/stream/formats/__tests__/vercel-ai-message-format.test.ts
create mode 100644 packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx
index a20d8caa1..ebcf00c9f 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.
+- **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`.)
+- **Lossy fields:** this integration does not currently preserve reasoning, sources, custom data, assistant files, approvals, or provider metadata when converting stored messages.
+- **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/packages/react-headless/README.md b/packages/react-headless/README.md
index 046526c15..0af453a73 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,19 @@ 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,
+});
+```
### Custom adapter
@@ -213,6 +232,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/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..57d5eda9f
--- /dev/null
+++ b/packages/react-headless/src/stream/adapters/__tests__/vercel-ai-sdk.test.ts
@@ -0,0 +1,342 @@
+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 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("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..9d2915216
--- /dev/null
+++ b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
@@ -0,0 +1,188 @@
+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 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();
+
+ for await (const chunk of readChunks(chunks)) {
+ switch (chunk.type) {
+ case "text-start":
+ yield {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: chunk.id,
+ role: "assistant",
+ };
+ break;
+
+ case "text-delta":
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: chunk.id,
+ delta: chunk.delta,
+ };
+ break;
+
+ case "text-end":
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: chunk.id,
+ };
+ break;
+
+ case "tool-input-start":
+ if (!startedTools.has(chunk.toolCallId)) {
+ startedTools.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: chunk.toolCallId,
+ toolCallName: chunk.toolName,
+ };
+ }
+ 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)) {
+ startedTools.add(chunk.toolCallId);
+ yield {
+ type: EventType.TOOL_CALL_START,
+ toolCallId: chunk.toolCallId,
+ toolCallName: chunk.toolName,
+ };
+ }
+
+ 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,
+ };
+ break;
+ }
+ }
+ },
+});
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..9668316b2
--- /dev/null
+++ b/packages/react-headless/src/stream/formats/__tests__/vercel-ai-message-format.test.ts
@@ -0,0 +1,547 @@
+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("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("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..601265056
--- /dev/null
+++ b/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
@@ -0,0 +1,440 @@
+import type { DynamicToolUIPart, UIMessage } from "ai";
+import type { InputContent, Message, ToolCall, ToolMessage, UserMessage } from "../../types";
+import type { MessageFormat } from "../../types/messageFormat";
+
+type UnknownRecord = Record & {
+ data?: unknown;
+ errorText?: unknown;
+ filename?: unknown;
+ id?: unknown;
+ input?: unknown;
+ mediaType?: unknown;
+ mimeType?: unknown;
+ output?: unknown;
+ parts?: 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 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 {
+ 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 (message.content !== undefined) {
+ 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 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 === "text" && typeof value.text === "string") {
+ // Each accumulated TextUIPart corresponds to a text-start item in the
+ // native stream. processStreamedMessage starts a new assistant message
+ // when a new text item follows existing text or tool calls, so mirror
+ // that boundary when rebuilding history from a final UIMessage.
+ if (hasBody()) flush();
+ segmentStarted = true;
+ text += value.text;
+ continue;
+ }
+
+ const name = toolName(value);
+ if (!name || typeof value.toolCallId !== "string") continue;
+
+ 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.
+ */
+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)
From ccabb3798b56e60ab6462093393221728386173b Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Wed, 5 Aug 2026 20:06:09 +0530
Subject: [PATCH 2/3] fix(react-headless): preserve Vercel AI stream semantics
---
docs/content/docs/api-reference/index.mdx | 2 +-
.../docs/api-reference/react-headless.mdx | 8 +
packages/react-headless/README.md | 4 +
.../vercel-ai-sdk.integration.test.ts | 140 ++++++++++++++++++
.../adapters/__tests__/vercel-ai-sdk.test.ts | 62 ++++++++
.../src/stream/adapters/vercel-ai-sdk.ts | 30 ++++
.../vercel-ai-message-format.test.ts | 132 +++++++++++++++++
.../formats/vercel-ai-message-format.ts | 36 ++++-
.../src/stream/processStreamedMessage.ts | 69 ++++++---
9 files changed, 459 insertions(+), 24 deletions(-)
create mode 100644 packages/react-headless/src/stream/__tests__/vercel-ai-sdk.integration.test.ts
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 0af453a73..c79650864 100644
--- a/packages/react-headless/README.md
+++ b/packages/react-headless/README.md
@@ -199,6 +199,10 @@ const llm = fetchLLM({
});
```
+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
Implement the `StreamProtocolAdapter` interface:
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
index 57d5eda9f..2fac5c262 100644
--- 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
@@ -35,6 +35,38 @@ async function parse(body: string): Promise {
}
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",
+ messageBoundary: true,
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-1",
+ messageBoundary: true,
+ },
+ {
+ type: EventType.STEP_STARTED,
+ stepName: "vercel-ai-step-2",
+ messageBoundary: true,
+ },
+ {
+ type: EventType.STEP_FINISHED,
+ stepName: "vercel-ai-step-2",
+ messageBoundary: true,
+ },
+ ]);
+ });
+
it("maps text start, delta, and end chunks", async () => {
const events = await parse(
sse({ type: "text-start", id: "text-1" }) +
@@ -316,6 +348,36 @@ describe("vercelAIAdapter", () => {
]);
});
+ 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 })),
diff --git a/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
index 9d2915216..f652a9eeb 100644
--- a/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
+++ b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
@@ -3,6 +3,8 @@ 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 {
@@ -78,9 +80,37 @@ export const vercelAIAdapter = (): StreamProtocolAdapter => ({
const startedTools = new Set();
const streamedToolArgs = new Set();
const endedTools = new Set();
+ let stepIndex = 0;
+ let activeStepName: string | undefined;
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":
+ activeStepName = `vercel-ai-step-${++stepIndex}`;
+ yield {
+ type: EventType.STEP_STARTED,
+ stepName: activeStepName,
+ // AG-UI step events can also describe arbitrary progress. Mark
+ // only AI SDK model-step events as assistant message boundaries.
+ messageBoundary: true,
+ } as AGUIEvent;
+ break;
+
+ case "finish-step": {
+ const stepName = activeStepName ?? `vercel-ai-step-${++stepIndex}`;
+ yield {
+ type: EventType.STEP_FINISHED,
+ stepName,
+ messageBoundary: true,
+ } as AGUIEvent;
+ activeStepName = undefined;
+ break;
+ }
+
case "text-start":
yield {
type: EventType.TEXT_MESSAGE_START,
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
index 9668316b2..fdb9d1339 100644
--- 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
@@ -1,3 +1,4 @@
+import { convertToModelMessages, type UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { vercelAIMessageFormat } from "../../../index";
import type { Message } from "../../../types";
@@ -88,6 +89,60 @@ describe("vercelAIMessageFormat", () => {
]);
});
+ 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[] = [
{
@@ -369,6 +424,83 @@ describe("vercelAIMessageFormat", () => {
]);
});
+ 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([
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
index 601265056..5a9d1315b 100644
--- a/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
+++ b/packages/react-headless/src/stream/formats/vercel-ai-message-format.ts
@@ -2,6 +2,9 @@ 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;
@@ -12,6 +15,7 @@ type UnknownRecord = Record & {
mimeType?: unknown;
output?: unknown;
parts?: unknown;
+ providerExecuted?: unknown;
role?: unknown;
source?: unknown;
state?: unknown;
@@ -33,6 +37,10 @@ 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;
@@ -130,6 +138,10 @@ function toUserParts(content: UserMessage["content"]): UIMessage["parts"] {
}
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) {
@@ -181,7 +193,7 @@ function toVercelMessages(messages: Message[]): UIMessage[] {
case "assistant": {
const parts: UIMessage["parts"] = [];
- if (message.content !== undefined) {
+ if (typeof message.content === "string" && message.content.length > 0) {
parts.push({ type: "text", text: message.content });
}
@@ -333,6 +345,7 @@ function appendAssistantSegments(message: ValidUIMessage, result: Message[]): vo
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;
@@ -357,12 +370,15 @@ function appendAssistantSegments(message: ValidUIMessage, result: Message[]): vo
for (const value of message.parts) {
if (!isRecord(value)) continue;
- if (value.type === "text" && typeof value.text === "string") {
- // Each accumulated TextUIPart corresponds to a text-start item in the
- // native stream. processStreamedMessage starts a new assistant message
- // when a new text item follows existing text or tool calls, so mirror
- // that boundary when rebuilding history from a final UIMessage.
+ 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;
@@ -371,6 +387,10 @@ function appendAssistantSegments(message: ValidUIMessage, result: Message[]): vo
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,
@@ -428,6 +448,10 @@ function fromVercelMessages(data: unknown): Message[] {
*
* 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[] {
diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts
index e22f46291..648a4a725 100644
--- a/packages/react-headless/src/stream/processStreamedMessage.ts
+++ b/packages/react-headless/src/stream/processStreamedMessage.ts
@@ -45,6 +45,11 @@ export const processStreamedMessage = async ({
// The wire message-item id whose text currently streams into currentMessage.
let currentTextItemId: string | null = null;
+ // AI SDK can emit multiple text items inside one stitched model step. When
+ // an adapter marks a step as a message boundary, that step—not each text id—
+ // controls segmentation so live state matches reconstructed UIMessage history.
+ let isInsideMessageBoundaryStep = false;
+
// Tool messages by toolCallId, so repeated TOOL_CALL_RESULTs for the same
// call UPDATE one message in place instead of duplicating it.
const toolMessagesByCallId = new Map();
@@ -62,6 +67,29 @@ export const processStreamedMessage = async ({
});
};
+ const hasCurrentMessageBody = () =>
+ (currentMessage.content?.length ?? 0) > 0 || (currentMessage.toolCalls?.length ?? 0) > 0;
+
+ const startNewAssistantMessage = () => {
+ if (!hasCurrentMessageBody()) return;
+
+ if (rafId !== null) {
+ // Flush the pending update so the finished segment's final state lands
+ // before the next segment is created.
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ if (!isFirst) updateMessage(currentMessage);
+ }
+
+ currentMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: "",
+ toolCalls: [],
+ };
+ isFirst = true;
+ };
+
for await (const event of adapter.parse(response)) {
switch (event.type) {
// TEXT_MESSAGE_CHUNK and TEXT_MESSAGE_CONTENT are very similar events but TEXT_MESSAGE_CHUNK
@@ -163,28 +191,35 @@ export const processStreamedMessage = async ({
// message so the live structure matches what reload reconstructs from
// storage
const startId = (event as { messageId?: string }).messageId ?? null;
- const hasBody =
- (currentMessage.content?.length ?? 0) > 0 || (currentMessage.toolCalls?.length ?? 0) > 0;
- if (hasBody && startId !== currentTextItemId) {
- if (rafId !== null) {
- // Flush the pending update so the finished segment's final state
- // lands before the next segment is created.
- cancelAnimationFrame(rafId);
- rafId = null;
- if (!isFirst) updateMessage(currentMessage);
- }
- currentMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- content: "",
- toolCalls: [],
- };
- isFirst = true;
+ if (!isInsideMessageBoundaryStep && startId !== currentTextItemId) {
+ startNewAssistantMessage();
}
currentTextItemId = startId;
break;
}
+ case EventType.STEP_STARTED: {
+ // Vercel AI SDK step markers separate stitched model calls. AG-UI also
+ // uses STEP_STARTED for generic progress, so only split events that an
+ // adapter explicitly identifies as a message boundary.
+ const boundary = event as typeof event & { messageBoundary?: boolean };
+ if (boundary.messageBoundary === true) {
+ startNewAssistantMessage();
+ currentTextItemId = null;
+ isInsideMessageBoundaryStep = true;
+ }
+ // Lifecycle-only events must not create an empty assistant message.
+ continue;
+ }
+
+ case EventType.STEP_FINISHED: {
+ // The next marked STEP_STARTED performs the split. Keeping the current
+ // segment active here also lets the final segment flush normally.
+ const boundary = event as typeof event & { messageBoundary?: boolean };
+ if (boundary.messageBoundary === true) isInsideMessageBoundaryStep = false;
+ continue;
+ }
+
case EventType.TOOL_CALL_RESULT: {
// Result landed → no longer executing / in flight.
clearToolExecuting(event.toolCallId);
From d82548fed49268dc5bc2bd6abae47ae0b8ee64da Mon Sep 17 00:00:00 2001
From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com>
Date: Thu, 6 Aug 2026 10:30:08 +0530
Subject: [PATCH 3/3] fix(react-headless): normalize Vercel steps as AG-UI
---
.../agent/reference/adapters-and-formats.mdx | 4 +-
.../adapters/__tests__/vercel-ai-sdk.test.ts | 111 +++++++++++++++++-
.../src/stream/adapters/vercel-ai-sdk.ts | 103 ++++++++++++----
.../src/stream/processStreamedMessage.ts | 69 +++--------
4 files changed, 204 insertions(+), 83 deletions(-)
diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx
index ebcf00c9f..6ac55f745 100644
--- a/docs/content/docs/agent/reference/adapters-and-formats.mdx
+++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx
@@ -444,7 +444,7 @@ const llm = fetchLLM({
- **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.
+- **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.
@@ -548,7 +548,7 @@ 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`.)
-- **Lossy fields:** this integration does not currently preserve reasoning, sources, custom data, assistant files, approvals, or provider metadata when converting stored messages.
+- **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`
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
index 2fac5c262..e517ca882 100644
--- 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
@@ -47,22 +47,109 @@ describe("vercelAIAdapter", () => {
{
type: EventType.STEP_STARTED,
stepName: "vercel-ai-step-1",
- messageBoundary: true,
},
{
type: EventType.STEP_FINISHED,
stepName: "vercel-ai-step-1",
- messageBoundary: true,
},
{
type: EventType.STEP_STARTED,
stepName: "vercel-ai-step-2",
- messageBoundary: true,
},
{
type: EventType.STEP_FINISHED,
stepName: "vercel-ai-step-2",
- messageBoundary: true,
+ },
+ ]);
+ });
+
+ 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",
},
]);
});
@@ -348,6 +435,22 @@ describe("vercelAIAdapter", () => {
]);
});
+ 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(
diff --git a/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
index f652a9eeb..fea294e4c 100644
--- a/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
+++ b/packages/react-headless/src/stream/adapters/vercel-ai-sdk.ts
@@ -81,7 +81,33 @@ export const vercelAIAdapter = (): StreamProtocolAdapter => ({
const streamedToolArgs = new Set();
const endedTools = new Set();
let stepIndex = 0;
- let activeStepName: string | undefined;
+
+ // 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) {
@@ -89,58 +115,75 @@ export const vercelAIAdapter = (): StreamProtocolAdapter => ({
}
switch (chunk.type) {
- case "start-step":
- activeStepName = `vercel-ai-step-${++stepIndex}`;
+ case "start-step": {
+ const stepName = `vercel-ai-step-${++stepIndex}`;
+ activeStep = { stepName, messageStarted: false };
yield {
type: EventType.STEP_STARTED,
- stepName: activeStepName,
- // AG-UI step events can also describe arbitrary progress. Mark
- // only AI SDK model-step events as assistant message boundaries.
- messageBoundary: true,
- } as AGUIEvent;
+ stepName,
+ };
break;
+ }
case "finish-step": {
- const stepName = activeStepName ?? `vercel-ai-step-${++stepIndex}`;
+ 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,
- messageBoundary: true,
- } as AGUIEvent;
- activeStepName = undefined;
+ };
+ activeStep = undefined;
break;
}
- case "text-start":
- yield {
- type: EventType.TEXT_MESSAGE_START,
- messageId: chunk.id,
- role: "assistant",
- };
+ 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":
+ case "text-delta": {
+ const event = startStepMessage(chunk.id);
+ if (event) yield event;
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
- messageId: chunk.id,
+ messageId: activeStep?.messageId ?? chunk.id,
delta: chunk.delta,
};
break;
+ }
case "text-end":
- yield {
- type: EventType.TEXT_MESSAGE_END,
- messageId: chunk.id,
- };
+ 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;
@@ -159,11 +202,14 @@ export const vercelAIAdapter = (): StreamProtocolAdapter => ({
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(),
};
}
@@ -211,8 +257,15 @@ export const vercelAIAdapter = (): StreamProtocolAdapter => ({
type: EventType.RUN_ERROR,
message: chunk.errorText,
};
- break;
+ return;
}
}
+
+ if (activeStep?.messageStarted && activeStep.messageId) {
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: activeStep.messageId,
+ };
+ }
},
});
diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts
index 648a4a725..e22f46291 100644
--- a/packages/react-headless/src/stream/processStreamedMessage.ts
+++ b/packages/react-headless/src/stream/processStreamedMessage.ts
@@ -45,11 +45,6 @@ export const processStreamedMessage = async ({
// The wire message-item id whose text currently streams into currentMessage.
let currentTextItemId: string | null = null;
- // AI SDK can emit multiple text items inside one stitched model step. When
- // an adapter marks a step as a message boundary, that step—not each text id—
- // controls segmentation so live state matches reconstructed UIMessage history.
- let isInsideMessageBoundaryStep = false;
-
// Tool messages by toolCallId, so repeated TOOL_CALL_RESULTs for the same
// call UPDATE one message in place instead of duplicating it.
const toolMessagesByCallId = new Map();
@@ -67,29 +62,6 @@ export const processStreamedMessage = async ({
});
};
- const hasCurrentMessageBody = () =>
- (currentMessage.content?.length ?? 0) > 0 || (currentMessage.toolCalls?.length ?? 0) > 0;
-
- const startNewAssistantMessage = () => {
- if (!hasCurrentMessageBody()) return;
-
- if (rafId !== null) {
- // Flush the pending update so the finished segment's final state lands
- // before the next segment is created.
- cancelAnimationFrame(rafId);
- rafId = null;
- if (!isFirst) updateMessage(currentMessage);
- }
-
- currentMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- content: "",
- toolCalls: [],
- };
- isFirst = true;
- };
-
for await (const event of adapter.parse(response)) {
switch (event.type) {
// TEXT_MESSAGE_CHUNK and TEXT_MESSAGE_CONTENT are very similar events but TEXT_MESSAGE_CHUNK
@@ -191,35 +163,28 @@ export const processStreamedMessage = async ({
// message so the live structure matches what reload reconstructs from
// storage
const startId = (event as { messageId?: string }).messageId ?? null;
- if (!isInsideMessageBoundaryStep && startId !== currentTextItemId) {
- startNewAssistantMessage();
+ const hasBody =
+ (currentMessage.content?.length ?? 0) > 0 || (currentMessage.toolCalls?.length ?? 0) > 0;
+ if (hasBody && startId !== currentTextItemId) {
+ if (rafId !== null) {
+ // Flush the pending update so the finished segment's final state
+ // lands before the next segment is created.
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ if (!isFirst) updateMessage(currentMessage);
+ }
+ currentMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: "",
+ toolCalls: [],
+ };
+ isFirst = true;
}
currentTextItemId = startId;
break;
}
- case EventType.STEP_STARTED: {
- // Vercel AI SDK step markers separate stitched model calls. AG-UI also
- // uses STEP_STARTED for generic progress, so only split events that an
- // adapter explicitly identifies as a message boundary.
- const boundary = event as typeof event & { messageBoundary?: boolean };
- if (boundary.messageBoundary === true) {
- startNewAssistantMessage();
- currentTextItemId = null;
- isInsideMessageBoundaryStep = true;
- }
- // Lifecycle-only events must not create an empty assistant message.
- continue;
- }
-
- case EventType.STEP_FINISHED: {
- // The next marked STEP_STARTED performs the split. Keeping the current
- // segment active here also lets the final segment flush normally.
- const boundary = event as typeof event & { messageBoundary?: boolean };
- if (boundary.messageBoundary === true) isInsideMessageBoundaryStep = false;
- continue;
- }
-
case EventType.TOOL_CALL_RESULT: {
// Result landed → no longer executing / in flight.
clearToolExecuting(event.toolCallId);