assistant-stream
Version:
Streaming utilities for AI assistants
1,233 lines (1,115 loc) • 39.2 kB
text/typescript
import { describe, it, expect, vi, onTestFinished } from "vitest";
import {
AssistantMessageAccumulator,
createInitialMessage,
} from "./assistant-message-accumulator";
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
import type { AssistantMessage } from "../utils/types";
const collectStream = async (
chunks: AssistantStreamChunk[],
): Promise<AssistantMessage[]> => {
const accumulator = new AssistantMessageAccumulator();
const messages: AssistantMessage[] = [];
const source = new ReadableStream<AssistantStreamChunk>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
await source.pipeThrough(accumulator).pipeTo(
new WritableStream({
write(message) {
messages.push(message);
},
}),
);
return messages;
};
describe("AssistantMessageAccumulator reasoning summaries", () => {
it("copies reasoning summaries from part-start chunks", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "reasoning", unstable_summary: "Planning" },
},
{ type: "text-delta", path: [0], textDelta: "thinking" },
{ type: "part-finish", path: [0] },
]);
expect(messages.at(-1)?.parts).toEqual([
expect.objectContaining({
type: "reasoning",
text: "thinking",
unstable_summary: "Planning",
}),
]);
});
it("preserves an empty reasoning summary", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "reasoning", unstable_summary: "" },
},
]);
expect(messages.at(-1)?.parts[0]).toEqual(
expect.objectContaining({ type: "reasoning", unstable_summary: "" }),
);
});
});
describe("AssistantMessageAccumulator document sources", () => {
it("defaults a missing document source media type", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: {
type: "source",
sourceType: "document",
id: "d1",
title: "Q3 report",
},
} as unknown as AssistantStreamChunk,
]);
expect(messages.at(-1)?.parts).toEqual([
expect.objectContaining({
type: "source",
sourceType: "document",
id: "d1",
title: "Q3 report",
mediaType: "application/octet-stream",
}),
]);
});
});
describe("AssistantMessageAccumulator tool argument status", () => {
it("marks arguments complete before the result and preserves a settled result", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "text-delta", path: [0], textDelta: '{"q":"test"}' },
{ type: "tool-call-args-text-finish", path: [0] },
{ type: "result", path: [0], result: "found", isError: false },
{ type: "tool-call-args-text-finish", path: [0] },
]);
expect(messages[1]?.parts[0]).toMatchObject({
state: "partial-call",
status: { type: "running", isArgsComplete: false },
});
expect(messages[2]?.parts[0]).toMatchObject({
state: "call",
args: { q: "test" },
status: { type: "running", isArgsComplete: true },
});
expect(messages.at(-1)?.parts[0]).toMatchObject({
state: "result",
result: "found",
status: { type: "complete", reason: "stop" },
});
});
it("preserves completed status when argument text finishes after the part", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "text-delta", path: [0], textDelta: '{"q":"test"}' },
{ type: "part-finish", path: [0] },
{ type: "tool-call-args-text-finish", path: [0] },
]);
expect(messages[3]?.parts[0]).toMatchObject({
state: "call",
status: { type: "complete", reason: "unknown" },
});
});
it("keeps a preliminary result running until a final result arrives", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "text-delta", path: [0], textDelta: "{}" },
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "interim",
isError: false,
isPreliminary: true,
modelContent: [{ type: "text", text: "Search in progress" }],
},
{ type: "part-finish", path: [0] },
{ type: "result", path: [0], result: "done", isError: false },
{ type: "part-finish", path: [0] },
]);
const preliminaryMessage = messages.find(
(message) =>
message.parts[0]?.type === "tool-call" &&
message.parts[0].isPreliminary,
);
expect(preliminaryMessage?.parts[0]).toMatchObject({
state: "call",
result: "interim",
isPreliminary: true,
modelContent: [{ type: "text", text: "Search in progress" }],
status: { type: "running", isArgsComplete: true },
});
expect(messages.at(-1)?.parts[0]).toMatchObject({
state: "result",
result: "done",
status: { type: "complete" },
});
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("isPreliminary");
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("modelContent");
});
it("clears model content when a later preliminary result omits it", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "searching",
isError: false,
isPreliminary: true,
modelContent: [{ type: "text", text: "Search in progress" }],
},
{
type: "result",
path: [0],
result: "one match so far",
isError: false,
isPreliminary: true,
},
]);
expect(messages.at(-1)?.parts[0]).toMatchObject({
state: "call",
result: "one match so far",
isPreliminary: true,
status: { type: "running", isArgsComplete: true },
});
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("modelContent");
});
it("clears stale artifact and messages when a later result omits them", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "searching",
isError: false,
isPreliminary: true,
artifact: { reportId: 7 },
messages: [
{
role: "assistant",
content: [{ type: "text", text: "in progress" }],
},
],
modelContent: [{ type: "text", text: "Search in progress" }],
},
{
type: "result",
path: [0],
result: "done",
isError: false,
},
]);
expect(messages.at(-1)?.parts[0]).toMatchObject({
state: "result",
result: "done",
status: { type: "complete", reason: "stop" },
});
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("modelContent");
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("artifact");
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("messages");
});
it("ignores a preliminary result after the final one", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "tool-call-args-text-finish", path: [0] },
{ type: "result", path: [0], result: "done", isError: false },
{
type: "result",
path: [0],
result: "late",
isError: false,
isPreliminary: true,
},
]);
expect(messages.at(-1)?.parts[0]).toMatchObject({
state: "result",
result: "done",
status: { type: "complete", reason: "stop" },
});
expect(messages.at(-1)?.parts[0]).not.toHaveProperty("isPreliminary");
});
it("keeps a preliminary result pending when the stream ends", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "interim",
isError: false,
isPreliminary: true,
},
{ type: "part-finish", path: [0] },
]);
expect(messages.at(-1)).toMatchObject({
status: { type: "requires-action", reason: "tool-calls" },
parts: [
{
state: "call",
result: "interim",
isPreliminary: true,
status: { type: "running", isArgsComplete: true },
},
],
});
});
});
describe("AssistantMessageAccumulator timing", () => {
it("should include timing on message-finish", async () => {
const chunks: AssistantStreamChunk[] = [
{ type: "step-start", path: [], messageId: "msg-1" },
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "Hello " },
{ type: "text-delta", path: [0], textDelta: "world" },
{ type: "part-finish", path: [0] },
{
type: "step-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
isContinued: false,
},
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 10, outputTokens: 5 },
},
];
const messages = await collectStream(chunks);
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
const timing = last.metadata.timing!;
expect(timing.streamStartTime).toBeTypeOf("number");
expect(timing.totalChunks).toBe(7);
expect(timing.toolCallCount).toBe(0);
expect(timing.tokenCount).toBe(5);
expect(timing.firstTokenTime).toBeTypeOf("number");
expect(timing.totalStreamTime).toBeTypeOf("number");
expect(timing.totalStreamTime).toBeGreaterThanOrEqual(0);
});
it.each([
{ stepTokens: [], finalTokens: 20, expected: 20 },
{ stepTokens: [7], finalTokens: 20, expected: 20 },
{ stepTokens: [3, 4], finalTokens: 0, expected: 7 },
{ stepTokens: [3, 4], finalTokens: undefined, expected: 7 },
])(
"uses $expected tokens for steps $stepTokens and final usage $finalTokens",
async ({ stepTokens, finalTokens, expected }) => {
const chunks: AssistantStreamChunk[] = [
{ type: "part-start", path: [], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "test" },
{ type: "part-finish", path: [0] },
];
for (const outputTokens of stepTokens) {
chunks.push({
type: "step-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 5, outputTokens },
isContinued: false,
});
}
if (finalTokens !== undefined) {
chunks.push({
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 5, outputTokens: finalTokens },
});
}
chunks.push({ type: "annotations", path: [], annotations: ["done"] });
const messages = await collectStream(chunks);
const timed = messages.filter((m) => m.metadata.timing !== undefined);
expect(timed.length).toBeGreaterThan(0);
for (const message of timed) {
expect(message.metadata.timing?.tokenCount).toBe(expected);
}
},
);
it("falls back to the step total when message-finish omits usage", async () => {
const messages = await collectStream([
{ type: "part-start", path: [], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "test" },
{ type: "part-finish", path: [0] },
{
type: "step-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 5, outputTokens: 9 },
isContinued: false,
},
{
type: "message-finish",
path: [],
finishReason: "stop",
} as unknown as AssistantStreamChunk,
]);
expect(messages.at(-1)?.metadata.timing?.tokenCount).toBe(9);
});
it("should track tool calls in timing", async () => {
const chunks: AssistantStreamChunk[] = [
{
type: "part-start",
path: [0],
part: {
type: "tool-call",
toolCallId: "tc-1",
toolName: "search",
},
},
{ type: "text-delta", path: [0], textDelta: '{"q":"test"}' },
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "found",
isError: false,
},
{ type: "part-finish", path: [0] },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
},
];
const messages = await collectStream(chunks);
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.toolCallCount).toBe(1);
});
it("should record per-tool-call timing on the part", async () => {
const before = Date.now();
const chunks: AssistantStreamChunk[] = [
{
type: "part-start",
path: [0],
part: {
type: "tool-call",
toolCallId: "tc-1",
toolName: "search",
},
},
{ type: "text-delta", path: [0], textDelta: '{"q":"test"}' },
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "found",
isError: false,
},
{ type: "part-finish", path: [0] },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
},
];
const messages = await collectStream(chunks);
const after = Date.now();
const runningPart = messages
.find((m) =>
m.parts.some((p) => p.type === "tool-call" && p.state !== "result"),
)!
.parts.find((p) => p.type === "tool-call")!;
expect(runningPart.timing).toBeDefined();
expect(runningPart.timing!.startedAt).toBeGreaterThanOrEqual(before);
expect(runningPart.timing!.completedAt).toBeUndefined();
const settledPart = messages
.at(-1)!
.parts.find((p) => p.type === "tool-call")!;
expect(settledPart.timing).toBeDefined();
expect(settledPart.timing!.completedAt).toBeGreaterThanOrEqual(
settledPart.timing!.startedAt,
);
expect(settledPart.timing!.completedAt).toBeLessThanOrEqual(after);
});
it("should record reasoning timing on the part", async () => {
const before = Date.now();
const chunks: AssistantStreamChunk[] = [
{ type: "part-start", path: [0], part: { type: "reasoning" } },
{ type: "text-delta", path: [0], textDelta: "thinking" },
{ type: "part-finish", path: [0] },
{ type: "part-start", path: [1], part: { type: "text" } },
{ type: "text-delta", path: [1], textDelta: "answer" },
{ type: "part-finish", path: [1] },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
},
];
const messages = await collectStream(chunks);
const after = Date.now();
const streamingPart = messages
.find((m) =>
m.parts.some(
(p) => p.type === "reasoning" && p.status.type === "running",
),
)!
.parts.find((p) => p.type === "reasoning")!;
expect(streamingPart.timing!.startedAt).toBeGreaterThanOrEqual(before);
expect(streamingPart.timing!.completedAt).toBeUndefined();
const last = messages.at(-1)!;
const settledPart = last.parts.find((p) => p.type === "reasoning")!;
expect(settledPart.timing!.completedAt).toBeGreaterThanOrEqual(
settledPart.timing!.startedAt,
);
expect(settledPart.timing!.completedAt).toBeLessThanOrEqual(after);
expect(last.parts.find((p) => p.type === "text")).not.toHaveProperty(
"timing",
);
});
it("should include timing on flush when stream closes without message-finish", async () => {
const chunks: AssistantStreamChunk[] = [
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "partial" },
];
const messages = await collectStream(chunks);
const last = messages.at(-1)!;
// flush should have produced a message with timing
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.totalChunks).toBe(2);
});
it("should estimate tokens from text when no usage provided", async () => {
const chunks: AssistantStreamChunk[] = [
{ type: "part-start", path: [0], part: { type: "text" } },
{
type: "text-delta",
path: [0],
textDelta: "1234567890123456789012345678901234567890",
}, // 40 chars
{ type: "part-finish", path: [0] },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
},
];
const messages = await collectStream(chunks);
const last = messages.at(-1)!;
expect(last.metadata.timing!.tokenCount).toBe(10); // 40/4
});
it("should record firstTokenTime on text-delta", async () => {
const chunks: AssistantStreamChunk[] = [
{
type: "part-start",
path: [0],
part: {
type: "tool-call",
toolCallId: "tc-1",
toolName: "search",
},
},
{ type: "text-delta", path: [0], textDelta: "{}" },
{ type: "tool-call-args-text-finish", path: [0] },
{
type: "result",
path: [0],
result: "ok",
isError: false,
},
{ type: "part-finish", path: [0] },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
},
];
const messages = await collectStream(chunks);
const last = messages.at(-1)!;
// text-delta on tool-call args still records first token
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.firstTokenTime).toBeTypeOf("number");
});
it("does not record firstTokenTime when a text-delta is dropped for an out-of-range path", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [5], textDelta: "x" },
]);
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
warn.mockRestore();
});
it("does not record firstTokenTime when a text-delta is dropped for a nested path", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0, 1], textDelta: "x" },
]);
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
warn.mockRestore();
});
it("does not record firstTokenTime when a text-delta is dropped for a wrong part type", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: {
type: "source",
sourceType: "url",
id: "s1",
url: "https://example.com",
},
},
{ type: "text-delta", path: [0], textDelta: "x" },
]);
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.firstTokenTime).toBeUndefined();
warn.mockRestore();
});
it("records firstTokenTime on the first applied text-delta, not a preceding dropped one", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const accumulator = new AssistantMessageAccumulator();
const writer = accumulator.writable.getWriter();
const messages: AssistantMessage[] = [];
const reading = (async () => {
const reader = accumulator.readable.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
messages.push(value);
}
})();
await writer.write({
type: "part-start",
path: [0],
part: { type: "text" },
});
vi.setSystemTime(2000);
await writer.write({ type: "text-delta", path: [5], textDelta: "x" });
vi.setSystemTime(3000);
await writer.write({ type: "text-delta", path: [0], textDelta: "hi" });
await writer.close();
await reading;
vi.useRealTimers();
const last = messages.at(-1)!;
expect(last.metadata.timing).toBeDefined();
expect(last.metadata.timing!.firstTokenTime).toBe(2000);
});
});
describe("AssistantMessageAccumulator error chunks", () => {
it("defaults missing code to unknown", async () => {
const messages = await collectStream([
{ type: "error", path: [], error: "stream failed" },
]);
const last = messages.at(-1)!;
expect(last.status).toEqual({
type: "incomplete",
reason: "error",
error: { code: "unknown", message: "stream failed" },
});
});
it("defaults a missing error message", async () => {
const messages = await collectStream([
{ type: "error", path: [] } as unknown as AssistantStreamChunk,
]);
const last = messages.at(-1)!;
expect(last.status).toEqual({
type: "incomplete",
reason: "error",
error: { code: "unknown", message: "unknown error" },
});
});
it("carries code and severity through unchanged", async () => {
const messages = await collectStream([
{
type: "error",
path: [],
error: "rate limited",
code: "provider",
severity: "warning",
},
]);
const last = messages.at(-1)!;
expect(last.status).toEqual({
type: "incomplete",
reason: "error",
error: {
code: "provider",
message: "rate limited",
severity: "warning",
},
});
});
it("drops out-of-taxonomy severity at ingestion", async () => {
const messages = await collectStream([
{
type: "error",
path: [],
error: "boom",
code: "unknown",
severity: "fatal",
} as unknown as AssistantStreamChunk,
]);
const last = messages.at(-1)!;
expect(last.status).toEqual({
type: "incomplete",
reason: "error",
error: {
code: "unknown",
message: "boom",
},
});
expect(last.status).toMatchObject({
error: expect.not.objectContaining({ severity: expect.anything() }),
});
});
});
describe("AssistantMessageAccumulator part path bounds", () => {
it("drops a text-delta whose path is out of range with a warning", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [5], textDelta: "x" },
{ type: "text-delta", path: [0], textDelta: "hi" },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(1);
expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
expect(warn).toHaveBeenCalledWith(
"Dropped text-delta chunk: no part at path [5]",
);
warn.mockRestore();
});
it("drops an out-of-range part-finish instead of fabricating a part", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "part-finish", path: [3] },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(1);
expect(last.parts[0]!.type).toBe("text");
warn.mockRestore();
});
it("drops part chunks when no parts exist", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "text-delta", path: [0], textDelta: "x" },
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "ok" },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(1);
expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
warn.mockRestore();
});
it("drops chunks with nested paths", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0, 1], textDelta: "x" },
{ type: "text-delta", path: [0], textDelta: "ok" },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(1);
expect(last.parts[0]).toMatchObject({ type: "text", text: "ok" });
warn.mockRestore();
});
it("drops an out-of-range result without touching existing tool calls", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "tool-call", toolCallId: "t1", toolName: "f" },
},
{ type: "text-delta", path: [0], textDelta: "{}" },
{ type: "result", path: [7], result: { ok: true }, isError: false },
{ type: "result", path: [0], result: { ok: true }, isError: false },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(1);
expect(last.parts[0]).toMatchObject({
type: "tool-call",
state: "result",
result: { ok: true },
});
warn.mockRestore();
});
});
describe("AssistantMessageAccumulator wrong part type chunks", () => {
const sourcePart = {
type: "source",
sourceType: "url",
id: "s1",
url: "https://example.com",
} as const;
it("drops a text-delta addressed to a source part", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: sourcePart },
{ type: "text-delta", path: [0], textDelta: "x" },
]);
const last = messages.at(-1)!;
expect(last.parts[0]).toMatchObject({ type: "source", id: "s1" });
expect(warn).toHaveBeenCalledWith(
"Dropped text-delta chunk: part is neither text nor tool-call",
);
warn.mockRestore();
});
it("drops a result addressed to a text part", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "hi" },
{ type: "result", path: [0], result: { ok: true }, isError: false },
]);
const last = messages.at(-1)!;
expect(last.parts[0]).toMatchObject({ type: "text", text: "hi" });
expect(warn).toHaveBeenCalledWith(
"Dropped result chunk: part is not a tool-call",
);
warn.mockRestore();
});
it("drops an args-text-finish addressed to a text part", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "tool-call-args-text-finish", path: [0] },
]);
const last = messages.at(-1)!;
expect(last.parts[0]).toMatchObject({ type: "text" });
expect(warn).toHaveBeenCalledWith(
"Dropped tool-call-args-text-finish chunk: part is not a tool-call",
);
warn.mockRestore();
});
it("inserts a placeholder for an unsupported part-start so later part indices stay aligned", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "bogus" },
} as unknown as AssistantStreamChunk,
{ type: "part-start", path: [1], part: { type: "text" } },
{ type: "text-delta", path: [1], textDelta: "ok" },
{ type: "part-finish", path: [0] },
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(2);
expect(last.parts[0]).toMatchObject({
type: "reasoning",
text: "",
status: { type: "complete", reason: "unknown" },
});
expect(last.parts[1]).toMatchObject({ type: "text", text: "ok" });
expect(warn).toHaveBeenCalledWith(
"Unsupported part-start type bogus: inserting an empty reasoning part to preserve part indices",
);
warn.mockRestore();
});
});
describe("AssistantMessageAccumulator warn dedup", () => {
it("warns once per drop class per instance", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [5], textDelta: "a" },
{ type: "text-delta", path: [5], textDelta: "b" },
{ type: "text-delta", path: [6], textDelta: "c" },
{ type: "part-finish", path: [9] },
]);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
"Dropped text-delta chunk: no part at path [5]",
);
expect(warn).toHaveBeenCalledWith(
"Dropped part-finish chunk: no part at path [9]",
);
warn.mockRestore();
});
it("resets dedup per accumulator instance", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const stream = [
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [5], textDelta: "x" },
] satisfies AssistantStreamChunk[];
await collectStream(stream);
await collectStream(stream);
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it("dedupes repeated wrong-part-type drops", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await collectStream([
{
type: "part-start",
path: [0],
part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
},
{ type: "text-delta", path: [0], textDelta: "a" },
{ type: "text-delta", path: [0], textDelta: "b" },
]);
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
});
describe("AssistantMessageAccumulator warn dedup key independence", () => {
it("keeps drop classes as independent keys within one stream", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await collectStream([
{
type: "part-start",
path: [0],
part: { type: "source", sourceType: "url", id: "s", url: "https://x" },
},
{ type: "text-delta", path: [0], textDelta: "wrong-part" },
{ type: "text-delta", path: [7], textDelta: "no-part" },
]);
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it("keys unsupported part-starts per type", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "bogus" },
} as unknown as AssistantStreamChunk,
{
type: "part-start",
path: [1],
part: { type: "bogus" },
} as unknown as AssistantStreamChunk,
{
type: "part-start",
path: [2],
part: { type: "video" },
} as unknown as AssistantStreamChunk,
]);
const last = messages.at(-1)!;
expect(last.parts).toHaveLength(3);
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it("caps the number of warned keys per instance", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await collectStream(
Array.from(
{ length: 20 },
(_, i) =>
({
type: "part-start",
path: [i],
part: { type: `bogus-${i}` },
}) as unknown as AssistantStreamChunk,
),
);
expect(warn).toHaveBeenCalledTimes(16);
warn.mockRestore();
});
});
describe("AssistantMessageAccumulator content alias", () => {
const contentIsAccessor = (message: AssistantMessage) =>
Object.getOwnPropertyDescriptor(message, "content")?.get !== undefined;
it("aliases content to parts on every message it emits", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "hi" },
{
type: "part-start",
path: [1],
part: { type: "tool-call", toolCallId: "tc-1", toolName: "search" },
},
{
type: "part-start",
path: [2],
part: {
type: "source",
sourceType: "url",
id: "s-1",
url: "https://example.com",
},
},
{
type: "part-start",
path: [3],
part: { type: "file", mimeType: "image/png", data: "AAAA" },
},
{
type: "part-start",
path: [4],
part: { type: "data", name: "chart", data: { a: 1 } },
},
{
type: "part-start",
path: [5],
part: { type: "totally-unknown" },
} as unknown as AssistantStreamChunk,
]);
expect(messages.at(-1)?.parts).toHaveLength(6);
for (const message of messages) {
expect(message.content).toBe(message.parts);
}
} finally {
warn.mockRestore();
}
});
it("aliases content to parts on the initial message", () => {
const message = createInitialMessage();
expect(contentIsAccessor(message)).toBe(true);
expect(message.content).toBe(message.parts);
// Published as unstable_createInitialMessage, so key order is observable
// through Object.keys and JSON serialization.
expect(Object.keys(message)).toEqual([
"role",
"status",
"parts",
"content",
"metadata",
]);
});
it("keeps content pointing at parts across status and metadata updates", async () => {
const messages = await collectStream([
{ type: "part-start", path: [0], part: { type: "text" } },
{ type: "text-delta", path: [0], textDelta: "hi" },
{ type: "annotations", path: [], annotations: ["a"] },
{ type: "step-start", path: [], messageId: "m-1" },
{
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 1, outputTokens: 1 },
},
]);
for (const message of messages) {
expect(message.content).toBe(message.parts);
}
});
});
describe("AssistantMessageAccumulator tool arguments", () => {
it("keeps nested values and partial arguments across small deltas", async () => {
const input = '{"query":"pizza","filters":{"limit":2}}';
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "tool-call", toolCallId: "call-1", toolName: "search" },
},
...[...input].map((text) => ({
type: "text-delta" as const,
path: [0],
textDelta: text,
})),
{ type: "tool-call-args-text-finish", path: [0] },
]);
const partial = messages.slice(0, -1).find((message) => {
const part = message.parts[0];
return (
part?.type === "tool-call" &&
part.args.query === "pizza" &&
part.argsText !== input
);
});
expect(partial?.parts[0]).toMatchObject({
type: "tool-call",
args: { query: "pizza" },
});
expect(messages.at(-1)?.parts[0]).toMatchObject({
type: "tool-call",
argsText: input,
args: { query: "pizza", filters: { limit: 2 } },
});
});
it("exposes tool arguments as plain cloneable objects", async () => {
const messages = await collectStream([
{
type: "part-start",
path: [0],
part: { type: "tool-call", toolCallId: "call-1", toolName: "search" },
},
{ type: "text-delta", path: [0], textDelta: '{"query":"pizza",' },
{ type: "text-delta", path: [0], textDelta: '"limit":2}' },
{ type: "tool-call-args-text-finish", path: [0] },
]);
const part = messages.at(-1)?.parts[0];
expect(part?.type).toBe("tool-call");
if (part?.type !== "tool-call") throw new Error("Expected a tool call");
expect(Object.getPrototypeOf(part.args)).toBe(Object.prototype);
expect(structuredClone(part.args)).toEqual({ query: "pizza", limit: 2 });
const mutableArgs = part.args as Record<string, unknown>;
mutableArgs.query = "updated";
expect(mutableArgs.query).toBe("updated");
});
});
describe("AssistantMessageAccumulator tool-call approvals", () => {
it("sets an approval on a tool call and drops one aimed at another part", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
onTestFinished(() => warn.mockRestore());
const messages = await collectStream([
{
type: "part-start",
path: [],
part: { type: "tool-call", toolCallId: "call-1", toolName: "deploy" },
},
{ type: "part-start", path: [], part: { type: "text" } },
{ type: "tool-call-approval", path: [0], approval: { id: "a1" } },
{ type: "tool-call-approval", path: [1], approval: { id: "a2" } },
]);
expect(messages.at(-1)!.parts).toEqual([
expect.objectContaining({ type: "tool-call", approval: { id: "a1" } }),
expect.not.objectContaining({ approval: expect.anything() }),
]);
expect(warn).toHaveBeenCalledWith(
"Dropped tool-call-approval chunk: part is not a tool-call",
);
});
});