assistant-stream
Version:
Streaming utilities for AI assistants
2,231 lines • 70.9 kB
text/typescript
import { describe, expect, it, vi } from "vitest";
import { UIMessageStreamDecoder } from "./UIMessageStream";
import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
import { NO_RESULT } from "../../tool/ToolResponse";
import { AssistantMessageAccumulator } from "../../accumulators/assistant-message-accumulator";
import { toolResultStream } from "../../tool/toolResultStream";
import type { AssistantMessage } from "../../utils/types";
import { promiseWithResolvers } from "../../../utils/promiseWithResolvers";
// Helper function to collect all chunks from a stream
async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader();
const chunks: T[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
} finally {
reader.releaseLock();
}
return chunks;
}
// Helper function to create a UI Message Stream from events
function createUIMessageStream(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const sseText = events.map((e) => `data: ${e}\n\n`).join("");
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(sseText));
controller.close();
},
});
}
describe("UIMessageStreamDecoder", () => {
it.each([
{ suffix: ":" + "x".repeat(100), maxLineLength: 64, maxEventLength: 256 },
{
suffix: "data: " + "x".repeat(100) + "\n\n",
maxLineLength: 256,
maxEventLength: 64,
},
])(
"stops before an oversized suffix after [DONE] ($maxLineLength/$maxEventLength)",
async ({ suffix, ...limits }) => {
const cancelled = promiseWithResolvers<void>();
const input = new ReadableStream<Uint8Array<ArrayBuffer>>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"data-test","data":1}\n\ndata: [DONE]\n\n' +
suffix,
),
);
},
cancel() {
cancelled.resolve();
},
});
const chunks = await collectChunks(
input.pipeThrough(new UIMessageStreamDecoder(limits)),
);
expect(chunks).toEqual([
{ type: "data", data: [{ name: "test", data: 1 }], path: [] },
]);
await cancelled.promise;
},
);
it.each(["message-finish", "part-start"])(
"can cancel at %s with pending tool arguments",
async (cancelAt) => {
const source = createUIMessageStream([
JSON.stringify({
type: "tool-input-start",
toolCallId: "weather",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "weather",
inputTextDelta: "{}",
}),
JSON.stringify({ type: "finish", finishReason: "error" }),
"[DONE]",
]).pipeThrough(new UIMessageStreamDecoder());
const reader = source.getReader();
try {
let finished = false;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value.type === cancelAt) {
finished = true;
await reader.cancel("generation failed");
break;
}
}
expect(finished).toBe(true);
expect(await reader.read()).toEqual({ done: true, value: undefined });
} finally {
reader.releaseLock();
}
expect(source.locked).toBe(false);
},
);
it("flushes pending tool arguments and results when the done marker cancels an open input", async () => {
const cancelled = promiseWithResolvers<void>();
const frames = [
{ type: "tool-input-start", toolCallId: "weather", toolName: "weather" },
{
type: "tool-input-delta",
toolCallId: "weather",
inputTextDelta: '{"city":"Berlin"}',
},
{
type: "tool-output-available",
toolCallId: "weather",
output: { temp: 20 },
},
{ type: "tool-input-start", toolCallId: "clock", toolName: "clock" },
{ type: "tool-input-delta", toolCallId: "clock", inputTextDelta: "{}" },
{ type: "finish", finishReason: "tool-calls" },
];
const input = new ReadableStream<Uint8Array<ArrayBuffer>>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
frames
.map((frame) => `data: ${JSON.stringify(frame)}\n\n`)
.join("") +
'data: [DONE]\n\ndata: {"type":"error","errorText":"after done"}\n\n',
),
);
},
cancel() {
cancelled.resolve();
},
});
const messages = await collectChunks(
input
.pipeThrough(new UIMessageStreamDecoder())
.pipeThrough(new AssistantMessageAccumulator()),
);
expect(messages.at(-1)?.content).toEqual([
expect.objectContaining({
type: "tool-call",
toolCallId: "weather",
toolName: "weather",
argsText: '{"city":"Berlin"}',
result: { temp: 20 },
}),
expect.objectContaining({
type: "tool-call",
toolCallId: "clock",
toolName: "clock",
argsText: "{}",
}),
]);
expect(messages.at(-1)?.status).toEqual({
type: "requires-action",
reason: "tool-calls",
});
await cancelled.promise;
});
it("enforces the configured SSE line limit", async () => {
await expect(
collectChunks(
createUIMessageStream(["[DONE]"]).pipeThrough(
new UIMessageStreamDecoder({ maxLineLength: 5 }),
),
),
).rejects.toThrow("SSE line exceeds maxLineLength");
});
it("enforces the configured SSE event limit", async () => {
await expect(
collectChunks(
createUIMessageStream(["[DONE]"]).pipeThrough(
new UIMessageStreamDecoder({ maxEventLength: 5 }),
),
),
).rejects.toThrow("SSE event exceeds maxEventLength");
});
it("should decode text deltas", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: " world" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const textDeltas = chunks.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
);
expect(textDeltas).toHaveLength(2);
expect(textDeltas[0]?.textDelta).toBe("Hello");
expect(textDeltas[1]?.textDelta).toBe(" world");
});
it("should decode legacy textDelta text deltas", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", textDelta: "Hello" }),
JSON.stringify({ type: "text-delta", textDelta: " world" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
// Find text-delta chunks
const textDeltas = chunks.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
);
expect(textDeltas).toHaveLength(2);
expect(textDeltas[0]?.textDelta).toBe("Hello");
expect(textDeltas[1]?.textDelta).toBe(" world");
});
it("should decode reasoning parts", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "reasoning-start", id: "reasoning_1" }),
JSON.stringify({ type: "reasoning-delta", delta: "Let me think..." }),
JSON.stringify({ type: "reasoning-end" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
// Find part-start for reasoning
const partStarts = chunks.filter(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start",
);
expect(partStarts.some((p) => p.part.type === "reasoning")).toBe(true);
});
it("should decode tool calls", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-call-start",
id: "tc_1",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({ type: "tool-call-delta", argsText: '{"city":' }),
JSON.stringify({ type: "tool-call-delta", argsText: '"NYC"}' }),
JSON.stringify({ type: "tool-call-end" }),
JSON.stringify({
type: "tool-result",
toolCallId: "call_abc",
result: { temp: 72 },
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
// Find tool-call part-start
const toolCallStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStart).toBeDefined();
if (toolCallStart?.part.type === "tool-call") {
expect(toolCallStart.part.toolName).toBe("weather");
expect(toolCallStart.part.toolCallId).toBe("call_abc");
}
// Find result
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result).toBeDefined();
expect(result?.result).toEqual({ temp: 72 });
});
it("should decode source-url parts", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "source-url",
sourceId: "src_1",
url: "https://example.com",
title: "Example",
}),
JSON.stringify({
type: "source-url",
sourceId: "src_2",
url: "https://example.org",
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const sourceStarts = chunks.filter(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "source",
);
expect(sourceStarts).toHaveLength(2);
if (sourceStarts[0]?.part.type === "source") {
expect(sourceStarts[0].part.id).toBe("src_1");
expect(sourceStarts[0].part.url).toBe("https://example.com");
expect(sourceStarts[0].part.title).toBe("Example");
}
if (sourceStarts[1]?.part.type === "source") {
expect(sourceStarts[1].part.id).toBe("src_2");
expect(sourceStarts[1].part.url).toBe("https://example.org");
expect(sourceStarts[1].part.title).toBeUndefined();
}
});
it("should decode legacy source parts", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "source",
source: {
sourceType: "url",
id: "src_1",
url: "https://example.com",
title: "Example",
},
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const sourceStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "source",
);
expect(sourceStart).toBeDefined();
if (sourceStart?.part.type === "source") {
expect(sourceStart.part.id).toBe("src_1");
expect(sourceStart.part.url).toBe("https://example.com");
expect(sourceStart.part.title).toBe("Example");
}
});
it("should decode source-document parts", async () => {
const messages = await collectChunks(
createUIMessageStream([
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "source-document",
sourceId: "src_1",
mediaType: "text/plain",
title: "ai.pdf",
filename: "ai.pdf",
}),
JSON.stringify({ type: "finish", finishReason: "stop" }),
"[DONE]",
])
.pipeThrough(new UIMessageStreamDecoder())
.pipeThrough(new AssistantMessageAccumulator()),
);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({
type: "source",
sourceType: "document",
id: "src_1",
title: "ai.pdf",
mediaType: "text/plain",
filename: "ai.pdf",
}),
]);
expect(messages.at(-1)!.parts[0]).not.toHaveProperty("url");
});
it("should decode file parts", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "file",
url: "https://example.com/image.png",
mediaType: "image/png",
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const fileStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "file",
);
expect(fileStart).toBeDefined();
if (fileStart?.part.type === "file") {
expect(fileStart.part.mimeType).toBe("image/png");
expect(fileStart.part.data).toBe("https://example.com/image.png");
}
});
it("should decode legacy file parts", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "file",
file: {
mimeType: "image/png",
data: "base64data...",
},
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const fileStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "file",
);
expect(fileStart).toBeDefined();
if (fileStart?.part.type === "file") {
expect(fileStart.part.mimeType).toBe("image/png");
expect(fileStart.part.data).toBe("base64data...");
}
});
it("should handle data-* chunks", async () => {
const onData = vi.fn();
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "data-weather",
data: { temp: 72, city: "NYC" },
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(
new UIMessageStreamDecoder({ onData }),
);
await collectChunks(decodedStream);
expect(onData).toHaveBeenCalledWith({
type: "data-weather",
name: "weather",
data: { temp: 72, city: "NYC" },
transient: undefined,
});
});
it("should handle transient data-* chunks", async () => {
const onData = vi.fn();
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "data-progress",
transient: true,
data: { percent: 50 },
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(
new UIMessageStreamDecoder({ onData }),
);
const chunks = await collectChunks(decodedStream);
// Transient data should call onData
expect(onData).toHaveBeenCalledWith({
type: "data-progress",
name: "progress",
data: { percent: 50 },
transient: true,
});
// Transient data should NOT emit a data chunk
const dataChunks = chunks.filter((c) => c.type === "data");
expect(dataChunks).toHaveLength(0);
});
it("should handle step lifecycle", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "start-step", messageId: "step_1" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({
type: "finish-step",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
isContinued: false,
}),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
// Find step-start chunks
const stepStarts = chunks.filter((c) => c.type === "step-start");
expect(stepStarts.length).toBeGreaterThanOrEqual(1);
// Find step-finish
const stepFinish = chunks.find(
(c): c is AssistantStreamChunk & { type: "step-finish" } =>
c.type === "step-finish",
);
expect(stepFinish).toBeDefined();
expect(stepFinish?.finishReason).toBe("stop");
expect(stepFinish?.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
expect(stepFinish?.isContinued).toBe(false);
});
it("records the step `start` opens once and each later step on its own", async () => {
const textFrames = (id: string) => [
{ type: "text-start", id },
{ type: "text-delta", id, delta: id },
{ type: "text-end", id },
];
const events = [
{ type: "start", messageId: "msg_1" },
{ type: "start-step" },
...textFrames("a"),
{ type: "finish-step" },
{ type: "start-step" },
...textFrames("b"),
{ type: "finish-step" },
{ type: "finish", finishReason: "stop" },
].map((event) => JSON.stringify(event));
const messages = await collectChunks(
createUIMessageStream([...events, "[DONE]"])
.pipeThrough(new UIMessageStreamDecoder())
.pipeThrough(new AssistantMessageAccumulator()),
);
expect(messages.at(-1)!.metadata.steps).toEqual([
expect.objectContaining({ state: "finished", messageId: "msg_1" }),
expect.objectContaining({ state: "finished", messageId: "msg_1" }),
]);
});
it("should handle the stock v6 lifecycle with bare step chunks", async () => {
const events = [
JSON.stringify({ type: "start" }),
JSON.stringify({ type: "start-step" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({ type: "finish-step" }),
JSON.stringify({ type: "finish", finishReason: "stop" }),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const stepStarts = chunks.filter(
(c): c is AssistantStreamChunk & { type: "step-start" } =>
c.type === "step-start",
);
expect(stepStarts).toHaveLength(1);
expect(stepStarts[0]?.messageId).toBeTruthy();
const stepFinish = chunks.find(
(c): c is AssistantStreamChunk & { type: "step-finish" } =>
c.type === "step-finish",
);
expect(stepFinish?.finishReason).toBe("unknown");
expect(stepFinish?.usage).toEqual({ inputTokens: 0, outputTokens: 0 });
expect(stepFinish?.isContinued).toBe(false);
const messageFinish = chunks.find(
(c): c is AssistantStreamChunk & { type: "message-finish" } =>
c.type === "message-finish",
);
expect(messageFinish?.finishReason).toBe("stop");
});
it("should default finishReason on a bare finish chunk", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({ type: "finish" }),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const messageFinish = chunks.find(
(c): c is AssistantStreamChunk & { type: "message-finish" } =>
c.type === "message-finish",
);
expect(messageFinish?.finishReason).toBe("unknown");
expect(messageFinish?.usage).toEqual({ inputTokens: 0, outputTokens: 0 });
});
it("should ignore malformed legacy source and file chunks", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "source" }),
JSON.stringify({ type: "source", source: null }),
JSON.stringify({ type: "file" }),
JSON.stringify({ type: "file", file: null }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const partStarts = chunks.filter(
(c) =>
c.type === "part-start" &&
(c.part.type === "source" || c.part.type === "file"),
);
expect(partStarts).toHaveLength(0);
});
it("should handle errors", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "error", errorText: "Something went wrong" }),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const errorChunk = chunks.find(
(c): c is AssistantStreamChunk & { type: "error" } => c.type === "error",
);
expect(errorChunk).toBeDefined();
expect(errorChunk?.error).toBe("Something went wrong");
});
it("should throw when stream ends without [DONE]", async () => {
const encoder = new TextEncoder();
const sseText =
'data: {"type":"text-delta","id":"text_1","delta":"Hello"}\n\n' +
'data: {"type":"text-delta","id":"text_1","delta":" world"}\n\n';
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sseText));
controller.close();
},
});
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
await expect(collectChunks(decodedStream)).rejects.toThrow(
"Stream ended abruptly without receiving [DONE] marker",
);
});
it("should discard an unterminated [DONE] event", async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n"));
controller.close();
},
});
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
await expect(collectChunks(decodedStream)).rejects.toThrow(
"Stream ended abruptly without receiving [DONE] marker",
);
});
it("should ignore unknown chunk types for forward compatibility", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "unknown-future-type", data: {} }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
// Should not throw, should complete successfully
expect(chunks.some((c) => c.type === "message-finish")).toBe(true);
});
it("drops frames that are not objects with a string type", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const invalidFrames = [
"null",
"5",
'"text"',
"[1,2]",
'{"foo":1}',
'{"type":5}',
];
const events = [
...invalidFrames,
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
expect(warn.mock.calls.map((c) => c[0])).toEqual(
invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
);
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
warn.mockRestore();
});
it("drops frames carrying prototype-pollution keys", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const events = [
'{"type":"text-delta","delta":"x","__proto__":{"polluted":true}}',
'{"type":"text-delta","delta":"y","constructor":{"prototype":{"polluted":true}}}',
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
expect(warn).toHaveBeenCalledTimes(2);
const deltas = chunks.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
);
expect(deltas).toHaveLength(1);
expect(deltas[0]!.textDelta).toBe("ok");
warn.mockRestore();
});
it("drops frames that are not valid JSON", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const invalidFrames = ['{"type":"te', "not json"];
const events = [
...invalidFrames,
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
expect(warn.mock.calls.map((c) => c[0])).toEqual(
invalidFrames.map((f) => `Dropped invalid UIMessageStream chunk: ${f}`),
);
expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
warn.mockRestore();
});
it("ignores a tool-call-delta arriving after tool-result", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-call-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
JSON.stringify({
type: "tool-result",
toolCallId: "call_abc",
result: { temp: 72 },
}),
JSON.stringify({ type: "tool-call-delta", argsText: '{"late":true}' }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argDeltas = chunks.filter((c) => c.type === "text-delta");
expect(argDeltas).toHaveLength(1);
expect(chunks.some((c) => c.type === "result")).toBe(true);
});
it("settles the tool part at result time instead of decoder flush", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-call-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({ type: "tool-call-delta", argsText: '{"city":"NYC"}' }),
JSON.stringify({ type: "tool-call-end" }),
JSON.stringify({
type: "tool-result",
toolCallId: "call_abc",
result: { temp: 72 },
}),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-end" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolPartFinish = chunks.findIndex(
(c) => c.type === "part-finish" && c.path[0] === 0,
);
const result = chunks.findIndex((c) => c.type === "result");
const messageFinish = chunks.findIndex((c) => c.type === "message-finish");
expect(result).toBeGreaterThan(-1);
expect(messageFinish).toBeGreaterThan(-1);
expect(toolPartFinish).toBeGreaterThan(result);
expect(toolPartFinish).toBeLessThan(messageFinish);
});
it("keeps the active tool call writable when another call receives its result", async () => {
const events = [
JSON.stringify({
type: "tool-call-start",
toolCallId: "call_a",
toolName: "first",
}),
JSON.stringify({
type: "tool-call-start",
toolCallId: "call_b",
toolName: "second",
}),
JSON.stringify({
type: "tool-result",
toolCallId: "call_a",
result: { ok: true },
}),
JSON.stringify({ type: "tool-call-delta", argsText: '{"x":1}' }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argDeltas = chunks.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
);
expect(argDeltas).toHaveLength(2);
const byPath = new Map(argDeltas.map((c) => [c.path[0], c.textDelta]));
expect(byPath.get(0)).toBe("{}");
expect(byPath.get(1)).toBe('{"x":1}');
});
it("materializes a tool result frame that carries no result", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-call-start",
id: "tc_1",
toolCallId: "call_abc",
toolName: "notify",
}),
JSON.stringify({ type: "tool-call-delta", argsText: "{}" }),
JSON.stringify({ type: "tool-call-end" }),
JSON.stringify({ type: "tool-result", toolCallId: "call_abc" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const stream = createUIMessageStream(events);
const decodedStream = stream.pipeThrough(new UIMessageStreamDecoder());
const chunks = await collectChunks(decodedStream);
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toBe(NO_RESULT);
});
describe("AI SDK v6 tool chunk names", () => {
it("decodes a streamed tool call (tool-input-start/delta/available, tool-output-available)", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"city":',
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '"Berlin"}',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
JSON.stringify({
type: "finish",
finishReason: "tool-calls",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolCallStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStart).toBeDefined();
if (toolCallStart?.part.type === "tool-call") {
expect(toolCallStart.part.toolName).toBe("weather");
expect(toolCallStart.part.toolCallId).toBe("call_abc");
}
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toEqual({ temp: 20 });
expect(result?.isError).toBe(false);
});
it("decodes a non-streamed tool-input-available without a prior tool-input-start", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolCallStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStart).toBeDefined();
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toEqual({ temp: 20 });
});
it("serializes successful string input as a JSON string", async () => {
const events = [
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: "Berlin",
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argsText = chunks
.filter((c) => c.type === "text-delta")
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('"Berlin"');
expect(JSON.parse(argsText)).toBe("Berlin");
});
it("prefers tool-input-available.input over earlier deltas", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"cit',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
});
it("maps tool-output-error to an error response", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"city":"Berlin"}',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-error",
toolCallId: "call_abc",
errorText: "city not found",
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toBe("city not found");
expect(result?.isError).toBe(true);
});
it("maps tool-input-error to an error response", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-error",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
errorText: "invalid input",
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolCallStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStart).toBeDefined();
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toBe("invalid input");
expect(result?.isError).toBe(true);
});
it("keeps unparsed tool-input-error input as args text", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-error",
toolCallId: "call_abc",
toolName: "weather",
input: '{"city":',
errorText: "invalid input",
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":');
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toBe("invalid input");
expect(result?.isError).toBe(true);
});
it("surfaces preliminary tool outputs before the final one", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 0 },
preliminary: true,
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 10 },
preliminary: true,
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const results = chunks.filter(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(results).toHaveLength(3);
expect(results.map((result) => result.result)).toEqual([
{ temp: 0 },
{ temp: 10 },
{ temp: 20 },
]);
expect(results.map((result) => result.isPreliminary)).toEqual([
true,
true,
undefined,
]);
});
it("holds preliminary outputs until the tool input is available", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 0 },
preliminary: true,
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 10 },
preliminary: true,
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const types = chunks.map((c) => c.type);
expect(types.indexOf("tool-call-args-text-finish")).toBeLessThan(
types.indexOf("result"),
);
const results = chunks.filter(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(results.map((result) => result.result)).toEqual([
{ temp: 0 },
{ temp: 10 },
{ temp: 20 },
]);
expect(results.map((result) => result.isPreliminary)).toEqual([
true,
true,
undefined,
]);
});
it("decodes parallel tool input streams independently", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_a",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_b",
toolName: "time",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_a",
inputTextDelta: '{"city":',
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_b",
inputTextDelta: '{"zone":"CET"}',
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_a",
inputTextDelta: '"Berlin"}',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_a",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_b",
toolName: "time",
input: { zone: "CET" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_a",
output: { temp: 20 },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_b",
output: { time: "12:00" },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolCallStarts = chunks.filter(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStarts).toHaveLength(2);
const argsByPath = new Map<string, string>();
for (const c of chunks) {
if (c.type !== "text-delta") continue;
const key = JSON.stringify(c.path);
argsByPath.set(key, (argsByPath.get(key) ?? "") + c.textDelta);
}
expect(new Set(argsByPath.values())).toEqual(
new Set(['{"city":"Berlin"}', '{"zone":"CET"}']),
);
const results = chunks.filter(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(results.map((r) => r.result)).toEqual(
expect.arrayContaining([{ temp: 20 }, { time: "12:00" }]),
);
});
it("drops a tool-input-delta arriving after the input settled instead of erroring", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"city":"Berlin"}',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"late":true}',
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"later":true}',
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toEqual({ temp: 20 });
});
it("opens a nameless call for a tool-output whose id this decoder never saw", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_from_previous_response",
output: { temp: 20 },
}),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({ type: "text-delta", id: "text_1", delta: "Hello" }),
JSON.stringify({ type: "text-end" }),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
expect(chunks).toContainEqual({
type: "part-start",
path: [],
part: {
type: "tool-call",
toolCallId: "call_from_previous_response",
toolName: "",
},
});
expect(chunks).toContainEqual(
expect.objectContaining({ type: "result", result: { temp: 20 } }),
);
const text = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta" && c.path[0] === 1,
)
.map((c) => c.textDelta)
.join("");
expect(text).toBe("Hello");
});
it("tolerates a duplicated tool-input-available for a settled tool call", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const argsText = chunks
.filter(
(c): c is AssistantStreamChunk & { type: "text-delta" } =>
c.type === "text-delta",
)
.map((c) => c.textDelta)
.join("");
expect(argsText).toBe('{"city":"Berlin"}');
const results = chunks.filter(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(results).toHaveLength(1);
expect(results[0]?.result).toEqual({ temp: 20 });
});
it("interleaves a streamed tool call with text like a real run", async () => {
const events = [
JSON.stringify({ type: "start", messageId: "msg_123" }),
JSON.stringify({ type: "start-step" }),
JSON.stringify({
type: "tool-input-start",
toolCallId: "call_abc",
toolName: "weather",
}),
JSON.stringify({
type: "tool-input-delta",
toolCallId: "call_abc",
inputTextDelta: '{"city":"Berlin"}',
}),
JSON.stringify({
type: "tool-input-available",
toolCallId: "call_abc",
toolName: "weather",
input: { city: "Berlin" },
}),
JSON.stringify({
type: "tool-output-available",
toolCallId: "call_abc",
output: { temp: 20 },
}),
JSON.stringify({ type: "start-step" }),
JSON.stringify({ type: "text-start", id: "text_1" }),
JSON.stringify({
type: "text-delta",
id: "text_1",
delta: "It is 20 degrees.",
}),
JSON.stringify({ type: "text-end" }),
JSON.stringify({
type: "finish",
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
}),
"[DONE]",
];
const chunks = await collectChunks(
createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
);
const toolCallStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "tool-call",
);
expect(toolCallStart).toBeDefined();
const result = chunks.find(
(c): c is AssistantStreamChunk & { type: "result" } =>
c.type === "result",
);
expect(result?.result).toEqual({ temp: 20 });
const textStart = chunks.find(
(c): c is AssistantStreamChunk & { type: "part-start" } =>
c.type === "part-start" && c.part.type === "text",
);
expect(textStart).toBeDefined();
});
});
});
describe("UIMessageStreamDecoder server abort", () => {
it.each([
{
terminal: [{ type: "abort" }],
status: { type: "incomplete", reason: "other" },
},
{
terminal: [
{ type: "abort", reason: "Server stopped run" },
{ type: "text-start", id: "fallback" },
{ type: "text-delta", id: "fallback", delta: ". Recovered answer" },
{ type: "text-end", id: "fallback" },
{ type: "finish", finishReason: "stop" },
],
status: { type: "complete", reason: "stop" },
texts: ["An unfinished answer", ". Recovered answer"],
},
{
terminal: [{ type: "finish", finishReason: "stop" }],
status: { type: "complete", reason: "stop" },
},
])(
"preserves partial text with $terminal and reports $status",
async ({ terminal, status, texts = ["An unfinished answer"] }) => {
const messages = await collectChunks(
createUIMessageStream([
...[
{ type: "start", messageId: "answer" },
{ type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: "An unfinished answer" },
...terminal,
].map((frame) => JSON.stringify(frame)),
"[DONE]",
])
.pipeThrough(new UIMessageStreamDecoder())
.pipeThrough(new AssistantMessageAccumulator()),
);
expect(messages.at(-1)?.content).toEqual(
texts.map((text) => expect.objectContaining({ type: "text", text })),
);
expect(messages.at(-1)?.status).toEqual(status);
},
);
it.each([false, true])(
"discards aborted tool input and allows an explicit retry (retry: %s)",
async (retry) => {
const execute = vi.fn(async () => "frontend result");
const onData = vi.fn();
const chunks = await collectChunks(
createUIMessageStream([
...[
{
type: "tool-input-start",
toolCallId: "call",
toolName: "lookup",
},
{
type: "tool-input-delta",
toolCallId: "call",
inputTextDelta: "{}",
},
{ type: "abort" },
...(retry
? [
{
type: "tool-input-available",
toolCallId: "call",
toolName: "lookup",
input: { retry: true },
},
{ type: "data-retry", data: "recovered" },
]
: []),
].map((frame) => JSON.stringify(frame)),
"[DONE]",
])
.pipeThrough(new UIMessageStreamDecoder({ onData }))
.pipeThrough(
toolResultStream(
{
lookup: {
parameters: { type: "object", properties: {} },
execute,
},
},
new AbortController().signal,
async () => undefined,
),
),
);
if (retry) {
expect(execute).toHaveBeenCalledExactlyOnceWith(
{ retry: true },
expect.anything(),
);
expect(onData).toHaveBeenCalledExactlyOnceWith({
type: "data-retry",
name: "retry",
data: "recovered",
});
} else {
expect(execute).not.toHaveBeenCalled();
expect(onData).not.toHaveBeenCalled();
}
expect(chunks).toContainEqual({
type: "message-finish",
path: [],
finishReason: "other",
usage: { inputTokens: 0, outputTokens: 0 },
});
expect(chunks.some((chunk) => chunk.type === "part-start")).toBe(retry);
},
);
});
describe("UIMessageStreamDecoder tool approvals", () => {
const decodeMessages = (frames: readonly object[]) =>
collectChunks(
createUIMessageStream([
...frames.map((frame) => JSON.stringify(frame)),
"[DONE]",
])
.pipeThrough(new UIMessageStreamDecoder())
.pipeThrough(new AssistantMessageAccumulator()),
);
const toolCallOf = (messages: readonly AssistantMessage[]) =>
messages.at(-1)!.parts.find((part) => part.type === "tool-call");
// Recorded from AI SDK 7 `streamText` with a `needsApproval` tool and
// `experimental_toolApprovalSecret`, through `toUIMessageStreamResponse()`.
const approvalRequestFrames = [
{ type: "start" },
{ type: "start-step" },
{
type: "tool-input-available",
toolCallId: "call_1",
toolName: "delete_file",
input: { path: "/tmp/a.txt" },
},
{
type: "tool-approval-request",
approvalId: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
toolCallId: "call_1",
signature: "7k2qAL3WgfPT0H4tuB9QTC8NAl-fp6siXIU6YqFc0tM",
},
{ type: "finish-step" },
{ type: "finish", finishReason: "tool-calls" },
];
it("puts an approval request on its tool call and pauses for it", async () => {
const messages = await decodeMessages(approvalRequestFrames);
expect(toolCallOf(messages)).toMatchObject({
toolCallId: "call_1",
toolName: "delete_file",
args: { path: "/tmp/a.txt" },
approval: {
id: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
signature: "7k2qAL3WgfPT0H4tuB9QTC8NAl-fp6siXIU6YqFc0tM",
},
});
expect(toolCallOf(messages)).not.toHaveProperty("result");
expect(messages.at(-1)!.status).toEqual({
type: "requires-action",
reason: "tool-calls",
});
});
it("keeps the request fields of an approval verbatim", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 3),
{
type: "tool-approval-request",
approvalId: "approval-1",
toolCallId: "call_1",
reason: "Deletes a file",
approvalDescriptor: { kind: "destructive" },
inputSchemaInput: { path: "a.txt" },
isAutomatic: false,
signature: "sig",
},
{ type: "finish", finishReason: "tool-calls" },
]);
expect(toolCallOf(messages)?.approval).toEqual({
id: "approval-1",
prompt: "Deletes a file",
requestReason: "Deletes a file",
descriptor: { kind: "destructive" },
inputSchemaInput: { path: "a.txt" },
isAutomatic: false,
signature: "sig",
});
});
it("settles a call the server denied on its own", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 3),
{
type: "tool-approval-request",
approvalId: "approval-1",
toolCallId: "call_1",
isAutomatic: true,
},
{
type: "tool-approval-response",
approvalId: "approval-1",
approved: false,
reason: "Blocked by policy",
},
{ type: "tool-output-denied", toolCallId: "call_1" },
{ type: "finish-step" },
{ type: "start-step" },
{ type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: "I could not delete it." },
{ type: "text-end", id: "text" },
{ type: "finish", finishReason: "stop" },
]);
expect(toolCallOf(messages)).toMatchObject({
approval: {
id: "approval-1",
isAutomatic: true,
approved: false,
reason: "Blocked by policy",
},
result: { error: "Blocked by policy" },
isError: true,
});
expect(messages.at(-1)!.status).toEqual({
type: "complete",
reason: "stop",
});
});
it("routes out-of-order decisions to the calls that requested them", async () => {
const calls = Array.from({ length: 16 }, (_, index) => ({
toolCallId: `approval-call-${index}`,
approvalId: `approval-${index}`,
approved: index % 2 === 0,
reason: `Decision ${index}`,
}));
const messages = await decodeMessages([
{ type: "start" },
...calls.flatMap(({ toolCallId, approvalId }) => [
{
type: "tool-input-available",
toolCallId,
toolName: "lookup",
input: {},
},
{ type: "tool-approval-request", toolCallId, approvalId },
]),
...calls.toReversed().map(({ approvalId, approved, reason }) => ({
type: "tool-approval-response",
approvalId,
approved,
reason,
})),
{ type: "finish", finishReason: "tool-calls" },
]);
expect(messages.at(-1)!.parts).toEqual(
calls.map(({ toolCallId, approvalId, approved, reason }) =>
expect.objectContaining({
toolCallId,
approval: { id: approvalId, approved, reason },
}),
),
);
});
it("ignores replaced approval ids and uses the latest request metadata", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 4),
{
type: "tool-approval-request",
toolCallId: "call_1",
approvalId: "replacement",
signature: "old",
},
{
type: "tool-approval-request",
toolCallId: "call_1",
approvalId: "replacement",
signature: "new",
reason: "Confirm the new path",
},
{
type: "tool-approval-response",
approvalId: "replacement",
approved: false,
reason: "Keep the file",
},
{
type: "tool-approval-response",
approvalId: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
approved: true,
reason: "Stale decision",
},
{ type: "tool-output-denied", toolCallId: "call_1" },
{ type: "finish", finishReason: "stop" },
]);
expect(toolCallOf(messages)).toMatchObject({
approval: {
id: "replacement",
signature: "new",
prompt: "Confirm the new path",
requestReason: "Confirm the new path",
approved: false,
reason: "Keep the file",
},
result: { error: "Keep the file" },
isError: true,
});
});
it("ignores unknown approval ids and requests for unknown calls", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 4),
{ type: "tool-approval-response", approvalId: "unknown", approved: true },
{
type: "tool-approval-request",
toolCallId: "missing",
approvalId: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
},
{
type: "tool-approval-response",
approvalId: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
approved: false,
reason: "Keep it",
},
{
type: "tool-approval-response",
approvalId: "unknown",
approved: true,
reason: "Unrelated",
},
{ type: "finish", finishReason: "tool-calls" },
]);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({
toolCallId: "call_1",
approval: {
id: "aitxt-1w6vi0Fered1RmYgbJWDbhzJ",
signature: "7k2qAL3WgfPT0H4tuB9QTC8NAl-fp6siXIU6YqFc0tM",
approved: false,
reason: "Keep it",
},
}),
]);
});
it("keeps first-request priority for reused ids and reroutes after replacement", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 3),
{
type: "tool-input-available",
toolCallId: "call_2",
toolName: "lookup",
input: {},
},
{
type: "tool-approval-request",
toolCallId: "call_2",
approvalId: "shared",
},
{
type: "tool-approval-request",
toolCallId: "call_1",
approvalId: "shared",
},
{
type: "tool-approval-request",
toolCallId: "call_1",
approvalId: "separate",
},
{
type: "tool-approval-request",
toolCallId: "call_1",
approvalId: "shared",
},
{
type: "tool-approval-response",
approvalId: "shared",
approved: false,
reason: "First request",
},
{
type: "tool-approval-request",
toolCallId: "call_2",
approvalId: "replacement",
},
{ type: "tool-approval-response", approvalId: "shared", approved: true },
{
type: "tool-approval-request",
toolCallId: "call_2",
approvalId: "shared",
signature: "updated",
},
{
type: "tool-approval-response",
approvalId: "shared",
approved: false,
reason: "Original priority",
},
{ type: "tool-approval-response", approvalId: "shared", approved: true },
{ type: "finish", finishReason: "tool-calls" },
]);
expect(
messages.some((message) =>
message.parts.some(
(part) =>
part.type === "tool-call" &&
part.toolCallId === "call_2" &&
part.approval?.reason === "First request",
),
),
).toBe(true);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({
toolCallId: "call_1",
approval: { id: "shared", approved: true },
}),
expect.objectContaining({
toolCallId: "call_2",
approval: {
id: "shared",
signature: "updated",
approved: true,
reason: "Original priority",
},
}),
]);
});
it("drops approval chunks without their ids", async () => {
const messages = await decodeMessages([
...approvalRequestFrames.slice(0, 3),
{ type: "tool-approval-request", toolCallId: "call_1" },
{ type: "tool-approval-request", approvalId: "approval-1" },
{ type: "tool-approval-response", approvalId: "approval-1" },
{ type: "tool-approval-response", approved: false },
{ type: "tool-output-denied" },
{ type: "finish", finishReason: "tool-calls" },
]);
expect(toolCallOf(messages)).not.toHaveProperty("approval");
expect(toolCallOf(messages)).not.toHaveProperty("result");
});
it.each([
{
output: { type: "tool-output-available", output: { deleted: true } },
result: { deleted: true },
isError: false,
},
{
output: { type: "tool-output-error", errorText: "An error occurred." },
result: "An error occurred.",
isError: true,
},
])(
"carries a $output.type for a call an earlier response opened",
async ({ output, result, isError }) => {
// Recorded continuation after the client approved `call_1`.
const messages = await decodeMessages([
{ type: "start" },
{ ...output, toolCallId: "call_1" },
{ type: "start-step" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "ok" },
{ type: "text-end", id: "t" },
{ type: "finish-step" },
{ type: "finish", finishReason: "stop" },
]);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({
type: "tool-call",
toolCallId: "call_1",
result,
isError,
}),
expect.objectContaining({ type: "text", text: "ok" }),
]);
},
);
it("drops missing output without opening or settling a call", async () => {
const messages = await decodeMessages([
{ type: "start" },
{ type: "tool-output-available", toolCallId: "missing" },
{
type: "tool-input-available",
toolCallId: "known",
toolName: "lookup",
input: {},
},
{ type: "tool-output-available", toolCallId: "known" },
{ type: "tool-output-available", toolCallId: "known", output: null },
{ type: "tool-output-available", toolCallId: "earlier", output: null },
{ type: "finish", finishReason: "stop" },
]);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({
toolCallId: "known",
toolName: "lookup",
result: null,
}),
expect.objectContaining({ toolCallId: "earlier", result: null }),
]);
});
it("ignores approval chunks for a call an earlier response opened", async () => {
// Recorded continuation after the client denied `call_1`.
const messages = await decodeMessages([
{ type: "start" },
{ type: "tool-output-denied", toolCallId: "call_1" },
{
type: "tool-approval-request",
approvalId: "approval-2",
toolCallId: "call_1",
},
{ type: "start-step" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "ok" },
{ type: "text-end", id: "t" },
{ type: "finish-step" },
{ type: "finish", finishReason: "stop" },
]);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({ type: "text", text: "ok" }),
]);
});
});