assistant-stream
Version:
Streaming utilities for AI assistants
1,336 lines (1,199 loc) • 41 kB
text/typescript
import { describe, it, expect, vi } from "vitest";
import {
createAssistantStream,
createAssistantStreamController,
type AssistantStreamController,
} from "./assistant-stream";
import { createAssistantStreamResponse } from "./assistant-stream-response";
import { AssistantStream } from "../AssistantStream";
import type { AssistantStreamChunk } from "../AssistantStreamChunk";
import { DataStreamDecoder } from "../serialization/data-stream/DataStream";
import { AssistantMessageAccumulator } from "../accumulators/assistant-message-accumulator";
import type { AssistantMessage } from "../utils/types";
import { toolResultStream } from "../tool/toolResultStream";
import {
AssistantTransportDecoder,
AssistantTransportEncoder,
} from "../serialization/assistant-transport/AssistantTransport";
const accumulate = async (response: Response): Promise<AssistantMessage> => {
const stream = AssistantStream.fromResponse(
response,
new DataStreamDecoder(),
);
let last: AssistantMessage | undefined;
await stream.pipeThrough(new AssistantMessageAccumulator()).pipeTo(
new WritableStream({
write(message) {
last = message;
},
}),
);
return last!;
};
const collectChunks = async (
stream: AssistantStream,
): Promise<AssistantStreamChunk[]> => {
const chunks: AssistantStreamChunk[] = [];
await stream.pipeTo(
new WritableStream({
write(chunk) {
chunks.push(chunk);
},
}),
);
return chunks;
};
const captureUnhandledRejections = async (
callback: () => Promise<void>,
): Promise<unknown[]> => {
const reasons: unknown[] = [];
const listener = (reason: unknown) => reasons.push(reason);
process.on("unhandledRejection", listener);
try {
await callback();
await new Promise((resolve) => setTimeout(resolve, 0));
return reasons;
} finally {
process.off("unhandledRejection", listener);
}
};
describe("tool-call writes after cancellation", () => {
it("ignores setResponse after the consumer cancels", async () => {
const unhandledRejections = await captureUnhandledRejections(async () => {
const [stream, controller] = createAssistantStreamController();
const toolCall = controller.addToolCallPart({
toolCallId: "t1",
toolName: "search",
});
const reader = stream.getReader();
await reader.cancel("consumer stopped");
await new Promise((resolve) => setTimeout(resolve, 0));
toolCall.setResponse({ result: "ok" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(unhandledRejections).toEqual([]);
});
it("ignores args appends and close after the consumer cancels", async () => {
const unhandledRejections = await captureUnhandledRejections(async () => {
const [stream, controller] = createAssistantStreamController();
const toolCall = controller.addToolCallPart({
toolCallId: "t1",
toolName: "search",
});
const reader = stream.getReader();
await reader.cancel("consumer stopped");
await new Promise((resolve) => setTimeout(resolve, 0));
toolCall.argsText.append('{"q":1}');
toolCall.close();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(unhandledRejections).toEqual([]);
});
it("cancels while a local part waits for its start index", async () => {
const [stream, controller] = createAssistantStreamController();
const first = controller.addTextPart();
first.append("before");
first.close();
controller.enqueue({
type: "step-finish",
path: [],
finishReason: "tool-calls",
usage: { inputTokens: 0, outputTokens: 0 },
isContinued: false,
});
const second = controller.addTextPart();
second.append("after");
const reader = stream.getReader();
await reader.read();
await reader.cancel();
});
});
describe("controller close idempotence", () => {
it("tolerates a second close after the stream drains", async () => {
const [stream, controller] = createAssistantStreamController();
controller.appendText("hi");
controller.close();
const reader = stream.getReader();
while (!(await reader.read()).done) {
// drain
}
expect(() => controller.close()).not.toThrow();
});
});
describe("raw chunk ordering", () => {
const stepFinish: AssistantStreamChunk = {
type: "step-finish",
path: [],
finishReason: "tool-calls",
usage: { inputTokens: 0, outputTokens: 0 },
isContinued: false,
};
it("keeps a raw step-finish before a buffered merged part", async () => {
const merged = new ReadableStream<AssistantStreamChunk>({
start(inner) {
inner.enqueue({ type: "part-start", part: { type: "text" }, path: [] });
inner.enqueue({ type: "text-delta", textDelta: "child", path: [0] });
inner.enqueue({ type: "part-finish", path: [0] });
inner.close();
},
});
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.merge(merged);
controller.enqueue(stepFinish);
}),
);
expect(chunks).toEqual([
stepFinish,
{ type: "part-start", part: { type: "text" }, path: [] },
{ type: "text-delta", textDelta: "child", path: [0] },
{ type: "part-finish", path: [0] },
]);
});
it("rejects a merged chunk whose part path has no start and cancels its source", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const cancelSource = vi.fn();
const merged = new ReadableStream<AssistantStreamChunk>({
start(inner) {
inner.enqueue({ type: "text-delta", textDelta: "orphan", path: [0] });
},
cancel: cancelSource,
});
await expect(
collectChunks(
createAssistantStream((controller) => controller.merge(merged)),
),
).rejects.toThrow("Path not found");
await vi.waitFor(() => expect(cancelSource).toHaveBeenCalledOnce());
} finally {
consoleError.mockRestore();
}
});
it("emits synchronous raw chunks before an appended part body", async () => {
const before: AssistantStreamChunk = {
type: "annotations",
path: [],
annotations: ["before"],
};
const after: AssistantStreamChunk = {
type: "annotations",
path: [],
annotations: ["after"],
};
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.enqueue(before);
controller.appendText("child");
controller.enqueue(after);
}),
);
expect(chunks).toEqual([
before,
{
type: "part-start",
path: [],
part: { type: "text" },
},
after,
{
type: "text-delta",
path: [0],
textDelta: "child",
},
{
type: "part-finish",
path: [0],
},
]);
});
it("keeps tool args when step-finish follows the closed args", async () => {
const message = await accumulate(
createAssistantStreamResponse((controller) => {
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
tool.argsText.append('{"city":"Paris"}');
tool.argsText.close();
controller.enqueue({
type: "step-finish",
path: [],
finishReason: "tool-calls",
usage: { inputTokens: 0, outputTokens: 0 },
isContinued: false,
});
tool.close();
}),
);
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "tool-call",
argsText: '{"city":"Paris"}',
}),
);
});
it("keeps tool args when message-finish follows the closed tool call", async () => {
const message = await accumulate(
createAssistantStreamResponse((controller) => {
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
tool.argsText.append('{"city":"Paris"}');
tool.argsText.close();
tool.close();
controller.enqueue({
type: "message-finish",
path: [],
finishReason: "tool-calls",
usage: { inputTokens: 0, outputTokens: 0 },
});
}),
);
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "tool-call",
argsText: '{"city":"Paris"}',
}),
);
});
it("keeps a text body ahead of a message-finish enqueued after it", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.appendText("hello");
controller.enqueue({
type: "message-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0 },
});
}),
);
expect(chunks.map((c) => c.type)).toEqual([
"part-start",
"text-delta",
"message-finish",
"part-finish",
]);
});
it("emits part chunks before the error chunk when a callback throws", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.appendText("hello");
throw new Error("failed");
}),
);
const errorIndex = chunks.findIndex((chunk) => chunk.type === "error");
const deltaIndex = chunks.findIndex((chunk) => chunk.type === "text-delta");
expect(deltaIndex).toBeGreaterThanOrEqual(0);
expect(errorIndex).toBeGreaterThan(deltaIndex);
});
it("starts a part opened after a held step-finish before its body", async () => {
const run = (controller: AssistantStreamController) => {
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
tool.argsText.append('{"city":"Paris"}');
controller.enqueue(stepFinish);
controller.appendText("next");
tool.close();
};
const chunks = await collectChunks(createAssistantStream(run));
let started = 0;
for (const chunk of chunks) {
if (chunk.type === "part-start" && chunk.path.length === 0) started++;
else if (chunk.path.length > 0)
expect(chunk.path[0]).toBeLessThan(started);
}
const message = await accumulate(createAssistantStreamResponse(run));
expect(message.parts).toContainEqual(
expect.objectContaining({ type: "text", text: "next" }),
);
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "tool-call",
argsText: '{"city":"Paris"}',
}),
);
});
it("keeps a tool call added after a held step-finish", async () => {
const message = await accumulate(
createAssistantStreamResponse((controller) => {
const first = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
first.argsText.append('{"city":"Paris"}');
controller.enqueue(stepFinish);
const second = controller.addToolCallPart({
toolCallId: "t2",
toolName: "weather",
args: { city: "Rome" },
});
second.setResponse({ result: "sunny" });
first.close();
}),
);
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "tool-call",
toolCallId: "t2",
argsText: '{"city":"Rome"}',
result: "sunny",
}),
);
});
it("keeps merged part paths aligned with start order across a microtask", async () => {
let innerController!: ReadableStreamDefaultController<AssistantStreamChunk>;
let release!: () => void;
const ready = new Promise<void>((resolve) => (release = resolve));
const stream = createAssistantStream(async (controller) => {
controller.merge(
new ReadableStream<AssistantStreamChunk>({
start(inner) {
innerController = inner;
},
}),
);
const text = controller.addTextPart();
controller.enqueue({
type: "step-finish",
path: [],
finishReason: "stop",
usage: { inputTokens: 2, outputTokens: 0 },
isContinued: false,
});
await ready;
innerController.enqueue({
type: "part-start",
part: { type: "text" },
path: [],
});
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "t",
});
text.close();
innerController.enqueue({ type: "part-finish", path: [0] });
tool.argsText.append('{"a":1}');
void tool.close();
innerController.close();
});
const reader = stream.getReader();
const chunks: AssistantStreamChunk[] = [];
const first = await reader.read();
chunks.push(first.value!);
await Promise.resolve();
release();
for (;;) {
const result = await reader.read();
if (result.done) break;
chunks.push(result.value);
}
const started: string[] = [];
for (const chunk of chunks) {
if (chunk.type === "part-start" && chunk.path.length === 0) {
started.push(chunk.part.type);
} else if (chunk.path.length > 0) {
expect(chunk.path[0]).toBeLessThan(started.length);
if (
chunk.type === "tool-call-args-text-finish" ||
(chunk.type === "text-delta" && chunk.textDelta.startsWith("{"))
) {
expect(started[chunk.path[0]!]).toBe("tool-call");
}
}
}
expect([...started].sort()).toEqual(["text", "text", "tool-call"]);
expect(
chunks
.filter((chunk) => chunk.type === "part-finish")
.map((chunk) => chunk.path[0])
.sort(),
).toEqual([0, 1, 2]);
let message: AssistantMessage | undefined;
await new ReadableStream<AssistantStreamChunk>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
})
.pipeThrough(new AssistantMessageAccumulator())
.pipeTo(
new WritableStream({
write(value: AssistantMessage) {
message = value;
},
}),
);
expect(message?.parts).toContainEqual(
expect.objectContaining({
type: "tool-call",
toolCallId: "t1",
argsText: '{"a":1}',
}),
);
});
it("emits a step-finish without waiting for an open part to close", async () => {
const [stream, controller] = createAssistantStreamController();
const text = controller.addTextPart();
text.append("a");
controller.enqueue(stepFinish);
const reader = stream.getReader();
const types: string[] = [];
while (!types.includes("step-finish")) {
const { value } = await reader.read();
types.push(value!.type);
}
expect(types).toEqual(["part-start", "text-delta", "step-finish"]);
text.append("b");
text.close();
controller.close();
const rest: string[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
rest.push(value.type);
}
expect(rest).toEqual(["text-delta", "part-finish"]);
});
it("cancels promptly while a step-finish is held", async () => {
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});
const stream = createAssistantStream(async (controller) => {
const text = controller.addTextPart();
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
text.append("a");
tool.argsText.append("{");
controller.enqueue(stepFinish);
await released;
text.append("late");
tool.close();
text.close();
});
const reader = stream.getReader();
await reader.read();
const reasons = await captureUnhandledRejections(() => reader.cancel());
release();
expect(reasons).toEqual([]);
});
it("does not hold a step-finish for a write the part dropped", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const [stream, controller] = createAssistantStreamController({
strict: false,
});
const tool = controller.addToolCallPart({
toolCallId: "t1",
toolName: "weather",
});
tool.argsText.append("{}");
tool.argsText.close();
tool.argsText.append("dropped");
controller.enqueue(stepFinish);
const reader = stream.getReader();
const types: string[] = [];
while (!types.includes("step-finish")) {
const { value } = await reader.read();
types.push(value!.type);
}
expect(types).toEqual(["part-start", "text-delta", "step-finish"]);
tool.close();
controller.close();
await reader.cancel();
} finally {
consoleError.mockRestore();
}
});
it("emits the error chunk while a merged stream stays open", async () => {
let upstream!: ReadableStreamDefaultController<AssistantStreamChunk>;
const stream = createAssistantStream((controller) => {
controller.merge(
new ReadableStream({
start(c) {
upstream = c;
},
}),
);
controller.appendText("partial");
throw new Error("failed");
});
const reader = stream.getReader();
const types: string[] = [];
while (!types.includes("error")) {
const { value } = await reader.read();
types.push(value!.type);
}
expect(types).toEqual(["part-start", "text-delta", "error"]);
upstream.close();
await reader.cancel();
});
});
describe("createAssistantStream task settlement", () => {
it.each(["text", "reasoning", "tool-call"] as const)(
"ignores a background %s writer after the callback fails",
async (type) => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let lateWrite!: Promise<void>;
const chunks = await collectChunks(
createAssistantStream((controller) => {
const part =
type === "text"
? controller.addTextPart()
: type === "reasoning"
? controller.addReasoningPart()
: controller.addToolCallPart("search").argsText;
lateWrite = gate.then(() => {
part.append("late");
});
throw new Error("provider failed");
}),
);
release();
await expect(lateWrite).resolves.toBeUndefined();
expect(chunks.filter((chunk) => chunk.type === "text-delta")).toEqual([]);
},
);
it.each(["text", "reasoning", "tool-call"] as const)(
"keeps strict writes after an explicit %s close when the callback fails",
async (type) => {
let append!: () => void;
await collectChunks(
createAssistantStream((controller) => {
const part =
type === "text"
? controller.addTextPart()
: type === "reasoning"
? controller.addReasoningPart()
: controller.addToolCallPart("search").argsText;
part.close();
append = () => part.append("late");
throw new Error("provider failed");
}),
);
expect(append).toThrow("Cannot append to a closed TextStreamController");
},
);
it("finishes open text and reasoning parts and cuts off open tool calls when the callback throws", async () => {
const chunks = await collectChunks(
createAssistantStream(async (controller) => {
controller.addTextPart().append("partial");
controller.addReasoningPart().append("thinking");
controller.addToolCallPart({ toolCallId: "t1", toolName: "search" });
throw new Error("provider failed");
}),
);
expect(chunks.filter((c) => c.type === "error")).toHaveLength(1);
expect(chunks).toContainEqual({
type: "text-delta",
path: [0],
textDelta: "partial",
});
expect(
chunks.filter((c) => c.type === "part-finish").map((c) => c.path),
).toEqual([[0], [1]]);
expect(chunks.map((c) => c.type)).not.toContain(
"tool-call-args-text-finish",
);
});
it.each([false, true])(
"keeps the args finish of a tool call whose args completed when the callback throws (async: %s)",
async (isAsync) => {
const run: Parameters<typeof createAssistantStream>[0] = (controller) => {
controller.addToolCallPart({
toolCallId: "t1",
toolName: "confirm",
args: { id: 1 },
});
throw new Error("upstream failed");
};
const chunks = await collectChunks(
createAssistantStream(isAsync ? async (c) => run(c) : run),
);
expect(chunks.filter((c) => c.type === "error")).toHaveLength(1);
expect(chunks.map((c) => c.type)).toContain("tool-call-args-text-finish");
},
);
it.each(["appendText", "appendReasoning"] as const)(
"finishes the part %s opened when the callback throws",
async (method) => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller[method]("partial");
throw new Error("provider failed");
}),
);
expect(chunks.map((c) => c.type)).toEqual([
"part-start",
"text-delta",
"error",
"part-finish",
]);
},
);
it("does not run a frontend tool whose call was open when the callback threw", async () => {
const execute = vi.fn(() => "confirmed");
const response = new Response(
createAssistantStream((controller) => {
controller.appendText("partial");
controller.addToolCallPart({ toolCallId: "t1", toolName: "confirm" });
controller
.addToolCallPart({ toolCallId: "t2", toolName: "confirm" })
.argsText.append('{"orderId":');
throw new Error("upstream failed");
}).pipeThrough(new AssistantTransportEncoder()),
);
let message: AssistantMessage | undefined;
await AssistantStream.fromResponse(
response,
new AssistantTransportDecoder(),
)
.pipeThrough(
toolResultStream(
{ confirm: { parameters: { type: "object" }, execute } },
new AbortController().signal,
async () => {},
),
)
.pipeThrough(new AssistantMessageAccumulator())
.pipeTo(
new WritableStream({
write(m) {
message = m;
},
}),
);
expect(execute).not.toHaveBeenCalled();
expect(message?.status).toMatchObject({
type: "incomplete",
reason: "error",
});
expect(message?.parts).toMatchObject([
{ type: "text", text: "partial", status: { type: "complete" } },
{ toolCallId: "t1", state: "partial-call" },
{ toolCallId: "t2", state: "partial-call", argsText: '{"orderId":' },
]);
});
it.each([true, false])(
"preserves merged output when the outer callback throws (finished: %s)",
async (finished) => {
const [merged, inner] = createAssistantStreamController();
inner.appendText("full answer");
if (finished) inner.close();
const pending = collectChunks(
createAssistantStream((controller) => {
controller.merge(merged);
throw new Error("outer failed");
}),
);
if (!finished) {
await Promise.resolve();
inner.appendText(" continued");
inner.close();
}
const chunks = await pending;
expect(
chunks
.filter((chunk) => chunk.type === "text-delta")
.map((chunk) => chunk.textDelta)
.join(""),
).toBe(finished ? "full answer" : "full answer continued");
expect(chunks.filter((chunk) => chunk.type === "error")).toHaveLength(1);
expect(
chunks.filter((chunk) => chunk.type === "part-finish"),
).toHaveLength(1);
},
);
it("stops tracking an input once it finishes", async () => {
let tracked: Set<unknown> | undefined;
await collectChunks(
createAssistantStream(async (controller) => {
tracked = (
controller as unknown as { _state: { openInputs: Set<unknown> } }
)._state.openInputs;
controller.appendText("a");
controller.addReasoningPart().close();
controller.addToolCallPart("search").setResponse({ result: 1 });
}),
);
expect(tracked?.size).toBe(0);
});
it("closes the outer stream when a merged stream throws with an open part", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.merge(
createAssistantStream(async (inner) => {
inner.addToolCallPart("search");
throw new Error("inner failed");
}),
);
}),
);
expect(chunks.map((c) => c.type)).toEqual(["part-start", "error"]);
});
it("emits callback failures without leaking an unhandled rejection", async () => {
let chunks: AssistantStreamChunk[] = [];
const unhandledRejections = await captureUnhandledRejections(async () => {
chunks = await collectChunks(
createAssistantStream(async () => {
throw new Error("provider failed");
}),
);
});
expect(chunks).toEqual([
{
type: "error",
path: [],
error: "Error: provider failed",
},
]);
expect(unhandledRejections).toEqual([]);
});
it("does not settle the stream again after cancellation", async () => {
let finishCallback!: () => void;
const callbackPending = new Promise<void>((resolve) => {
finishCallback = resolve;
});
const unhandledRejections = await captureUnhandledRejections(async () => {
const reader = createAssistantStream(() => callbackPending).getReader();
await reader.cancel("consumer stopped");
finishCallback();
await callbackPending;
});
expect(unhandledRejections).toEqual([]);
});
it("waits for merged source cleanup during cancellation", async () => {
let finishCleanup = () => {};
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve;
});
const cancelSource = vi.fn(() => cleanup);
const stream = createAssistantStream((controller) => {
controller.merge(new ReadableStream({ cancel: cancelSource }));
return new Promise(() => {});
});
let cancelSettled = false;
const cancel = stream.cancel().then(() => {
cancelSettled = true;
});
await vi.waitFor(() => expect(cancelSource).toHaveBeenCalledOnce());
expect(cancelSettled).toBe(false);
finishCleanup();
await expect(cancel).resolves.toBeUndefined();
expect(cancelSettled).toBe(true);
});
it("reports callback failures after the controller is explicitly closed", async () => {
const error = new Error("cleanup failed");
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.close();
throw error;
}),
);
expect(chunks).toEqual([]);
expect(consoleError).toHaveBeenCalledOnce();
expect(consoleError).toHaveBeenCalledWith(error);
} finally {
consoleError.mockRestore();
}
});
it("does not report callback failures caused after cancellation", async () => {
let failCallback!: (error: Error) => void;
const callbackPending = new Promise<void>((_, reject) => {
failCallback = reject;
});
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const unhandledRejections = await captureUnhandledRejections(async () => {
const reader = createAssistantStream(() => callbackPending).getReader();
await reader.cancel("consumer stopped");
failCallback(new Error("provider stopped"));
await callbackPending.catch(() => undefined);
});
expect(unhandledRejections).toEqual([]);
expect(consoleError).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
});
it("does not settle the outer stream again after a merged stream errors", async () => {
let finishCallback!: () => void;
const callbackPending = new Promise<void>((resolve) => {
finishCallback = resolve;
});
const streamError = new Error("merged stream failed");
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const unhandledRejections = await captureUnhandledRejections(async () => {
const stream = createAssistantStream(async (controller) => {
controller.merge(
new ReadableStream({
start(streamController) {
streamController.error(streamError);
},
}),
);
await callbackPending;
});
await expect(collectChunks(stream)).rejects.toBe(streamError);
finishCallback();
await callbackPending;
});
expect(unhandledRejections).toEqual([]);
expect(consoleError).toHaveBeenCalledWith(streamError);
} finally {
consoleError.mockRestore();
}
});
it("surfaces a merged stream error while sibling cleanup continues", async () => {
let finishCleanup = () => {};
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve;
});
const cancelSource = vi.fn(() => cleanup);
const streamError = new Error("merged stream failed");
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const stream = createAssistantStream((controller) => {
controller.merge(
new ReadableStream({
start(streamController) {
streamController.error(streamError);
},
}),
);
controller.merge(new ReadableStream({ cancel: cancelSource }));
return new Promise(() => {});
});
const readResult = stream
.getReader()
.read()
.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
);
await vi.waitFor(() => expect(cancelSource).toHaveBeenCalledOnce());
await expect(readResult).resolves.toEqual({ error: streamError });
} finally {
finishCleanup();
await cleanup;
consoleError.mockRestore();
}
});
it("emits an error chunk when merging a locked stream", async () => {
const source = new ReadableStream();
const sourceReader = source.getReader();
try {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.merge(source);
}),
);
expect(chunks).toEqual([
{
type: "error",
path: [],
error:
"TypeError: Cannot merge a stream that is already locked to a reader.",
},
]);
} finally {
sourceReader.releaseLock();
}
});
});
describe("addToolCallPart with an immediate response", () => {
it("completes the stream without an explicit tool close", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.addToolCallPart({
toolName: "search",
response: { result: "done" },
});
}),
);
expect(chunks.map((c) => c.type)).toContain("result");
expect(chunks.at(-1)?.type).toBe("part-finish");
});
it("completes the stream when args accompany the response", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
controller.addToolCallPart({
toolName: "search",
args: { query: "x" },
response: { result: "done" },
});
}),
);
const deltas = chunks.filter((c) => c.type === "text-delta");
expect(deltas.map((c) => c.textDelta).join("")).toBe('{"query":"x"}');
expect(chunks.filter((c) => c.type === "result")).toHaveLength(1);
expect(chunks.at(-1)?.type).toBe("part-finish");
});
it("emits the args ahead of the result", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
const tool = controller.addToolCallPart({ toolName: "search" });
tool.argsText.append('{"query":"x"}');
tool.setResponse({ result: "done" });
}),
);
expect(
chunks.filter((c) => c.path.length === 1).map((c) => c.type),
).toEqual([
"text-delta",
"result",
"tool-call-args-text-finish",
"part-finish",
]);
});
it("emits the result ahead of the args finish when the args close in the same tick", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
const tool = controller.addToolCallPart({ toolName: "search" });
tool.argsText.append('{"query":"x"}');
tool.argsText.close();
tool.setResponse({ result: "done" });
}),
);
expect(
chunks.filter((c) => c.path.length === 1).map((c) => c.type),
).toEqual([
"text-delta",
"result",
"tool-call-args-text-finish",
"part-finish",
]);
});
it("rejects args passed alongside argsText instead of concatenating them", () => {
const [, controller] = createAssistantStreamController();
expect(() =>
controller.addToolCallPart({
toolName: "search",
argsText: '{"query":"x"}',
args: { query: "y" },
response: { result: "done" },
}),
).toThrow("Cannot append to a closed TextStreamController");
});
it("keeps only argsText when args are also passed in lenient mode", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
const chunks = await collectChunks(
createAssistantStream(
(controller) => {
controller.addToolCallPart({
toolName: "search",
argsText: '{"query":"x"}',
args: { query: "y" },
response: { result: "done" },
});
},
{ strict: false },
),
);
const deltas = chunks.filter((c) => c.type === "text-delta");
expect(deltas.map((c) => c.textDelta).join("")).toBe('{"query":"x"}');
expect(chunks.filter((c) => c.type === "result")).toHaveLength(1);
expect(consoleError).toHaveBeenCalledTimes(1);
} finally {
consoleError.mockRestore();
}
});
it("keeps the args across a data-stream round trip", async () => {
const message = await accumulate(
createAssistantStreamResponse((controller) => {
controller.addToolCallPart({
toolCallId: "t1",
toolName: "search",
args: { query: "x" },
response: { result: "done" },
});
}),
);
expect(message.parts[0]).toMatchObject({
type: "tool-call",
argsText: '{"query":"x"}',
result: "done",
});
});
it("keeps working when the caller also closes explicitly", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
const tool = controller.addToolCallPart({
toolName: "search",
response: { result: "done" },
});
tool.close();
}),
);
expect(chunks.filter((c) => c.type === "result")).toHaveLength(1);
expect(chunks.filter((c) => c.type === "part-finish")).toHaveLength(1);
expect(chunks.at(-1)?.type).toBe("part-finish");
});
});
describe("tool-call finish ordering", () => {
it("keeps close order across uneven argument backlogs", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
const first = controller.addToolCallPart("first");
const second = controller.addToolCallPart("second");
first.argsText.append('{"value":"');
for (let index = 0; index < 20; index++) {
first.argsText.append(String(index % 10));
}
first.argsText.append('"}');
first.close();
second.close();
}),
);
expect(
chunks
.filter((chunk) => chunk.type === "part-finish")
.map((chunk) => chunk.path),
).toEqual([[0], [1]]);
});
it("delivers finishes in tool close order", async () => {
const chunks = await collectChunks(
createAssistantStream((controller) => {
const first = controller.addToolCallPart("first");
const second = controller.addToolCallPart("second");
second.close();
first.close();
}),
);
expect(
chunks
.filter((chunk) => chunk.type === "part-finish")
.map((chunk) => chunk.path),
).toEqual([[1], [0]]);
});
it("does not block a closed tool behind an earlier open tool", async () => {
const [stream, controller] = createAssistantStreamController();
controller.addToolCallPart("first");
controller.addToolCallPart("second").close();
const reader = stream.getReader();
let finish: AssistantStreamChunk | undefined;
while (finish?.type !== "part-finish") {
const next = await reader.read();
if (next.done) break;
finish = next.value;
}
expect(finish).toEqual({ type: "part-finish", path: [1] });
await reader.cancel();
});
});
describe("AssistantStreamController withParentId", () => {
it("preserves a reasoning summary from addReasoningPart", async () => {
const stream = createAssistantStream((controller) => {
const part = controller.addReasoningPart({
unstable_summary: "Planning",
});
part.append("thinking");
part.close();
});
const chunks = await collectChunks(stream);
expect(chunks).toContainEqual({
type: "part-start",
path: [],
part: {
type: "reasoning",
unstable_summary: "Planning",
},
});
});
it("attaches parentId to text parts across a data-stream round trip", async () => {
const response = createAssistantStreamResponse((controller) => {
controller.appendText("intro");
const group = controller.withParentId("group-1");
group.appendSource({
type: "source",
sourceType: "url",
id: "s1",
url: "https://example.com",
title: "Example",
});
group.appendText("grouped text");
});
const message = await accumulate(response);
const intro = message.parts.find(
(p) => p.type === "text" && p.text === "intro",
);
const grouped = message.parts.find(
(p) => p.type === "text" && p.text === "grouped text",
);
const source = message.parts.find((p) => p.type === "source");
expect(intro?.parentId).toBeUndefined();
expect(grouped?.parentId).toBe("group-1");
expect(source?.parentId).toBe("group-1");
});
it("attaches parentId to reasoning parts across a data-stream round trip", async () => {
const response = createAssistantStreamResponse((controller) => {
controller.appendReasoning("thinking out loud");
const group = controller.withParentId("group-1");
group.appendReasoning("grouped reasoning");
});
const message = await accumulate(response);
const ungrouped = message.parts.find(
(p) => p.type === "reasoning" && p.text === "thinking out loud",
);
const grouped = message.parts.find(
(p) => p.type === "reasoning" && p.text === "grouped reasoning",
);
expect(ungrouped?.parentId).toBeUndefined();
expect(grouped?.parentId).toBe("group-1");
});
it("opens a new text part when withParentId switches between ids", async () => {
const response = createAssistantStreamResponse((controller) => {
controller.withParentId("group-1").appendText("first");
controller.withParentId("group-2").appendText("second");
});
const message = await accumulate(response);
const first = message.parts.find(
(p) => p.type === "text" && p.text === "first",
);
const second = message.parts.find(
(p) => p.type === "text" && p.text === "second",
);
expect(first?.parentId).toBe("group-1");
expect(second?.parentId).toBe("group-2");
});
it("attaches parentId on addTextPart called directly inside a withParentId scope", async () => {
const response = createAssistantStreamResponse((controller) => {
const part = controller.withParentId("group-1").addTextPart();
part.append("explicit");
part.close();
});
const message = await accumulate(response);
const text = message.parts.find(
(p) => p.type === "text" && p.text === "explicit",
);
expect(text?.parentId).toBe("group-1");
});
});