import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { ClientOptions } from "./client"; import { VoiceController } from "./controller"; const { MockClient, closeContext, createContext } = vi.hoisted(() => { const closeContext = vi.fn().mockResolvedValue(undefined); const createContext = vi.fn(() => ({ state: "running", close: closeContext })); class MockClient { static instances: MockClient[] = []; readonly options: ClientOptions; private listeners = new Map void>>(); constructor(options: ClientOptions) { this.options = options; MockClient.instances.push(this); } on(name: string, listener: (value: unknown) => void) { const listeners = this.listeners.get(name) ?? new Set(); listeners.add(listener); this.listeners.set(name, listeners); return () => { listeners.delete(listener); }; } emit(name: string, value: unknown) { this.listeners.get(name)?.forEach((listener) => { listener(value); }); } connect = vi.fn(() => { this.emit("status", "listening"); return Promise.resolve(); }); close = vi.fn(() => { this.emit("status", "closed"); return Promise.resolve(); }); updateSession = vi.fn(); setGate = vi.fn(); setMuted = vi.fn(); renderExternalAnswer = vi.fn(); sendToolOutput = vi.fn(); join = vi.fn(); } return { MockClient, closeContext, createContext }; }); vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient })); vi.mock("../audio/engine", () => ({ createAudioContext: createContext })); let pendingReasoning: ((value: Response) => void) | undefined; let controller: VoiceController; let fetchMock: Mock<(path: string, init?: RequestInit) => Promise>; beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); createContext.mockReturnValue({ state: "running", close: closeContext }); MockClient.instances = []; pendingReasoning = undefined; vi.stubGlobal("AudioContext", vi.fn()); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } }); fetchMock = vi.fn((path: string) => { if (path === "/api/reasoning") { return new Promise((resolve) => { pendingReasoning = resolve; }); } const pathname = new URL(path, "http://localhost").pathname; const responses: Record = { "/api/config": { reasoningType: "OPENAI", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, "/api/user/phone": { result: { name: "Ana" } }, "/api/user/reports": { count: 0 }, }; return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {}))); }); vi.stubGlobal("fetch", fetchMock); controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" }); }); afterEach(async () => { await controller.stop(); vi.useRealTimers(); vi.unstubAllGlobals(); }); async function start() { await controller.initialize(new AbortController().signal); await controller.start(); const client = MockClient.instances[0]; if (!client) { throw new Error("No client created"); } return client; } describe("voice application lifecycle", () => { it.each(["TALK", "TALKFIRST"])( "automatically starts %s once with the greeting after initialization", async (startSessionMode) => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })), ); controller.offerAutoStart(); expect(MockClient.instances).toHaveLength(0); await controller.initialize(); controller.offerAutoStart(); controller.offerAutoStart(); expect(MockClient.instances).toHaveLength(1); expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero"); expect(controller.getSnapshot().status).toBe("listening"); await controller.stop(); controller.offerAutoStart(); expect(MockClient.instances).toHaveLength(1); expect(controller.getSnapshot().status).toBe("closed"); }, ); it.each(["", "UNSUPPORTED"])("leaves startup manual for mode %s", async (startSessionMode) => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })), ); await controller.initialize(); controller.offerAutoStart(); expect(createContext).not.toHaveBeenCalled(); expect(MockClient.instances).toHaveLength(0); await controller.start(); expect(MockClient.instances).toHaveLength(1); expect(MockClient.instances[0]?.options.greeting).toBeUndefined(); }); it("waits for caller context before the automatic attempt", async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ startSessionMode: "TALK", s2sUrl: "ws://localhost/v1/realtime" })), ); let finishProfile: ((value: Response) => void) | undefined; fetchMock.mockImplementationOnce( () => new Promise((resolve) => { finishProfile = resolve; }), ); const initializing = controller.initialize(); await vi.advanceTimersByTimeAsync(1); expect(controller.getSnapshot()).toMatchObject({ configReady: true, contextLoading: true }); controller.offerAutoStart(); expect(MockClient.instances).toHaveLength(0); finishProfile?.(new Response(JSON.stringify({ result: { name: "Ana" } }))); await initializing; controller.offerAutoStart(); expect(MockClient.instances).toHaveLength(1); }); it.each(["TALK", "TALKFIRST"])( "leaves a manual fallback when the browser blocks %s audio", async (startSessionMode) => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })), ); createContext.mockReturnValueOnce({ state: "suspended", close: closeContext }); await controller.initialize(); controller.offerAutoStart(); expect(closeContext).toHaveBeenCalledOnce(); expect(MockClient.instances).toHaveLength(0); expect(controller.getSnapshot()).toMatchObject({ status: "idle", error: "", notice: "Toca el micrófono para empezar a conversar.", }); controller.offerAutoStart(); expect(createContext).toHaveBeenCalledOnce(); await controller.start(); expect(MockClient.instances).toHaveLength(1); expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero"); expect(controller.getSnapshot()).toMatchObject({ status: "listening", notice: "" }); }, ); it("notifies only meter subscribers when microphone level changes", async () => { const client = await start(); const app = vi.fn(); const meter = vi.fn(); controller.subscribe(app); controller.subscribeLevel(meter); const snapshot = controller.getSnapshot(); client.emit("level", 0.2); expect(controller.getLevel()).toBe(0.2); expect(meter).toHaveBeenCalledOnce(); expect(app).not.toHaveBeenCalled(); expect(controller.getSnapshot()).toBe(snapshot); }); it("saves only changed preferences and keeps noise calibration out of session updates", async () => { const client = await start(); const save = vi.spyOn(Storage.prototype, "setItem"); client.updateSession.mockClear(); controller.changeSettings({ noiseGate: -35 }); expect(save).toHaveBeenCalledExactlyOnceWith("s2s.ws.noiseGate", "-35"); expect(client.setGate).toHaveBeenCalledExactlyOnceWith({ enabled: true, thresholdDb: -35 }); expect(client.updateSession).not.toHaveBeenCalled(); controller.changeSettings({ voice: "Serena", instructions: "New instructions" }); expect(client.updateSession).toHaveBeenCalledOnce(); controller.changeSettings({ voice: "Serena", instructions: "New instructions" }); expect(client.updateSession).toHaveBeenCalledOnce(); }); it("renders configuration and starts without waiting for optional reports", async () => { const original = fetchMock.getMockImplementation(); if (!original) { throw new Error("Missing fetch mock"); } let finishReports: ((value: Response) => void) | undefined; fetchMock.mockImplementation((path, init) => path.startsWith("/api/user/reports") ? new Promise((resolve) => { finishReports = resolve; }) : original(path, init), ); const initializing = controller.initialize(); await vi.advanceTimersByTimeAsync(1); expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: true, profile: { name: "Ana" } }); await controller.start(); expect(MockClient.instances).toHaveLength(1); finishReports?.(new Response(JSON.stringify({ count: 2 }))); await initializing; expect(controller.getSnapshot().reports?.count).toBe(2); }); it("waits for caller context but bounds an unavailable profile request", async () => { const original = fetchMock.getMockImplementation(); if (!original) { throw new Error("Missing fetch mock"); } fetchMock.mockImplementation((path, init) => path.startsWith("/api/user/phone") ? new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => { reject(new DOMException("Request cancelled", "AbortError")); }); }) : original(path, init), ); const initializing = controller.initialize(); await vi.advanceTimersByTimeAsync(1); expect(controller.getSnapshot()).toMatchObject({ loading: false, contextLoading: true }); await controller.start(); expect(MockClient.instances).toHaveLength(0); await vi.advanceTimersByTimeAsync(8000); await initializing; expect(controller.getSnapshot()).toMatchObject({ contextLoading: false, profile: null }); await controller.start(); expect(MockClient.instances).toHaveLength(1); }); it("blocks session startup after a configuration failure and allows retry", async () => { fetchMock.mockRejectedValueOnce(new Error("offline")); await controller.initialize(); expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: false }); await controller.start(); expect(MockClient.instances).toHaveLength(0); await controller.initialize(); expect(controller.getSnapshot()).toMatchObject({ configReady: true, configError: "" }); await controller.start(); expect(MockClient.instances).toHaveLength(1); }); it("discards a superseded initialization even if its fetch ignores cancellation", async () => { let finishConfig: ((value: Response) => void) | undefined; fetchMock.mockImplementationOnce( () => new Promise((resolve) => { finishConfig = resolve; }), ); const earlier = controller.initialize(); await controller.initialize(); finishConfig?.(new Response(JSON.stringify({ theme: "stale" }))); await earlier; expect(controller.getSnapshot()).toMatchObject({ configReady: true, config: { theme: "" } }); }); it("loads configuration and caller context without an account request on startup or stop", async () => { const client = await start(); await controller.stop(); expect(client.close).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([ "/api/config", "/api/user/phone", "/api/user/reports", ]); }); it("renders an external answer and preserves separate profile context", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " }))); await vi.advanceTimersByTimeAsync(1); const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning"); expect(request?.[1]?.body).toContain('"phoneNumber":"1234"'); expect(request?.[1]?.body).toContain("Caller profile resolved"); expect(client.options.instructions).not.toContain("Caller profile resolved"); expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana"); expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]); }); it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })), ); const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); await vi.advanceTimersByTimeAsync(1); expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]); }); it("discards external reasoning that finishes after the session was stopped", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false }); await controller.stop(); pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" }))); await vi.advanceTimersByTimeAsync(1); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]); }); it("aborts an older answer and discards it if it finishes after the current turn", async () => { const client = await start(); client.emit("transcript", { id: "older", role: "user", text: "Anterior", partial: false }); const earlier = pendingReasoning; const earlierSignal = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning")?.[1]?.signal; client.emit("transcript", { id: "newer", role: "user", text: "Actual", partial: false }); expect(earlierSignal?.aborted).toBe(true); pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta actual" }))); await vi.advanceTimersByTimeAsync(1); earlier?.(new Response(JSON.stringify({ answer: "Respuesta anterior" }))); await vi.advanceTimersByTimeAsync(1); expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Respuesta actual"); expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual([ "Anterior", "Actual", "Respuesta actual", ]); expect(controller.getSnapshot().error).toBe(""); }); it("invalidates pending reasoning as soon as the user interrupts", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Pregunta", partial: false }); client.emit("status", "user-speaking"); pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta interrumpida" }))); await vi.advanceTimersByTimeAsync(1); expect(client.renderExternalAnswer).not.toHaveBeenCalled(); expect(controller.getSnapshot().status).toBe("user-speaking"); }); it("applies and removes RAG context in the live session", async () => { const client = await start(); controller.setRag("Datos importantes"); expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []); controller.setRag(""); expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []); }); it("closes after silence but does not cut off the assistant while speaking", async () => { const client = await start(); client.emit("status", "ai-speaking"); await vi.advanceTimersByTimeAsync(60000); expect(client.close).not.toHaveBeenCalled(); client.emit("status", "listening"); await vi.advanceTimersByTimeAsync(30000); expect(client.close).toHaveBeenCalledOnce(); expect(controller.getSnapshot().status).toBe("closed"); }); it("does not count native processing or an external answer request as user inactivity", async () => { const client = await start(); client.emit("status", "processing"); await vi.advanceTimersByTimeAsync(60000); expect(client.close).not.toHaveBeenCalled(); client.emit("transcript", { id: "u1", role: "user", text: "Consulta lenta", partial: false }); client.emit("status", "listening"); await vi.advanceTimersByTimeAsync(60000); expect(client.close).not.toHaveBeenCalled(); expect(controller.getSnapshot().status).toBe("processing"); pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta" }))); await vi.advanceTimersByTimeAsync(1); expect(controller.getSnapshot().status).toBe("listening"); }); }); describe("authenticated MCP conversations", () => { const relayUrl = () => `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`; function enableMcp(reasoningType = "OPENAI") { controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" }); const original = fetchMock.getMockImplementation(); let sequence = 0; if (!original) { throw new Error("Missing fetch mock"); } fetchMock.mockImplementation((path, init) => { if (path === "/api/config") { return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() }))); } if (path === "/api/conversations") { sequence++; return Promise.resolve( new Response( JSON.stringify({ conversationId: `conversation-${String(sequence)}`, expiresIn: 3600, profile: { id: "alice", name: "Alice" }, }), ), ); } if (path === "/api/mcp/tools") { return Promise.resolve( new Response( JSON.stringify({ tools: [ { name: "list_reports", parameters: { properties: { request: { type: "object" } } }, requires_auth: true, }, ], }), ), ); } if (path.startsWith("/api/conversations/current")) { return Promise.resolve(new Response(null, { status: 204 })); } return original(path, init); }); } it("bootstraps once, keeps credentials out of storage, and skips legacy context loading", async () => { enableMcp("PROXY"); const client = await start(); expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config", "/api/conversations", "/api/mcp/tools"]); expect(controller.getSnapshot()).toMatchObject({ profile: { name: "Alice" }, reports: null, tools: [{ name: "list_reports" }], }); expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`); expect(client.options.instructions).not.toContain("APIPILOT"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); const discovery = fetchMock.mock.calls.find(([path]) => path === "/api/mcp/tools"); expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1"); await controller.stop(); const teardown = fetchMock.mock.calls.find(([path]) => path === "/api/conversations/current"); expect(teardown?.[1]).toMatchObject({ method: "DELETE", keepalive: true }); await controller.start(); expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2"); }); it("blocks voice startup when phone login fails and retries on a later manual start", async () => { enableMcp(); const original = fetchMock.getMockImplementation(); let denied = true; if (!original) { throw new Error("Missing fetch mock"); } fetchMock.mockImplementation((path, init) => path === "/api/conversations" && denied ? Promise.resolve(new Response(JSON.stringify({ detail: "Usuario no disponible." }), { status: 401 })) : original(path, init), ); await controller.initialize(); expect(controller.getSnapshot()).toMatchObject({ configReady: true, contextLoading: false, error: "Usuario no disponible.", }); controller.offerAutoStart(); await controller.start(); expect(MockClient.instances).toHaveLength(0); denied = false; await controller.start(); expect(MockClient.instances).toHaveLength(1); }); it("does not initialize voice when the phone selector is missing", async () => { enableMcp(); controller = new VoiceController({ phoneNumber: "", episodeId: "" }); await controller.initialize(); await controller.start(); expect(MockClient.instances).toHaveLength(0); expect(controller.getSnapshot().error).toContain("número de teléfono"); expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config"]); }); it("binds HTTP reasoning and cancellation to the conversation and exact turn", async () => { enableMcp(); const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false }); const old = pendingReasoning; const first = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning"); const firstHeaders = new Headers(first?.[1]?.headers); expect(firstHeaders.get("X-Vocero-Conversation")).toBe("conversation-1"); client.emit("status", "user-speaking"); const interrupt = fetchMock.mock.calls.find(([path]) => path.endsWith("/interrupt")); expect(new Headers(interrupt?.[1]?.headers).get("X-Vocero-Turn")).toBe(firstHeaders.get("X-Vocero-Turn")); client.emit("transcript", { id: "u2", role: "user", text: "El último", partial: false }); pendingReasoning?.(new Response(JSON.stringify({ answer: "Actual" }))); await vi.advanceTimersByTimeAsync(1); old?.(new Response(JSON.stringify({ answer: "Anterior" }))); await vi.advanceTimersByTimeAsync(1); expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Actual"); }); it("closes a revoked conversation without falling back to public providers", async () => { enableMcp(); const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false }); pendingReasoning?.(new Response(JSON.stringify({ detail: "La sesión expiró." }), { status: 401 })); await vi.advanceTimersByTimeAsync(1); expect(client.close).toHaveBeenCalledOnce(); expect(controller.getSnapshot()).toMatchObject({ status: "error", error: "La sesión expiró." }); expect(fetchMock.mock.calls.some(([path]) => path.startsWith("/api/user/"))).toBe(false); }); it("cleans up a conversation whose catalog arrives after teardown", async () => { enableMcp(); const original = fetchMock.getMockImplementation(); let finishCatalog: ((value: Response) => void) | undefined; if (!original) { throw new Error("Missing fetch mock"); } fetchMock.mockImplementation((path, init) => path === "/api/mcp/tools" ? new Promise((resolve) => { finishCatalog = resolve; }) : original(path, init), ); const initializing = controller.initialize(); await vi.advanceTimersByTimeAsync(1); await controller.stop(); finishCatalog?.(new Response(JSON.stringify({ tools: [] }))); await initializing; expect(controller.getSnapshot()).toMatchObject({ profile: null, tools: [], status: "closed", contextLoading: false, }); expect( fetchMock.mock.calls.some(([path, init]) => path === "/api/conversations/current" && init?.method === "DELETE"), ).toBe(true); expect(MockClient.instances).toHaveLength(0); }); it("keeps separate conversation IDs in separate controller instances", async () => { enableMcp(); await controller.initialize(); const other = new VoiceController({ phoneNumber: "5678", episodeId: "" }); await other.initialize(); await controller.start(); await other.start(); expect(MockClient.instances.map((client) => client.options.directUrl)).toEqual([ `${relayUrl()}?conversation_id=conversation-1`, `${relayUrl()}?conversation_id=conversation-2`, ]); await other.stop(); }); it("does not forward the conversation ID to a direct external speech service", async () => { enableMcp(); fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ mcp: true, reasoningType: "OPENAI", s2sUrl: "wss://speech.example.test/v1/realtime" }), ), ); const client = await start(); expect(client.options.directUrl).toBe("wss://speech.example.test/v1/realtime"); }); });