UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

240 lines (219 loc) • 8.52 kB
import { describe, expect, it } from "vitest"; import { RESUMABLE_STREAM_ID_HEADER, createResumableAssistantStreamResponse, createResumeAssistantStreamResponse, } from "./createResumableAssistantStreamResponse"; import { createResumableStreamContext } from "./ResumableStreamContext"; import { ResumableStreamError } from "./errors"; import { createInMemoryResumableStreamStore } from "./stores/InMemoryResumableStreamStore"; import { DataStreamDecoder } from "../core/serialization/data-stream/DataStream"; import { AssistantTransportEncoder } from "../core/serialization/assistant-transport/AssistantTransport"; import { AssistantMessageAccumulator } from "../core/accumulators/assistant-message-accumulator"; import { promiseWithResolvers } from "../utils/promiseWithResolvers"; async function decodeAssistantText( response: Response, onText?: (text: string) => void, ): Promise<string> { const stream = response .body!.pipeThrough(new DataStreamDecoder()) .pipeThrough(new AssistantMessageAccumulator()); let text = ""; for await (const message of stream) { text = message.parts .filter((p) => p.type === "text") .map((p) => p.text) .join(""); onText?.(text); } return text; } describe("createResumableAssistantStreamResponse", () => { it("produces a response that decodes through DataStreamDecoder", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); const response = await createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", callback: (controller) => { controller.appendText("hello "); controller.appendText("world"); }, }); expect(response.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1"); expect(response.headers.get("content-type")).toContain("text/plain"); expect(await decodeAssistantText(response)).toBe("hello world"); }); it("resume endpoint replays the same payload byte-for-byte", async () => { const store = createInMemoryResumableStreamStore(); const ctx = createResumableStreamContext({ store }); const first = await createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", callback: (controller) => { controller.appendText("alpha"); const tool = controller.addToolCallPart({ toolName: "echo", toolCallId: "t1", args: { v: 1 }, response: { result: { ok: true } }, }); tool.close(); }, }); const firstBytes = await new Response(first.body).arrayBuffer(); const second = await createResumeAssistantStreamResponse({ context: ctx, streamId: "s1", }); const secondBytes = await new Response(second.body).arrayBuffer(); expect(new Uint8Array(secondBytes)).toEqual(new Uint8Array(firstBytes)); expect(second.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1"); }); it("resume returns 404 when streamId is unknown", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); const response = await createResumeAssistantStreamResponse({ context: ctx, streamId: "missing", }); expect(response.status).toBe(404); const body = await response.json(); expect(body).toEqual({ error: "stream not found" }); }); it("custom encoder factory survives across producer + consumer", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); const response = await createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", encoder: () => new AssistantTransportEncoder(), callback: (controller) => controller.appendText("hi"), }); expect(response.headers.get("content-type")).toBe("text/event-stream"); const text = await new Response(response.body).text(); expect(text).toContain("text-delta"); expect(text).toContain("[DONE]"); }); it("user headers override encoder defaults but not the stream id header", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); const response = await createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", headers: { "Cache-Control": "private, max-age=0", [RESUMABLE_STREAM_ID_HEADER]: "spoofed", }, callback: (controller) => controller.appendText("hi"), }); expect(response.headers.get("cache-control")).toBe("private, max-age=0"); expect(response.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("s1"); await response.body!.cancel(); }); describe("producer crash", () => { it("synchronous callback throw is encoded into the body and finalizes the stream", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); const response = await createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", callback: () => { throw new Error("boom"); }, }); expect(response.status).toBe(200); const firstBytes = await new Response(response.body).arrayBuffer(); expect(new TextDecoder().decode(firstBytes)).toContain("Error: boom"); while ((await ctx.status("s1")) === "streaming") { await new Promise((r) => setTimeout(r, 5)); } expect(await ctx.status("s1")).toBe("done"); const replay = await ctx.resume("s1"); expect(replay).not.toBeNull(); const replayBytes = await new Response(replay).arrayBuffer(); expect(new Uint8Array(replayBytes)).toEqual(new Uint8Array(firstBytes)); }); }); it("requireResume rejects with ResumableStreamError(missing) for unknown ids", async () => { const ctx = createResumableStreamContext({ store: createInMemoryResumableStreamStore(), }); let captured: unknown; try { await ctx.requireResume("nonexistent"); } catch (e) { captured = e; } expect(captured).toBeInstanceOf(ResumableStreamError); expect((captured as ResumableStreamError).code).toBe("missing"); }); it("mid-stream consumer joins active production and receives every chunk in order", async () => { const continueProduction = promiseWithResolvers<void>(); const finishProduction = promiseWithResolvers<void>(); const tasks: Promise<unknown>[] = []; const store = createInMemoryResumableStreamStore(); const ctx = createResumableStreamContext({ store, waitUntil: (task) => tasks.push(task), }); let producerText = ""; let replayText = ""; const producerDone = createResumableAssistantStreamResponse({ context: ctx, streamId: "s1", callback: async (controller) => { controller.appendText("chunk0;"); controller.appendText("chunk1;"); await continueProduction.promise; controller.appendText("chunk2;"); controller.appendText("chunk3;"); controller.appendText("chunk4;"); await finishProduction.promise; }, }) .then((response) => decodeAssistantText(response, (text) => { producerText = text; }), ) .catch((error: unknown) => error); let replayDone: Promise<unknown> = Promise.resolve(); try { await expect .poll(() => producerText, { interval: 1 }) .toBe("chunk0;chunk1;"); replayDone = createResumeAssistantStreamResponse({ context: ctx, streamId: "s1", }) .then((response) => decodeAssistantText(response, (text) => { replayText = text; }), ) .catch((error: unknown) => error); await expect .poll(() => replayText, { interval: 1 }) .toBe("chunk0;chunk1;"); continueProduction.resolve(); const expected = "chunk0;chunk1;chunk2;chunk3;chunk4;"; await expect.poll(() => replayText, { interval: 1 }).toBe(expected); expect(await ctx.status("s1")).toBe("streaming"); finishProduction.resolve(); expect(await producerDone).toBe(expected); expect(await replayDone).toBe(expected); expect(await ctx.status("s1")).toBe("done"); } finally { continueProduction.resolve(); finishProduction.resolve(); await Promise.allSettled([producerDone, replayDone, ...tasks]); store.dispose(); } }); });