UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

342 lines (291 loc) • 9.75 kB
import { afterEach, describe, expect, it, vi } from "vitest"; import type { AssistantStreamChunk } from "../../AssistantStreamChunk"; import { promiseWithResolvers } from "../../../utils/promiseWithResolvers"; import { createMergeStream } from "./merge"; const textDelta = (textDelta: string): AssistantStreamChunk => ({ type: "text-delta", path: [0], textDelta, }); afterEach(() => { vi.restoreAllMocks(); }); describe("createMergeStream", () => { it("does not create readers for ordered raw chunks", async () => { const getReader = vi.spyOn(ReadableStream.prototype, "getReader"); const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; merger.enqueue(textDelta("a")); merger.enqueue(textDelta("b")); merger.enqueue(textDelta("c")); expect(getReader).not.toHaveBeenCalled(); merger.seal(); await merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); expect(received).toEqual([textDelta("a"), textDelta("b"), textDelta("c")]); }); it("preserves raw chunk order around a merged stream", async () => { const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; merger.enqueue(textDelta("before-1")); merger.enqueue(textDelta("before-2")); merger.addStream( new ReadableStream({ start(controller) { controller.enqueue(textDelta("child")); controller.close(); }, }), ); merger.enqueue(textDelta("after")); merger.seal(); await merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); expect(received).toEqual([ textDelta("before-1"), textDelta("before-2"), textDelta("child"), textDelta("after"), ]); }); it("keeps an immediately ready child ahead of a later raw chunk", async () => { const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; merger.addStream( new ReadableStream({ start(controller) { controller.enqueue(textDelta("child")); controller.close(); }, }), ); merger.enqueue(textDelta("raw")); merger.seal(); await merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); expect(received).toEqual([textDelta("child"), textDelta("raw")]); }); it("preserves child readiness between raw chunks", async () => { let childController!: ReadableStreamDefaultController<AssistantStreamChunk>; const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; merger.addStream( new ReadableStream({ start(controller) { childController = controller; }, }), ); merger.enqueue(textDelta("before")); childController.enqueue(textDelta("child")); childController.close(); merger.enqueue(textDelta("after")); merger.seal(); await merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); expect(received).toEqual([ textDelta("before"), textDelta("child"), textDelta("after"), ]); }); it("does not block raw chunks on a delayed child", async () => { let childController!: ReadableStreamDefaultController<AssistantStreamChunk>; const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; merger.enqueue(textDelta("before")); merger.addStream( new ReadableStream({ start(controller) { childController = controller; }, }), ); merger.enqueue(textDelta("after")); merger.seal(); const drain = merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); await vi.waitFor(() => { expect(received).toEqual([textDelta("before"), textDelta("after")]); }); childController.enqueue(textDelta("child")); childController.close(); await drain; expect(received).toEqual([ textDelta("before"), textDelta("after"), textDelta("child"), ]); }); it("orders opted-in finishes across uneven child backlogs", async () => { const merger = createMergeStream(); const received: AssistantStreamChunk[] = []; let firstController!: ReadableStreamDefaultController<AssistantStreamChunk>; let secondController!: ReadableStreamDefaultController<AssistantStreamChunk>; const first = new ReadableStream<AssistantStreamChunk>({ start(controller) { firstController = controller; }, }); const second = new ReadableStream<AssistantStreamChunk>({ start(controller) { secondController = controller; }, }); const firstDelivered = promiseWithResolvers<void>(); const secondDelivered = promiseWithResolvers<void>(); let firstPrevious = Promise.resolve(); let secondPrevious = Promise.resolve(); merger.addStream(first, undefined, { orderedFinish: { previous: () => firstPrevious, delivered: firstDelivered, }, }); merger.addStream(second, undefined, { orderedFinish: { previous: () => secondPrevious, delivered: secondDelivered, }, }); firstPrevious = Promise.resolve(); firstController.enqueue(textDelta("first-1")); firstController.enqueue(textDelta("first-2")); firstController.enqueue({ type: "part-finish", path: [0] }); firstController.close(); secondPrevious = firstDelivered.promise; secondController.enqueue({ type: "part-finish", path: [1] }); secondController.close(); merger.seal(); await merger.readable.pipeTo( new WritableStream({ write(chunk) { received.push(chunk); }, }), ); expect( received .filter((chunk) => chunk.type === "part-finish") .map((chunk) => chunk.path), ).toEqual([[0], [1]]); }); it("propagates child errors while raw chunks are pending", async () => { const error = new Error("child failed"); const consoleError = vi .spyOn(console, "error") .mockImplementation(() => {}); let childController!: ReadableStreamDefaultController<AssistantStreamChunk>; const child = new ReadableStream<AssistantStreamChunk>({ start(controller) { childController = controller; }, }); const merger = createMergeStream(); merger.enqueue(textDelta("before")); merger.addStream(child); merger.enqueue(textDelta("after")); merger.seal(); const drain = merger.readable.pipeTo(new WritableStream()); childController.error(error); await expect(drain).rejects.toBe(error); await vi.waitFor(() => expect(child.locked).toBe(false)); expect(consoleError).toHaveBeenCalledWith(error); }); it("discards raw chunks after cancellation", async () => { const getReader = vi.spyOn(ReadableStream.prototype, "getReader"); const merger = createMergeStream(); merger.enqueue(textDelta("before")); await merger.readable.cancel(); merger.enqueue(textDelta("after")); expect(merger.isCancelled()).toBe(true); expect(getReader).not.toHaveBeenCalled(); }); it("releases child readers after successful completion", async () => { const child = new ReadableStream<never>({ start(controller) { controller.close(); }, }); const merger = createMergeStream(); merger.addStream(child); merger.seal(); await merger.readable.pipeTo(new WritableStream()); expect(child.locked).toBe(false); }); it("waits for every child reader to finish cancellation", async () => { let finishCleanup = () => {}; const cleanup = new Promise<void>((resolve) => { finishCleanup = resolve; }); const delayedCancel = vi.fn(() => cleanup); const rejectedCancel = vi.fn(() => Promise.reject(new Error("failed"))); const merger = createMergeStream(); const delayedStream = new ReadableStream({ cancel: delayedCancel }); const rejectedStream = new ReadableStream({ cancel: rejectedCancel }); merger.addStream(delayedStream); merger.addStream(rejectedStream); let cancelSettled = false; const cancel = merger.readable .getReader() .cancel() .then(() => { cancelSettled = true; }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(delayedCancel).toHaveBeenCalledOnce(); expect(rejectedCancel).toHaveBeenCalledOnce(); expect(cancelSettled).toBe(false); finishCleanup(); await expect(cancel).resolves.toBeUndefined(); expect(cancelSettled).toBe(true); expect(delayedStream.locked).toBe(false); expect(rejectedStream.locked).toBe(false); }); it("cancels streams rejected after sealing", async () => { const cancelSource = vi.fn(); const merger = createMergeStream(); merger.seal(); expect(() => merger.addStream(new ReadableStream({ cancel: cancelSource })), ).toThrow("Cannot add streams after the run callback has settled."); await vi.waitFor(() => expect(cancelSource).toHaveBeenCalledOnce()); }); }); describe("createMergeStream seal", () => { it("is idempotent once the underlying stream has closed", async () => { const merger = createMergeStream(); merger.seal(); const reader = merger.readable.getReader(); while (!(await reader.read()).done) { // drain } expect(() => merger.seal()).not.toThrow(); expect(merger.isSealed()).toBe(true); }); });