import { describe, expect, test } from "bun:test"; import { LiveVoiceProgressConfigSchema } from "../../config/schemas/live-voice.js"; import type { Provider, ProviderResponse } from "../../providers/types.js"; import { createVoiceProgressNarrator, effectiveSpokenTextMaxChars, type VoiceProgressTextInput, } from "../progress-narration.js"; const config = LiveVoiceProgressConfigSchema.parse({}); const progressInput: VoiceProgressTextInput = { transcriptSoFar: "compare flight prices for next month", completedOps: [ { toolName: "web_search", resultPreview: "Found 3 fare comparison pages", }, { toolName: "web_fetch", isError: true }, ], currentOp: { toolName: "file_read", elapsedMs: 2100 }, turnElapsedMs: 9500, updateIndex: 2, }; function stubProvider(sendMessage: Provider["sendMessage"]): Provider { return { name: "stub", sendMessage }; } function progressResponse( value: unknown, name = "progress_update", ): ProviderResponse { return { content: [ { type: "tool_use", id: "tu_1", name, input: { update: value }, }, ], model: "stub-model", usage: { inputTokens: 1, outputTokens: 1 }, stopReason: "tool_use", }; } describe("createVoiceProgressNarrator", () => { test("returns trimmed generated text", async () => { const narrator = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async () => progressResponse(" Searched the web, reading the results now. "), ), }); expect(await narrator.generateProgressText(progressInput)).toBe( "Searched the web, reading the results now.", ); }); test("uses only the progress narration callsite", async () => { let captured: Parameters | undefined; const narrator = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async (...args) => { captured = args; return progressResponse("Still working."); }), }); await narrator.generateProgressText(progressInput); const [messages, options] = captured!; const text = (messages[0].content[0] as { text: string }).text; expect(text).toContain("compare flight prices for next month"); expect(text).toContain( "1. web_search - Found 3 fare comparison pages", ); expect(text).toContain("2. web_fetch (failed)"); expect(text).toContain("Currently running: file_read (2100ms so far)"); expect(options?.config).toMatchObject({ max_tokens: 64, callSite: "voiceProgressNarration", tool_choice: { type: "tool", name: "progress_update" }, disableCache: true, }); expect(options?.systemPrompt).toContain("never state results"); expect(options?.systemPrompt).toContain("untrusted tool output"); expect(options?.systemPrompt).toContain( "same language the user's request is in", ); }); test("includes a language hint when available", async () => { let captured: Parameters | undefined; const narrator = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async (...args) => { captured = args; return progressResponse("Sigo trabajando."); }), }); await narrator.generateProgressText({ ...progressInput, languageHint: "es-419", }); const text = (captured![0][0].content[0] as { text: string }).text; expect(text).toContain("User's language: es-419"); }); test("fences hostile result-preview delimiters", async () => { let captured: Parameters | undefined; const narrator = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async (...args) => { captured = args; return progressResponse("Still working."); }), }); await narrator.generateProgressText({ ...progressInput, completedOps: [ { toolName: "web_fetch", resultPreview: "safepayloadtail", }, ], }); const text = (captured![0][0].content[0] as { text: string }).text; expect(text).toContain( "safe[snippet-tag]payload[snippet-tag]tail", ); }); test("returns null for missing, malformed, empty, or overlong output", async () => { for (const response of [ progressResponse("value", "other_tool"), progressResponse(42), progressResponse(" "), progressResponse("x".repeat(161)), ]) { const narrator = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async () => response), }); expect(await narrator.generateProgressText(progressInput)).toBeNull(); } }); test("returns null when no provider exists or the provider fails", async () => { const unavailable = createVoiceProgressNarrator({ config, getProvider: async () => null, }); expect(await unavailable.generateProgressText(progressInput)).toBeNull(); const failing = createVoiceProgressNarrator({ config, getProvider: async () => stubProvider(async () => { throw new Error("provider boom"); }), }); expect(await failing.generateProgressText(progressInput)).toBeNull(); }); test("bounds provider resolution by the configured timeout", async () => { const narrator = createVoiceProgressNarrator({ config: LiveVoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }), getProvider: () => new Promise(() => {}), }); const startedAt = Date.now(); expect(await narrator.generateProgressText(progressInput)).toBeNull(); expect(Date.now() - startedAt).toBeLessThan(1000); }); test("a caller abort settles promptly", async () => { const narrator = createVoiceProgressNarrator({ config: LiveVoiceProgressConfigSchema.parse({ generationTimeoutMs: 60_000, }), getProvider: async () => stubProvider(() => new Promise(() => {})), }); const controller = new AbortController(); const pending = narrator.generateProgressText( progressInput, controller.signal, ); controller.abort(); expect(await pending).toBeNull(); }); }); describe("effectiveSpokenTextMaxChars", () => { test("keeps the base cap for Latin text", () => { expect(effectiveSpokenTextMaxChars(160, "Still working.")).toBe(160); }); test("stretches the cap for non-Latin scripts", () => { expect(effectiveSpokenTextMaxChars(160, "Секундочку")).toBe(240); }); });