UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

651 lines (567 loc) • 20.6 kB
import { describe, expect, it, vi } from "vitest"; import { AssistantTransportEncoder, AssistantTransportDecoder, } from "./AssistantTransport"; import { promiseWithResolvers } from "../../../utils/promiseWithResolvers"; import type { AssistantStreamChunk } from "../../AssistantStreamChunk"; // Helper function to collect all chunks from a stream async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> { const reader = stream.getReader(); const chunks: T[] = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } } finally { reader.releaseLock(); } return chunks; } // Helper function to encode and decode a stream async function encodeAndDecode( stream: ReadableStream<AssistantStreamChunk>, ): Promise<ReadableStream<AssistantStreamChunk>> { // Encode the stream to Uint8Array (simulating network transmission) const encodedStream = stream.pipeThrough(new AssistantTransportEncoder()); // Collect all encoded chunks const encodedChunks = await collectChunks(encodedStream); // Create a new stream from the encoded chunks const reconstructedStream = new ReadableStream<Uint8Array>({ start(controller) { for (const chunk of encodedChunks) { controller.enqueue(chunk); } controller.close(); }, }); // Decode the reconstructed stream return reconstructedStream.pipeThrough(new AssistantTransportDecoder()); } function sseStream(frames: string[]): ReadableStream<AssistantStreamChunk> { const sseText = frames.map((frame) => `data: ${frame}\n\n`).join("") + "data: [DONE]\n\n"; return new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }).pipeThrough(new AssistantTransportDecoder()); } describe("AssistantTransportEncoder", () => { it("should encode text-delta chunks to SSE format", async () => { const chunks: AssistantStreamChunk[] = [ { type: "text-delta", textDelta: "Hello", path: [] }, { type: "text-delta", textDelta: " world", path: [] }, ]; const stream = new ReadableStream<AssistantStreamChunk>({ start(controller) { for (const chunk of chunks) { controller.enqueue(chunk); } controller.close(); }, }); const encodedStream = stream.pipeThrough(new AssistantTransportEncoder()); const encodedChunks = await collectChunks(encodedStream); // Decode the chunks to verify format const decoder = new TextDecoder(); const text = encodedChunks.map((chunk) => decoder.decode(chunk)).join(""); // Should contain SSE formatted data expect(text).toContain('data: {"type":"text-delta"'); expect(text).toContain('"textDelta":"Hello"'); expect(text).toContain('"textDelta":" world"'); // Should end with [DONE] expect(text).toContain("data: [DONE]"); }); it("should set correct headers", () => { const encoder = new AssistantTransportEncoder(); expect(encoder.headers.get("Content-Type")).toBe("text/event-stream"); expect(encoder.headers.get("Cache-Control")).toBe("no-cache"); expect(encoder.headers.get("Connection")).toBe("keep-alive"); }); }); describe("AssistantTransportDecoder", () => { it("decodes a valid chunk after a field prefixed by a second BOM", async () => { const chunk = { type: "text-delta", textDelta: "kept", path: [0], } satisfies AssistantStreamChunk; const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue( new TextEncoder().encode( `\uFEFF\uFEFFdata: {"ignored":true}\ndata: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, ), ); controller.close(); }, }); const chunks = await collectChunks( stream.pipeThrough(new AssistantTransportDecoder()), ); expect(chunks).toEqual([chunk]); }); it("should decode SSE format back to chunks", async () => { const originalChunks: AssistantStreamChunk[] = [ { type: "text-delta", textDelta: "Hello", path: [] }, { type: "text-delta", textDelta: " world", path: [] }, ]; const stream = new ReadableStream<AssistantStreamChunk>({ start(controller) { for (const chunk of originalChunks) { controller.enqueue(chunk); } controller.close(); }, }); const decodedStream = await encodeAndDecode(stream); const decodedChunks = await collectChunks(decodedStream); expect(decodedChunks).toEqual(originalChunks); }); it("forwards configured SSE line and event limits", async () => { const chunk = { type: "text-delta", textDelta: "Hello", path: [], } satisfies AssistantStreamChunk; const sseText = `data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`; const createStream = () => new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); await expect( collectChunks( createStream().pipeThrough( new AssistantTransportDecoder({ maxLineLength: 20, maxEventLength: 256, }), ), ), ).rejects.toThrow("SSE line exceeds maxLineLength"); await expect( collectChunks( createStream().pipeThrough( new AssistantTransportDecoder({ maxLineLength: 256, maxEventLength: 20, }), ), ), ).rejects.toThrow("SSE event exceeds maxEventLength"); await expect( collectChunks( createStream().pipeThrough( new AssistantTransportDecoder({ maxLineLength: 256, maxEventLength: 256, }), ), ), ).resolves.toEqual([chunk]); }); it("should stop decoding at [DONE]", async () => { // Manually create an SSE stream with [DONE] in the middle const sseText = 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n' + "data: [DONE]\n\n" + 'data: {"type":"text-delta","textDelta":"Should not appear","path":[]}\n\n'; const encoder = new TextEncoder(); const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(encoder.encode(sseText)); controller.close(); }, }); const decodedStream = stream.pipeThrough(new AssistantTransportDecoder()); const decodedChunks = await collectChunks(decodedStream); // Should only have the chunk before [DONE] expect(decodedChunks).toHaveLength(1); expect(decodedChunks[0]).toEqual({ type: "text-delta", textDelta: "Hello", path: [], }); }); it.each([ { suffix: ":" + "x".repeat(100), maxLineLength: 64, maxEventLength: 256 }, { suffix: "data: " + "x".repeat(100) + "\n\n", maxLineLength: 256, maxEventLength: 64, }, ])( "stops before an oversized suffix after [DONE] ($maxLineLength/$maxEventLength)", async ({ suffix, ...limits }) => { const cancelled = promiseWithResolvers<void>(); const input = new ReadableStream<Uint8Array<ArrayBuffer>>({ start(controller) { controller.enqueue( new TextEncoder().encode( 'data: {"type":"data","data":[1]}\n\ndata: [DONE]\n\n' + suffix, ), ); }, cancel() { cancelled.resolve(); }, }); const chunks = await collectChunks( input.pipeThrough(new AssistantTransportDecoder(limits)), ); expect(chunks).toEqual([{ type: "data", data: [1], path: [] }]); await cancelled.promise; }, ); it("should handle part-start chunks", async () => { const originalChunks: AssistantStreamChunk[] = [ { type: "part-start", part: { type: "text" }, path: [], }, { type: "text-delta", textDelta: "Hello", path: [] }, { type: "part-finish", path: [] }, ]; const stream = new ReadableStream<AssistantStreamChunk>({ start(controller) { for (const chunk of originalChunks) { controller.enqueue(chunk); } controller.close(); }, }); const decodedStream = await encodeAndDecode(stream); const decodedChunks = await collectChunks(decodedStream); expect(decodedChunks).toEqual(originalChunks); }); it("should decode SSE with CRLF line endings", async () => { // Simulate a server that uses \r\n line endings (common in HTTP) const sseText = 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\r\n\r\n' + "data: [DONE]\r\n\r\n"; const encoder = new TextEncoder(); const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(encoder.encode(sseText)); controller.close(); }, }); const decodedStream = stream.pipeThrough(new AssistantTransportDecoder()); const decodedChunks = await collectChunks(decodedStream); expect(decodedChunks).toHaveLength(1); expect(decodedChunks[0]).toEqual({ type: "text-delta", textDelta: "Hello", path: [], }); }); it("should discard an unterminated [DONE] event", async () => { const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode("data: [DONE]\n")); controller.close(); }, }); const decodedStream = stream.pipeThrough(new AssistantTransportDecoder()); await expect(collectChunks(decodedStream)).rejects.toThrow( "Stream ended abruptly without receiving [DONE] marker", ); }); it("should throw error when stream ends without [DONE]", async () => { // Manually create an SSE stream without [DONE] const sseText = 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n' + 'data: {"type":"text-delta","textDelta":" world","path":[]}\n\n'; const encoder = new TextEncoder(); const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(encoder.encode(sseText)); controller.close(); }, }); const decodedStream = stream.pipeThrough(new AssistantTransportDecoder()); // Should throw when trying to collect all chunks await expect(collectChunks(decodedStream)).rejects.toThrow( "Stream ended abruptly without receiving [DONE] marker", ); }); it("drops frames that are not objects", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ "null", "5", '"text"', "[1,2]", '{"type":"text-delta","textDelta":"Hello","path":[]}', ]), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "Hello", path: [] }, ]); expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("(not-an-object)"), ); warn.mockRestore(); }); it("drops frames with a missing or unknown type", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"path":[]}', '{"type":5,"path":[]}', '{"type":"bogus","path":[]}', '{"type":"toString","path":[]}', '{"type":"part-finish","path":[]}', ]), ); expect(decodedChunks).toEqual([{ type: "part-finish", path: [] }]); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it("drops frames with an invalid path", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"text-delta","textDelta":"b","path":"0"}', '{"type":"text-delta","textDelta":"c","path":[-1]}', '{"type":"text-delta","textDelta":"d","path":[1.5]}', '{"type":"text-delta","textDelta":"e","path":["0"]}', '{"type":"text-delta","textDelta":"f","path":[0]}', ]), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "f", path: [0] }, ]); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it("normalizes a missing path to an empty path on message-level chunks", async () => { const decodedChunks = await collectChunks( sseStream([ '{"type":"update-state","operations":[{"type":"set","path":["messages"],"value":[]}]}', '{"type":"error","error":"boom"}', ]), ); expect(decodedChunks).toEqual([ { type: "update-state", operations: [{ type: "set", path: ["messages"], value: [] }], path: [], }, { type: "error", error: "boom", path: [] }, ]); }); it("drops part-addressed chunks with a missing path", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"text-delta","textDelta":"a"}', '{"type":"part-finish"}', '{"type":"result","result":{"ok":true}}', '{"type":"text-delta","textDelta":"ok","path":[0]}', ]), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "ok", path: [0] }, ]); expect(warn).toHaveBeenCalledTimes(3); warn.mockRestore(); }); it("drops frames that are not valid JSON", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"text-delta"', "not json", '{"type":"text-delta","textDelta":"ok","path":[0]}', ]), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "ok", path: [0] }, ]); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it("drops frames missing per-type required fields", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"part-start","path":[]}', '{"type":"part-start","part":[],"path":[]}', '{"type":"text-delta","textDelta":5,"path":[0]}', '{"type":"annotations","path":[]}', '{"type":"data","path":[]}', '{"type":"update-state","path":[]}', '{"type":"message-finish","path":[]}', '{"type":"step-finish","path":[]}', '{"type":"error","path":[]}', '{"type":"error","error":42,"path":[]}', '{"type":"message-finish","finishReason":"stop","path":[]}', '{"type":"error","error":"boom","path":[]}', ]), ); expect(decodedChunks).toEqual([ { type: "error", path: [] }, { type: "error", error: 42, path: [] }, { type: "message-finish", finishReason: "stop", path: [] }, { type: "error", error: "boom", path: [] }, ]); expect(warn).toHaveBeenCalledTimes(7); expect(warn).toHaveBeenCalledWith( expect.stringContaining("(invalid-fields:part-start)"), ); warn.mockRestore(); }); it("tolerates a result without isError or result but rejects a non-boolean isError", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"result","result":null,"path":[0]}', '{"type":"result","path":[0]}', '{"type":"result","result":1,"isError":"no","path":[0]}', ]), ); expect(decodedChunks).toEqual([ { type: "result", result: null, path: [0] }, { type: "result", path: [0] }, ]); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it("carries a tool-call approval and drops malformed ones", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const decodedChunks = await collectChunks( sseStream([ '{"type":"tool-call-approval","approval":{"id":"a1","signature":"sig"},"path":[0]}', '{"type":"tool-call-approval","approval":{"signature":"sig"},"path":[0]}', '{"type":"tool-call-approval","approval":{"id":"a2","approved":"false"},"path":[0]}', '{"type":"tool-call-approval","path":[0]}', ]), ); expect(decodedChunks).toEqual([ { type: "tool-call-approval", approval: { id: "a1", signature: "sig" }, path: [0], }, ]); expect(warn).toHaveBeenCalledWith( expect.stringContaining("(invalid-fields:tool-call-approval)"), ); warn.mockRestore(); }); it("round-trips error chunks with code and severity", async () => { const originalChunks: AssistantStreamChunk[] = [ { type: "error", path: [], error: "rate limited", code: "provider", severity: "warning", }, ]; const stream = new ReadableStream<AssistantStreamChunk>({ start(controller) { for (const chunk of originalChunks) { controller.enqueue(chunk); } controller.close(); }, }); const decodedStream = await encodeAndDecode(stream); const decodedChunks = await collectChunks(decodedStream); expect(decodedChunks).toEqual(originalChunks); }); it("should throw on unknown SSE event types", async () => { const sseText = "event: custom\ndata: {}\n\n" + 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n' + "data: [DONE]\n\n"; const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); await expect( collectChunks(stream.pipeThrough(new AssistantTransportDecoder())), ).rejects.toThrow("Unknown SSE event type: custom"); }); describe("strict: false", () => { it("ignores unknown SSE event types", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); const sseText = "event: custom\ndata: {}\n\n" + 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n' + "data: [DONE]\n\n"; const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); const decodedChunks = await collectChunks( stream.pipeThrough(new AssistantTransportDecoder({ strict: false })), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "Hello", path: [] }, ]); expect(error).toHaveBeenCalledWith( "Ignored unknown SSE event type: custom", ); error.mockRestore(); }); it("tolerates streams ending without [DONE]", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const sseText = 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n'; const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); const decodedChunks = await collectChunks( stream.pipeThrough(new AssistantTransportDecoder({ strict: false })), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "Hello", path: [] }, ]); expect(warn).toHaveBeenCalledWith( "Stream ended abruptly without receiving [DONE] marker", ); warn.mockRestore(); }); it("drops invalid JSON and empty data lines", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const sseText = "data: {not json\n\n" + "data: \n\n" + 'data: {"type":"text-delta","textDelta":"Hello","path":[]}\n\n' + "data: [DONE]\n\n"; const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); const decodedChunks = await collectChunks( stream.pipeThrough(new AssistantTransportDecoder({ strict: false })), ); expect(decodedChunks).toEqual([ { type: "text-delta", textDelta: "Hello", path: [] }, ]); warn.mockRestore(); }); }); });