import { describe, expect, it } from "vitest"; import { MessageUpdateStatus, MessageUpdateType, type MessageUpdate, } from "$lib/types/MessageUpdate"; import { applyStreamingMode, resolveStreamingMode, smoothStreamUpdates } from "./messageUpdates"; async function* fromArray(values: T[]): AsyncGenerator { for (const value of values) { yield value; } } async function collect(iter: AsyncGenerator) { const updates: MessageUpdate[] = []; for await (const update of iter) { updates.push(update); } return updates; } const streamText = (updates: MessageUpdate[]) => updates .filter((u) => u.type === MessageUpdateType.Stream) .map((u) => u.token) .join(""); describe("smoothStreamUpdates", () => { it("merges partial words and preserves final text", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "Hel" }, { type: MessageUpdateType.Stream, token: "lo " }, { type: MessageUpdateType.Stream, token: "wor" }, { type: MessageUpdateType.Stream, token: "ld!" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0, _internal: { detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null }, }) ); const streamedChunks = updates.filter((u) => u.type === MessageUpdateType.Stream); expect(streamedChunks.map((u) => u.token)).toEqual(["Hello ", "world!"]); expect(streamText(updates)).toBe("Hello world!"); }); it("flushes buffered stream text before in-flow updates", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "hello" }, { type: MessageUpdateType.Stream, token: " world" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); expect(updates[0]).toMatchObject({ type: MessageUpdateType.Stream }); expect(updates[1]).toMatchObject({ type: MessageUpdateType.Stream }); expect(updates[2]).toEqual({ type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished, }); expect(streamText(updates)).toBe("hello world"); }); it("passes out-of-band updates through without splitting words", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "hel" }, { type: MessageUpdateType.Title, title: "My conversation" }, { type: MessageUpdateType.RouterMetadata, route: "casual", model: "some-model" }, { type: MessageUpdateType.Stream, token: "lo" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); const chunks = updates.filter((u) => u.type === MessageUpdateType.Stream).map((u) => u.token); // Title and router metadata render outside the message text; they must // not force "hel" out as its own fragment. expect(chunks).toEqual(["hello"]); expect(updates.some((u) => u.type === MessageUpdateType.Title)).toBe(true); expect(updates.some((u) => u.type === MessageUpdateType.RouterMetadata)).toBe(true); }); it("spreads burst tokens over time", async () => { const bigToken = "word ".repeat(40); // 200 chars, 40 words const source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: bigToken }]; let nowMs = 0; const emitTimes: number[] = []; const iter = smoothStreamUpdates(fromArray(source), { minDelayMs: 5, maxDelayMs: 80, minRateCharsPerMs: 0.3, _internal: { now: () => nowMs, sleep: async (ms: number) => { nowMs += ms; }, detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null, }, }); for await (const update of iter) { if (update.type === MessageUpdateType.Stream) { emitTimes.push(nowMs); } } // Should have multiple emissions expect(emitTimes.length).toBeGreaterThan(5); // Gap between first and last emission should be significant (not instant dump) const totalSpread = (emitTimes.at(-1) ?? 0) - (emitTimes[0] ?? 0); expect(totalSpread).toBeGreaterThan(100); }); it("keeps delays within configured bounds", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "one two three four five six seven eight nine ten ", }, ]; const delays: number[] = []; let nowMs = 0; await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 5, maxDelayMs: 80, minRateCharsPerMs: 0.3, _internal: { now: () => nowMs, sleep: async (ms: number) => { delays.push(ms); nowMs += ms; }, detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null, }, }) ); expect(delays.length).toBeGreaterThan(2); expect(delays.every((d) => d >= 5 && d <= 80)).toBe(true); }); it("handles CJK text correctly", async () => { const source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: "你好,世界!" }]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); expect(streamText(updates)).toBe("你好,世界!"); }); it("propagates source errors to consumer", async () => { async function* failingSource(): AsyncGenerator { yield { type: MessageUpdateType.Stream, token: "hello " }; throw new Error("source failed"); } await expect( collect(smoothStreamUpdates(failingSource(), { minDelayMs: 0, maxDelayMs: 0 })) ).rejects.toThrow("source failed"); }); it("propagates source errors even when no full chunk was emitted yet", async () => { async function* failingSource(): AsyncGenerator { yield { type: MessageUpdateType.Stream, token: "hel" }; throw new Error("source failed"); } await expect( collect( smoothStreamUpdates(failingSource(), { minDelayMs: 0, maxDelayMs: 0, _internal: { detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null }, }) ) ).rejects.toThrow("source failed"); }); it("drains queued stream chunks before throwing source errors", async () => { async function* failingSource(): AsyncGenerator { yield { type: MessageUpdateType.Stream, token: "a " }; yield { type: MessageUpdateType.Stream, token: "b " }; yield { type: MessageUpdateType.Stream, token: "c " }; throw new Error("source failed"); } const seen: MessageUpdate[] = []; let seenError: Error | null = null; try { for await (const update of smoothStreamUpdates(failingSource(), { minDelayMs: 0, maxDelayMs: 0, _internal: { detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null }, })) { seen.push(update); } } catch (error) { seenError = error as Error; } expect(streamText(seen)).toBe("a b c "); expect(seenError?.message).toBe("source failed"); }); it("caps burst tail latency with backlog acceleration", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "word ".repeat(500) }, ]; let nowMs = 0; await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 5, maxDelayMs: 80, minRateCharsPerMs: 0.3, maxBufferedMs: 400, _internal: { now: () => nowMs, sleep: async (ms: number) => { nowMs += ms; }, detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null, }, }) ); // Everything queued at t=0 must finish draining by ~maxBufferedMs. expect(nowMs).toBeLessThanOrEqual(450); }); it("skips empty tokens gracefully", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "" }, { type: MessageUpdateType.Stream, token: "hello " }, { type: MessageUpdateType.Stream, token: "" }, { type: MessageUpdateType.Stream, token: "world!" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); expect(streamText(updates)).toBe("hello world!"); }); it("does not split words on keep-alive updates", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "hel" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.KeepAlive }, { type: MessageUpdateType.Stream, token: "lo" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); const chunks = updates.filter((u) => u.type === MessageUpdateType.Stream).map((u) => u.token); // The keep-alive between the two halves must not flush "hel" on its own. expect(chunks).toEqual(["hello"]); }); it("emits CJK text at word-level cadence", async () => { const text = "这是一个关于人工智能的长篇讨论,模型逐字输出中文内容。"; const source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: text }]; const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); const chunks = updates.filter((u) => u.type === MessageUpdateType.Stream); // CJK has no spaces; words must still stream out incrementally rather // than waiting for punctuation or end of stream. expect(chunks.length).toBeGreaterThan(5); expect(streamText(updates)).toBe(text); }); it("keeps emitting through boundary-less text (hashes, base64)", async () => { const blob = "QmFzZTY0RGF0YQ".repeat(30); // 420 chars, no word boundaries const tokens = blob.match(/.{1,60}/g) ?? []; const source: MessageUpdate[] = tokens.map((token) => ({ type: MessageUpdateType.Stream, token, })); const updates = await collect( smoothStreamUpdates(fromArray(source), { minDelayMs: 0, maxDelayMs: 0 }) ); const chunks = updates.filter((u) => u.type === MessageUpdateType.Stream); // Without the pending-buffer cap this would be held until stream end // and dumped as a single chunk. expect(chunks.length).toBeGreaterThan(3); expect(streamText(updates)).toBe(blob); }); it("closes the source iterator when the consumer stops early", async () => { let closed = false; async function* source(): AsyncGenerator { try { yield { type: MessageUpdateType.Stream, token: "a " }; yield { type: MessageUpdateType.Stream, token: "b " }; yield { type: MessageUpdateType.Stream, token: "c " }; } finally { closed = true; } } const iter = smoothStreamUpdates(source(), { minDelayMs: 0, maxDelayMs: 0 }); await iter.next(); await iter.return(undefined); await new Promise((resolve) => setImmediate(resolve)); expect(closed).toBe(true); }); }); /** * Deterministic virtual clock: drives the smoother and a timed source from the * same simulated timeline so emission timing can be asserted exactly. */ function createVirtualClock() { let nowMs = 0; let seq = 0; const timers: Array<{ at: number; seq: number; resolve: () => void }> = []; const sleep = (ms: number) => new Promise((resolve) => { timers.push({ at: nowMs + Math.max(0, ms), seq: seq++, resolve }); }); const tick = () => new Promise((resolve) => setImmediate(resolve)); const run = async (promise: Promise): Promise => { let settled = false; promise.then( () => (settled = true), () => (settled = true) ); let idleTicks = 0; while (!settled) { await tick(); if (settled) break; if (timers.length === 0) { if (++idleTicks > 20) throw new Error(`virtual clock stalled at t=${nowMs}ms`); continue; } idleTicks = 0; timers.sort((a, b) => a.at - b.at || a.seq - b.seq); const timer = timers.shift(); if (!timer) continue; nowMs = Math.max(nowMs, timer.at); timer.resolve(); } return promise; }; return { now: () => nowMs, sleep, run }; } type Clock = ReturnType; type TimedEvent = { at: number; update: MessageUpdate }; async function* timedSource(events: TimedEvent[], clock: Clock): AsyncGenerator { for (const event of events) { const wait = event.at - clock.now(); if (wait > 0) await clock.sleep(wait); yield event.update; } } async function collectTimed(events: TimedEvent[], clock: Clock) { const emitted: Array<{ at: number; token: string }> = []; await clock.run( (async () => { const iter = smoothStreamUpdates(timedSource(events, clock), { _internal: { now: clock.now, sleep: clock.sleep }, }); for await (const update of iter) { if (update.type === MessageUpdateType.Stream) { emitted.push({ at: clock.now(), token: update.token }); } } })() ); return emitted; } describe("smoothStreamUpdates timing", () => { const word = (at: number, token = "lorem "): TimedEvent => ({ at, update: { type: MessageUpdateType.Stream, token }, }); it("shows the first word the moment it arrives", async () => { const clock = createVirtualClock(); const emitted = await collectTimed([word(0, "Hello "), word(30, "world ")], clock); expect(emitted[0]).toMatchObject({ at: 0, token: "Hello " }); }); it("adds no latency to streams slower than the pacing floor", async () => { const clock = createVirtualClock(); const events = Array.from({ length: 10 }, (_, i) => word(i * 100)); const emitted = await collectTimed(events, clock); // Each word renders exactly when it arrives; pacing reservations // (~20ms per word at the floor rate) are absorbed by the 100ms gaps. expect(emitted.map((e) => e.at)).toEqual(events.map((e) => e.at)); }); it("spreads a large single burst evenly until the buffered deadline", async () => { const clock = createVirtualClock(); const emitted = await collectTimed([word(0, "lorem ".repeat(300))], clock); expect(emitted.length).toBe(300); // Drains close to (and never far past) the 400ms default budget. const last = emitted.at(-1)?.at ?? 0; expect(last).toBeGreaterThan(300); expect(last).toBeLessThanOrEqual(450); // Linear spread: no large rendering gap while a backlog exists. let maxGap = 0; for (let i = 1; i < emitted.length; i++) { maxGap = Math.max(maxGap, emitted[i].at - emitted[i - 1].at); } expect(maxGap).toBeLessThanOrEqual(25); }); it("never holds a chunk more than maxBufferedMs past its arrival", async () => { const clock = createVirtualClock(); // Fast provider: 60 chars every 10ms (~6000 cps) for 300ms. const events = Array.from({ length: 30 }, (_, i) => word(i * 10, "lorem ".repeat(10))); const arrivals: Array<{ endIndex: number; at: number }> = []; let total = 0; for (const e of events) { if (e.update.type === MessageUpdateType.Stream) { total += e.update.token.length; arrivals.push({ endIndex: total, at: e.at }); } } const arrivedAt = (charIndex: number) => arrivals.find((a) => charIndex < a.endIndex)?.at ?? 0; const emitted = await collectTimed(events, clock); let emittedChars = 0; for (const e of emitted) { const lastChar = emittedChars + e.token.length - 1; expect(e.at - arrivedAt(lastChar)).toBeLessThanOrEqual(410); emittedChars += e.token.length; } expect(emittedChars).toBe(total); }); it("recovers promptly after a mid-stream stall", async () => { const clock = createVirtualClock(); const events = [ ...Array.from({ length: 5 }, (_, i) => word(i * 30)), // 2s stall, then the stream resumes. ...Array.from({ length: 5 }, (_, i) => word(2000 + i * 30)), ]; const emitted = await collectTimed(events, clock); const firstAfterStall = emitted.find((e) => e.at >= 2000); expect(firstAfterStall?.at).toBe(2000); expect(emitted.length).toBe(10); }); }); describe("applyStreamingMode", () => { it("keeps stream unchanged for raw mode", async () => { const source: MessageUpdate[] = [ { type: MessageUpdateType.Stream, token: "Hello" }, { type: MessageUpdateType.Status, status: MessageUpdateStatus.Finished }, ]; const raw = await collect(applyStreamingMode(fromArray(source), "raw")); expect(raw).toEqual(source); }); }); describe("resolveStreamingMode", () => { it("returns explicit streamingMode when set", () => { expect(resolveStreamingMode({ streamingMode: "raw" })).toBe("raw"); expect(resolveStreamingMode({ streamingMode: "smooth" })).toBe("smooth"); }); it("defaults to smooth when unset", () => { expect(resolveStreamingMode({})).toBe("smooth"); }); it("maps unsupported legacy values to smooth", () => { expect(resolveStreamingMode({ streamingMode: "final" })).toBe("smooth"); }); });