assistant-stream
Version:
Streaming utilities for AI assistants
171 lines (156 loc) • 6.02 kB
text/typescript
import { describe, expect, it } from "vitest";
import {
RESUMABLE_STREAM_ID_HEADER,
createResumableAssistantStreamResponse,
createResumeAssistantStreamResponse,
} from "../createResumableAssistantStreamResponse";
import { createResumableStreamContext } from "../ResumableStreamContext";
import { createInMemoryResumableStreamStore } from "../stores/InMemoryResumableStreamStore";
import {
AssistantTransportDecoder,
AssistantTransportEncoder,
} from "../../core/serialization/assistant-transport/AssistantTransport";
import { DataStreamDecoder } from "../../core/serialization/data-stream/DataStream";
import { AssistantMessageAccumulator } from "../../core/accumulators/assistant-message-accumulator";
import type { AssistantStreamChunk } from "../../core/AssistantStreamChunk";
import type { AssistantMessage } from "../../core/utils/types";
async function decodeViaDataStream(body: ReadableStream<Uint8Array>) {
const stream = body.pipeThrough(new DataStreamDecoder());
const out: string[] = [];
for await (const chunk of stream) {
if (chunk.type === "text-delta") out.push(chunk.textDelta);
}
return out.join("");
}
async function accumulate(
body: ReadableStream<Uint8Array>,
decoder: TransformStream<Uint8Array, AssistantStreamChunk>,
): Promise<AssistantMessage> {
const messages = body
.pipeThrough(decoder)
.pipeThrough(new AssistantMessageAccumulator());
let last: AssistantMessage | undefined;
for await (const msg of messages) last = msg;
if (!last) throw new Error("accumulator yielded no messages");
return last;
}
describe("resumable integration", () => {
it("ten concurrent consumers see identical bytes for a 50 chunk stream", async () => {
const ctx = createResumableStreamContext({
store: createInMemoryResumableStreamStore(),
});
const producer = await createResumableAssistantStreamResponse({
context: ctx,
streamId: "fanout",
callback: async (controller) => {
for (let i = 0; i < 50; i++) {
controller.appendText(`chunk-${i}`);
await Promise.resolve();
}
},
});
const consumers = await Promise.all(
Array.from({ length: 10 }, async () => {
const r = await ctx.resume("fanout");
if (!r) throw new Error("resume returned null");
return new Response(r).arrayBuffer();
}),
);
const producerBytes = await new Response(producer.body).arrayBuffer();
const expected = new Uint8Array(producerBytes);
for (const c of consumers) {
expect(new Uint8Array(c)).toEqual(expected);
}
const text = await decodeViaDataStream(
new Response(new Uint8Array(producerBytes)).body!,
);
expect(text).toBe(
Array.from({ length: 50 }, (_, i) => `chunk-${i}`).join(""),
);
});
it("non-ASCII payload round-trips byte-for-byte through resume", async () => {
const allCodePoints = Array.from({ length: 256 }, (_, i) =>
String.fromCodePoint(i),
).join("");
const emoji = "hello 🌍 🚀 ✨";
const payload = allCodePoints + emoji;
const ctx = createResumableStreamContext({
store: createInMemoryResumableStreamStore(),
});
const producer = await createResumableAssistantStreamResponse({
context: ctx,
streamId: "binary",
callback: (controller) => controller.appendText(payload),
});
const producerBytes = new Uint8Array(
await new Response(producer.body).arrayBuffer(),
);
const replay = await createResumeAssistantStreamResponse({
context: ctx,
streamId: "binary",
});
const replayBytes = new Uint8Array(
await new Response(replay.body).arrayBuffer(),
);
expect(replayBytes).toEqual(producerBytes);
expect(replay.headers.get(RESUMABLE_STREAM_ID_HEADER)).toBe("binary");
const decoded = await decodeViaDataStream(new Response(replayBytes).body!);
expect(decoded).toBe(payload);
});
it("AssistantTransportEncoder round-trips through resume into an identical message", async () => {
const ctx = createResumableStreamContext({
store: createInMemoryResumableStreamStore(),
});
const producer = await createResumableAssistantStreamResponse({
context: ctx,
streamId: "transport",
encoder: () => new AssistantTransportEncoder(),
callback: (controller) => {
controller.appendText("processing... ");
const tool = controller.addToolCallPart({
toolName: "lookup",
toolCallId: "tool-1",
args: { query: "weather" },
response: { result: { temperature: 72 } },
});
tool.close();
controller.appendText("done");
},
});
const producerMessage = await accumulate(
producer.body!,
new AssistantTransportDecoder(),
);
const replay = await createResumeAssistantStreamResponse({
context: ctx,
streamId: "transport",
encoder: () => new AssistantTransportEncoder(),
});
expect(replay.headers.get("content-type")).toBe("text/event-stream");
const replayMessage = await accumulate(
replay.body!,
new AssistantTransportDecoder(),
);
// Tool-call timing is measured by the consuming accumulator, so a replay
// re-measures it; compare parts modulo timing.
const withoutTiming = (parts: typeof replayMessage.parts) =>
parts.map((part) =>
part.type === "tool-call"
? (({ timing: _timing, ...rest }) => rest)(part)
: part,
);
expect(withoutTiming(replayMessage.parts)).toEqual(
withoutTiming(producerMessage.parts),
);
expect(replayMessage.status).toEqual(producerMessage.status);
const toolPart = replayMessage.parts.find((p) => p.type === "tool-call");
expect(toolPart).toBeDefined();
expect(toolPart).toMatchObject({
toolName: "lookup",
toolCallId: "tool-1",
args: { query: "weather" },
result: { temperature: 72 },
timing: { startedAt: expect.any(Number) },
});
});
});