UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

1,336 lines (1,199 loc) • 41 kB
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"); }); });