UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

202 lines (191 loc) • 6.9 kB
import { describe, expect, it } from "vitest"; import { UIMessageStreamDecoder } from "./UIMessageStream"; import { AssistantMessageAccumulator } from "../../accumulators/assistant-message-accumulator"; import type { AssistantMessage } from "../../utils/types"; async function decodeMessages(events: Record<string, unknown>[]) { const stream = new ReadableStream<Uint8Array<ArrayBuffer>>({ start(controller) { controller.enqueue( new TextEncoder().encode( [...events.map((event) => JSON.stringify(event)), "[DONE]"] .map((event) => `data: ${event}\n\n`) .join(""), ), ); controller.close(); }, }); const messages: AssistantMessage[] = []; await stream .pipeThrough(new UIMessageStreamDecoder()) .pipeThrough(new AssistantMessageAccumulator()) .pipeTo( new WritableStream({ write(value) { messages.push(value); }, }), ); return messages; } async function decode(events: Record<string, unknown>[]) { return (await decodeMessages(events)).at(-1)!; } describe("UIMessageStreamDecoder block identity", () => { it.each(["text", "reasoning"])( "updates %s provider metadata across start, delta, and end chunks", async (type) => { const startMetadata = { example: { phase: "start" } }; const deltaMetadata = { example: { phase: "delta" } }; const endMetadata = { example: { phase: "end" } }; const makeDelta = (text: string, providerMetadata?: object) => type === "text" ? { type: "text-delta", id: "part", textDelta: text, ...(providerMetadata && { providerMetadata }), } : { type: "reasoning-delta", id: "part", delta: text, ...(providerMetadata && { providerMetadata }), }; const messages = await decodeMessages([ { type: `${type}-start`, id: "part", providerMetadata: startMetadata, }, makeDelta("Hello", deltaMetadata), makeDelta(" world"), { type: `${type}-end`, id: "part", providerMetadata: endMetadata }, ]); expect( messages.map((message) => { const part = message.parts[0]; return part?.type === "text" || part?.type === "reasoning" ? part.providerMetadata : undefined; }), ).toEqual([ startMetadata, deltaMetadata, deltaMetadata, endMetadata, endMetadata, ]); }, ); it.each(["text", "reasoning"])( "keeps interleaved %s blocks separate", async (type) => { const message = await decode([ { type: "start", messageId: "message" }, { type: `${type}-start`, id: "first" }, { type: `${type}-delta`, id: "first", delta: "First " }, { type: `${type}-start`, id: "second" }, { type: `${type}-delta`, id: "second", delta: "Second " }, { type: `${type}-delta`, id: "first", delta: "answer." }, { type: `${type}-end`, id: "first" }, { type: `${type}-delta`, id: "second", delta: "answer." }, { type: `${type}-end`, id: "second" }, { type: "finish", finishReason: "stop" }, ]); expect(message.parts).toMatchObject([ { type, text: "First answer.", status: { type: "complete" } }, { type, text: "Second answer.", status: { type: "complete" } }, ]); }, ); it.each(["text", "reasoning"])( "preserves adjacent %s block boundaries", async (type) => { const message = await decode([ { type: `${type}-start`, id: "first" }, { type: `${type}-delta`, id: "first", delta: "First answer." }, { type: `${type}-end`, id: "first" }, { type: `${type}-start`, id: "second" }, { type: `${type}-delta`, id: "second", delta: "Second answer." }, { type: `${type}-end`, id: "second" }, ]); expect(message.parts).toMatchObject([ { type, text: "First answer." }, { type, text: "Second answer." }, ]); }, ); it("keeps a text block intact while reasoning arrives", async () => { const message = await decode([ { type: "text-start", id: "shared" }, { type: "text-delta", id: "shared", delta: "Answer: " }, { type: "reasoning-start", id: "shared" }, { type: "reasoning-delta", id: "shared", delta: "Thinking." }, { type: "text-delta", id: "shared", delta: "42" }, { type: "reasoning-end", id: "shared" }, { type: "text-end", id: "shared" }, ]); expect(message.parts).toMatchObject([ { type: "text", text: "Answer: 42" }, { type: "reasoning", text: "Thinking." }, ]); }); it.each(["text", "reasoning"])( "closes unfinished %s blocks at EOF", async (type) => { const message = await decode([ { type: `${type}-start`, id: "first" }, { type: `${type}-delta`, id: "first", delta: "First answer." }, { type: `${type}-start`, id: "second" }, { type: `${type}-delta`, id: "second", delta: "Second answer." }, ]); expect(message.parts).toMatchObject([ { type, text: "First answer.", status: { type: "complete" } }, { type, text: "Second answer.", status: { type: "complete" } }, ]); }, ); it.each(["text", "reasoning"])( "supports legacy %s deltas and ends without IDs", async (type) => { const delta = (text: string) => type === "text" ? { type: "text-delta", textDelta: text } : { type: "reasoning-delta", delta: text }; const message = await decode([ delta("Before a start."), { type: `${type}-start`, id: "part" }, delta("Inside the block."), { type: `${type}-end` }, delta("After an end."), ]); expect(message.parts).toMatchObject([ { type, text: "Before a start." }, { type, text: "Inside the block." }, { type, text: "After an end." }, ]); }, ); it.each(["text", "reasoning"])( "reuses a %s block ID after a step ends", async (type) => { const message = await decode([ { type: "start", messageId: "message" }, { type: "start-step" }, { type: `${type}-start`, id: "part" }, { type: `${type}-delta`, id: "part", delta: "First step." }, { type: "finish-step" }, { type: "start-step" }, { type: `${type}-start`, id: "part" }, { type: `${type}-delta`, id: "part", delta: "Second step." }, { type: "finish-step" }, { type: "finish" }, ]); expect(message.parts).toMatchObject([ { type, text: "First step.", status: { type: "complete" } }, { type, text: "Second step.", status: { type: "complete" } }, ]); }, ); });