assistant-stream
Version:
Streaming utilities for AI assistants
342 lines (291 loc) • 9.75 kB
text/typescript
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);
});
});