import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { AssistantEvent } from "../api/index.js"; import type { TurnChannelContext, TurnInterfaceContext, } from "../channels/types.js"; import type { Conversation } from "../daemon/conversation.js"; import { persistUserMessage as persistUserMessageImpl } from "../daemon/conversation-messaging.js"; import { setConfig } from "./helpers/set-config.js"; /** Seed the config the voice bridge reads: disclosure copy, plus disabled * secret detection and memory so the real persist path stays inert. */ function seedVoiceConfig(disclosure: { enabled: boolean; text: string }): void { setConfig("secretDetection", { enabled: false }); setConfig("calls", { disclosure }); setConfig("memory", { enabled: false, v2: { enabled: false } }); } let voiceConversationFactory: (() => Conversation) | null = null; mock.module("../daemon/conversation-store.js", () => ({ getOrCreateConversation: async () => { if (!voiceConversationFactory) { throw new Error("voiceConversationFactory not set for test"); } return voiceConversationFactory(); }, })); import { CALL_OPENING_MARKER } from "../calls/voice-control-protocol.js"; import { startVoiceTurn } from "../calls/voice-session-bridge.js"; import { createConversation, getMessages, } from "../persistence/conversation-crud.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { assistantEventHub, broadcastMessage, } from "../runtime/assistant-event-hub.js"; await initializeDb(); /** * Build a session that emits multiple events via the onEvent callback, * simulating assistant text deltas followed by message_complete. */ function makeStreamingSession(events: AssistantEvent[]): Conversation { return { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async ( _content: string, _messageId: string, options?: { onEvent?: (msg: AssistantEvent) => void }, ) => { const onEvent = options?.onEvent ?? (() => {}); for (const event of events) { onEvent(event); } }, handleConfirmationResponse: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], abort: () => {}, } as unknown as Conversation; } function makePersistingStreamingSession( conversationId: string, events: AssistantEvent[], ): Conversation & { callSessionId?: string } { type PersistUserMessageContext = Parameters[0]; let turnChannelContext: TurnChannelContext | null = null; let turnInterfaceContext: TurnInterfaceContext | null = null; let processing = false; const session = { conversationId, messages: [], abortController: null, currentRequestId: undefined, queue: {} as never, trustContext: undefined, isProcessing: () => processing, setProcessing: (value: boolean) => { processing = value; }, persistUserMessage: async ( ...args: Parameters ) => persistUserMessageImpl(session, ...args), getTurnChannelContext: () => turnChannelContext, getTurnInterfaceContext: () => turnInterfaceContext, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: (ctx: Parameters[0]) => { session.trustContext = ctx ?? undefined; }, setCommandIntent: () => {}, setTurnChannelContext: (ctx: TurnChannelContext) => { turnChannelContext = ctx; }, setTurnInterfaceContext: (ctx: TurnInterfaceContext) => { turnInterfaceContext = ctx; }, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async ( _content: string, _messageId: string, options?: { onEvent?: (msg: AssistantEvent) => void }, ) => { const onEvent = options?.onEvent ?? (() => {}); for (const event of events) { onEvent(event); } processing = false; session.abortController = null; session.currentRequestId = undefined; }, handleConfirmationResponse: () => {}, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation & PersistUserMessageContext & { callSessionId?: string; }; return session; } function parsePersistedMetadata( metadata: string | null | undefined, ): Record { if (!metadata) { throw new Error("Expected persisted message metadata"); } return JSON.parse(metadata) as Record; } /** * Helper to inject voice bridge deps with a given conversation factory. */ function injectDeps(conversationFactory: () => Conversation): void { voiceConversationFactory = conversationFactory; } describe("voice-session-bridge", () => { beforeEach(() => { seedVoiceConfig({ enabled: false, text: "" }); const db = getDb(); db.run("DELETE FROM messages"); db.run("DELETE FROM conversations"); }); test("throws when deps not injected", async () => { // Reset the module-level orchestrator by re-calling with undefined // (we can't easily reset module state, so we test the fresh import path) // Instead, test that startVoiceTurn works after injection expect(true).toBe(true); // placeholder — real test below }); test("startVoiceTurn forwards text deltas to onTextDelta callback", async () => { const conversation = createConversation("voice bridge delta test"); const events: AssistantEvent[] = [ { type: "assistant_text_delta", text: "Hello ", conversationId: conversation.id, }, { type: "assistant_text_delta", text: "world", conversationId: conversation.id, }, { type: "message_complete", conversationId: conversation.id }, ]; const session = makeStreamingSession(events); injectDeps(() => session); const receivedDeltas: string[] = []; let completed = false; const handle = await startVoiceTurn({ conversationId: conversation.id, content: "Hello from caller", isInbound: true, onTextDelta: (text) => receivedDeltas.push(text), onComplete: () => { completed = true; }, onError: () => {}, }); // Wait for async agent loop await new Promise((r) => setTimeout(r, 50)); expect(receivedDeltas).toEqual(["Hello ", "world"]); expect(completed).toBe(true); expect(handle.turnId).toBeDefined(); expect(typeof handle.abort).toBe("function"); }); test("startVoiceTurn forwards error events to onError callback", async () => { const conversation = createConversation("voice bridge error test"); const events: AssistantEvent[] = [ { type: "error", message: "Provider unavailable" }, ]; const session = makeStreamingSession(events); injectDeps(() => session); const receivedErrors: string[] = []; await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: (msg) => receivedErrors.push(msg), }); await new Promise((r) => setTimeout(r, 50)); expect(receivedErrors).toEqual(["Provider unavailable"]); }); test("abort handle cancels the in-flight turn", async () => { const conversation = createConversation("voice bridge abort test"); let abortCalled = false; const session = { isProcessing: () => false, currentRequestId: undefined as string | undefined, persistUserMessage: (options: { requestId?: string }) => { session.currentRequestId = options.requestId; return { id: "test-msg-id", deduplicated: false }; }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { await new Promise((r) => setTimeout(r, 200)); }, handleConfirmationResponse: () => {}, abort: () => { abortCalled = true; }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); const handle = await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); handle.abort(); expect(abortCalled).toBe(true); }); test("startVoiceTurn passes callSite: 'callAgent' to runAgentLoop", async () => { const conversation = createConversation("voice bridge callSite test"); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedOptions: Record | undefined; const session = { ...makeStreamingSession(events), runAgentLoop: async ( _content: string, _messageId: string, options?: Record, ) => { capturedOptions = options; const onEvent = (options as { onEvent?: (msg: AssistantEvent) => void })?.onEvent ?? (() => {}); for (const event of events) { onEvent(event); } }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(capturedOptions).toBeDefined(); expect(capturedOptions?.callSite).toBe("callAgent"); }); test("startVoiceTurn declares the turn interactive so approval prompts are raised for its observer", async () => { // A caller is on the line. If the turn ran non-interactive the permission // checker would deny an approval-gated tool before any prompt existed, and // the bridge's approval observer (auto-resolve for a non-guardian caller, a // real card for the guardian) would never get to decide. Presence is per // turn, so the bridge has to say so. const conversation = createConversation("voice bridge interactive test"); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedOptions: Record | undefined; const session = { ...makeStreamingSession(events), runAgentLoop: async ( _content: string, _messageId: string, options?: Record, ) => { capturedOptions = options; const onEvent = (options as { onEvent?: (msg: AssistantEvent) => void })?.onEvent ?? (() => {}); for (const event of events) { onEvent(event); } }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(capturedOptions?.isInteractive).toBe(true); }); test("external AbortSignal triggers turn abort", async () => { const conversation = createConversation("voice bridge signal test"); let abortCalled = false; const session = { isProcessing: () => false, currentRequestId: undefined as string | undefined, persistUserMessage: (options: { requestId?: string }) => { session.currentRequestId = options.requestId; return { id: "test-msg-id", deduplicated: false }; }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { await new Promise((r) => setTimeout(r, 200)); }, handleConfirmationResponse: () => {}, abort: () => { abortCalled = true; }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); const ac = new AbortController(); await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, signal: ac.signal, }); // Abort via the external controller ac.abort(); // Give the event listener a microtask to fire await new Promise((r) => setTimeout(r, 10)); expect(abortCalled).toBe(true); }); test("startVoiceTurn passes turnChannelContext with voice channel", async () => { const conversation = createConversation( "voice bridge channel context test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedTurnChannelContext: unknown = null; const session = { ...makeStreamingSession(events), setTurnChannelContext: (ctx: unknown) => { capturedTurnChannelContext = ctx; }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(capturedTurnChannelContext).toEqual({ userMessageChannel: "phone", assistantMessageChannel: "phone", }); }); test("startVoiceTurn defaults persisted voice metadata to phone", async () => { const conversation = createConversation( "voice bridge phone metadata default test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; const session = makePersistingStreamingSession(conversation.id, events); injectDeps(() => session); let persistedUserMessageId: string | undefined; await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, callbacks: { persisted_user_message_id: (messageId) => { persistedUserMessageId = messageId; }, }, }); await new Promise((r) => setTimeout(r, 50)); const persisted = getMessages(conversation.id).find( (message) => message.id === persistedUserMessageId, ); const metadata = parsePersistedMetadata(persisted?.metadata); expect(persisted).toBeDefined(); expect(metadata).toMatchObject({ userMessageChannel: "phone", assistantMessageChannel: "phone", userMessageInterface: "phone", assistantMessageInterface: "phone", }); }); test("startVoiceTurn can persist local live voice metadata and callbacks", async () => { const conversation = createConversation( "voice bridge local live voice metadata test", ); const events: AssistantEvent[] = [ { type: "assistant_text_delta", text: "Hi", conversationId: conversation.id, }, { type: "message_complete", conversationId: conversation.id, messageId: "assistant-msg-1", }, ]; let capturedVoiceSessionId: string | undefined; const capturedPrompts: Array = []; const session = makePersistingStreamingSession(conversation.id, events); session.setVoiceCallControlPrompt = (prompt: string | null) => { capturedPrompts.push(prompt); }; voiceConversationFactory = () => session; const textDeltaEvents: AssistantEvent[] = []; const completeEvents: AssistantEvent[] = []; let persistedUserMessageId: string | undefined; let persistedAssistantMessageId: string | undefined; await startVoiceTurn({ conversationId: conversation.id, voiceSessionId: "live-voice-session-1", userMessageChannel: "vellum", assistantMessageChannel: "vellum", userMessageInterface: "macos", assistantMessageInterface: "macos", // Synthetic fixture — this test only asserts pass-through of a // caller-supplied prompt, not the production live-voice prompt (that // string is pinned in live-voice-events.test.ts). voiceControlPrompt: "test control prompt", content: "Hello from local live voice", isInbound: true, callbacks: { assistant_text_delta: (msg) => textDeltaEvents.push(msg), message_complete: (msg) => completeEvents.push(msg), persisted_user_message_id: (messageId) => { persistedUserMessageId = messageId; capturedVoiceSessionId = session.callSessionId; }, persisted_assistant_message_id: (messageId) => { persistedAssistantMessageId = messageId; }, }, }); await new Promise((r) => setTimeout(r, 50)); expect(capturedVoiceSessionId).toBe("live-voice-session-1"); expect(capturedPrompts[0]).toBe("test control prompt"); expect(textDeltaEvents).toEqual([events[0]]); expect(completeEvents).toEqual([events[1]]); expect(persistedAssistantMessageId).toBe("assistant-msg-1"); const persisted = getMessages(conversation.id).find( (message) => message.id === persistedUserMessageId, ); const metadata = parsePersistedMetadata(persisted?.metadata); expect(persisted).toBeDefined(); expect(metadata).toMatchObject({ userMessageChannel: "vellum", assistantMessageChannel: "vellum", userMessageInterface: "macos", assistantMessageInterface: "macos", }); }); test("startVoiceTurn passes guardian context to the session", async () => { const conversation = createConversation( "voice bridge guardian context test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedTrustContext: unknown = null; const session = { ...makeStreamingSession(events), setTrustContext: (ctx: unknown) => { if (ctx != null) { capturedTrustContext = ctx; } }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); const trustCtx = { sourceChannel: "phone" as const, trustClass: "guardian" as const, guardianExternalUserId: "+15550001111", guardianChatId: "+15550001111", }; await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, assistantId: "test-assistant", trustContext: trustCtx, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(capturedTrustContext).toEqual(trustCtx); }); test("inbound non-guardian opener prompt uses pickup framing instead of outbound phrasing", async () => { const conversation = createConversation( "voice bridge inbound opener framing test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedPrompt: string | null = null; const session = { ...makeStreamingSession(events), setVoiceCallControlPrompt: (prompt: string | null) => { if (prompt != null) { capturedPrompt = prompt; } }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Hello there", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); if (!capturedPrompt) { throw new Error("Expected voice call control prompt to be set"); } const prompt: string = capturedPrompt; expect(prompt).toContain( "this is an inbound call you are answering (not a call you initiated)", ); expect(prompt).toContain( "Introduce yourself once at the start using your assistant name if you know it", ); expect(prompt).toContain( "If your assistant name is not known, skip the name and just identify yourself as the guardian's assistant.", ); expect(prompt).toContain( "Never use a UUID-shaped internal assistant ID as your spoken name.", ); expect(prompt).toContain( 'Do NOT say "I\'m calling" or "I\'m calling on behalf of".', ); }); test("inbound disclosure guidance is rewritten for pickup context", async () => { seedVoiceConfig({ enabled: true, text: "At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.", }); const conversation = createConversation( "voice bridge inbound disclosure rewrite test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; let capturedPrompt: string | null = null; const session = { ...makeStreamingSession(events), setVoiceCallControlPrompt: (prompt: string | null) => { if (prompt != null) { capturedPrompt = prompt; } }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Hi", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); if (!capturedPrompt) { throw new Error("Expected voice call control prompt to be set"); } const prompt: string = capturedPrompt; expect(prompt).toContain( "At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.", ); expect(prompt).toContain( "rewrite any disclosure naturally for pickup context", ); expect(prompt).toContain( 'Do NOT say "I\'m calling", "I called you", or "I\'m calling on behalf of".', ); }); test("auto-denies confirmation requests for non-guardian voice turns", async () => { const conversation = createConversation( "voice bridge auto-deny non-guardian test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleConfirmationCalls: Array<{ requestId: string; decision: string; decisionContext?: string; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { // Simulate the conversation emitting a confirmation_request to its // event observer (this is how the real conversation fans out). clientHandler({ type: "confirmation_request", requestId: "req-voice-1", toolName: "host_bash", input: { command: "rm -rf /" }, riskLevel: "high", allowlistOptions: [], scopeOptions: [], } as AssistantEvent); // The auto-deny resolves the prompter immediately, so the agent loop // can continue. In production the loop would continue; here we just // return to simulate completion. }, handleConfirmationResponse: ( requestId: string, decision: string, options?: { decisionContext?: string }, ) => { handleConfirmationCalls.push({ requestId, decision, decisionContext: options?.decisionContext, }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Delete everything", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", guardianExternalUserId: "+15550009999", guardianChatId: "+15550009999", requesterExternalUserId: "+15550002222", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); // The confirmation should have been auto-denied immediately expect(handleConfirmationCalls.length).toBe(1); expect(handleConfirmationCalls[0].requestId).toBe("req-voice-1"); expect(handleConfirmationCalls[0].decision).toBe("deny"); expect(handleConfirmationCalls[0].decisionContext).toContain("voice call"); expect(handleConfirmationCalls[0].decisionContext).toContain("host_bash"); // Phone callers get the guardian-access framing, not the local-session // could-not-verify copy. expect(handleConfirmationCalls[0].decisionContext).toContain( "requires guardian-level access", ); }); test("auto-denies with could-not-verify copy for local live-voice (vellum) turns", async () => { const conversation = createConversation( "voice bridge auto-deny vellum copy test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleConfirmationCalls: Array<{ requestId: string; decision: string; decisionContext?: string; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { clientHandler({ type: "confirmation_request", requestId: "req-voice-vellum", toolName: "host_bash", input: { command: "touch /tmp/x" }, riskLevel: "medium", allowlistOptions: [], scopeOptions: [], } as AssistantEvent); }, handleConfirmationResponse: ( requestId: string, decision: string, options?: { decisionContext?: string }, ) => { handleConfirmationCalls.push({ requestId, decision, decisionContext: options?.decisionContext, }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); // No trustContext: the local session's guardian trust could not be // resolved (fresh install, gateway unreachable). The turn is still the // device owner's own client, so the deny copy must say verification // failed rather than implying they lack guardian access. await startVoiceTurn({ conversationId: conversation.id, userMessageChannel: "vellum", userMessageInterface: "macos", content: "run a command", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(handleConfirmationCalls.length).toBe(1); expect(handleConfirmationCalls[0].decision).toBe("deny"); expect(handleConfirmationCalls[0].decisionContext).toContain( "could not be verified for this voice session", ); expect(handleConfirmationCalls[0].decisionContext).toContain("text chat"); expect(handleConfirmationCalls[0].decisionContext).not.toContain( "requires guardian-level access", ); }); test("auto-denies confirmation requests for unverified_channel voice turns", async () => { const conversation = createConversation( "voice bridge auto-deny unverified test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleConfirmationCalls: Array<{ requestId: string; decision: string; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { clientHandler({ type: "confirmation_request", requestId: "req-voice-2", toolName: "network_request", input: { url: "https://evil.com" }, riskLevel: "medium", allowlistOptions: [], scopeOptions: [], } as AssistantEvent); }, handleConfirmationResponse: (requestId: string, decision: string) => { handleConfirmationCalls.push({ requestId, decision }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "Make a request", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "unknown", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(handleConfirmationCalls.length).toBe(1); expect(handleConfirmationCalls[0].requestId).toBe("req-voice-2"); expect(handleConfirmationCalls[0].decision).toBe("deny"); }); test("auto-denies confirmation requests when guardian context is missing", async () => { const conversation = createConversation( "voice bridge auto-deny unknown actor test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleConfirmationCalls: Array<{ requestId: string; decision: string; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { clientHandler({ type: "confirmation_request", requestId: "req-voice-unknown", toolName: "host_bash", input: { command: "touch /tmp/x" }, riskLevel: "medium", allowlistOptions: [], scopeOptions: [], } as AssistantEvent); }, handleConfirmationResponse: (requestId: string, decision: string) => { handleConfirmationCalls.push({ requestId, decision }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "run a command", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(handleConfirmationCalls.length).toBe(1); expect(handleConfirmationCalls[0].requestId).toBe("req-voice-unknown"); expect(handleConfirmationCalls[0].decision).toBe("deny"); }); test("auto-allows confirmation requests for guardian voice turns", async () => { const conversation = createConversation( "voice bridge auto-allow guardian test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleConfirmationCalls: Array<{ requestId: string; decision: string; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { clientHandler({ type: "confirmation_request", requestId: "req-voice-3", toolName: "host_bash", input: { command: "ls" }, riskLevel: "low", allowlistOptions: [], scopeOptions: [], } as AssistantEvent); // For verified guardian voice turns, the confirmation should be // auto-approved so the run can continue without a chat approval UI. }, handleConfirmationResponse: (requestId: string, decision: string) => { handleConfirmationCalls.push({ requestId, decision }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "List files", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "guardian", guardianExternalUserId: "+15550001111", guardianChatId: "+15550001111", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(handleConfirmationCalls.length).toBe(1); expect(handleConfirmationCalls[0].requestId).toBe("req-voice-3"); expect(handleConfirmationCalls[0].decision).toBe("allow"); }); // Wire-order invariant under test: the bridge must broadcast the // `confirmation_request` BEFORE resolving it — canonical rationale on the // broadcast in voice-session-bridge.ts's confirmation_request branch. The // fake's handleConfirmationResponse mirrors production's synchronous // `interaction_resolved` broadcast so the wire order is observable through // the event hub, which serializes publishes in call order. Its `emit` // mirrors a real conversation's: the sink (the hub) delivers first, then // observers run, so the bridge's resolution lands after the request. function makeConfirmationOrderingSession( conversationId: string, requestId: string, ): Conversation { let clientHandler: (msg: AssistantEvent) => void = () => {}; const emit = (msg: AssistantEvent) => { broadcastMessage(msg); clientHandler(msg); }; return { emit, isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { emit({ type: "confirmation_request", requestId, toolName: "host_bash", input: { command: "ls" }, riskLevel: "low", allowlistOptions: [], scopeOptions: [], conversationId, } as AssistantEvent); }, handleConfirmationResponse: (resolvedRequestId: string) => { broadcastMessage({ type: "interaction_resolved", requestId: resolvedRequestId, conversationId, kind: "confirmation", state: "approved", } as AssistantEvent); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; } async function collectConfirmationWireOrder( conversationId: string, turn: () => Promise, ): Promise<{ requestIndex: number; resolvedIndex: number }> { const published: AssistantEvent[] = []; const subscription = assistantEventHub.subscribe({ type: "process", filter: { conversationId }, callback: (event) => { published.push(event.message); }, }); try { await turn(); await new Promise((r) => setTimeout(r, 50)); } finally { subscription.dispose(); } return { requestIndex: published.findIndex( (m) => m.type === "confirmation_request", ), resolvedIndex: published.findIndex( (m) => m.type === "interaction_resolved", ), }; } test("broadcasts the confirmation_request before auto-allowing it (guardian)", async () => { const conversation = createConversation( "voice bridge confirmation order allow test", ); injectDeps(() => makeConfirmationOrderingSession(conversation.id, "req-order-allow"), ); const { requestIndex, resolvedIndex } = await collectConfirmationWireOrder( conversation.id, () => startVoiceTurn({ conversationId: conversation.id, content: "List files", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "guardian", guardianExternalUserId: "+15555550100", guardianChatId: "+15555550100", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }), ); expect(requestIndex).toBeGreaterThanOrEqual(0); expect(resolvedIndex).toBeGreaterThan(requestIndex); }); test("broadcasts the confirmation_request before auto-denying it (non-guardian)", async () => { const conversation = createConversation( "voice bridge confirmation order deny test", ); injectDeps(() => makeConfirmationOrderingSession(conversation.id, "req-order-deny"), ); const { requestIndex, resolvedIndex } = await collectConfirmationWireOrder( conversation.id, () => startVoiceTurn({ conversationId: conversation.id, content: "List files", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }), ); expect(requestIndex).toBeGreaterThanOrEqual(0); expect(resolvedIndex).toBeGreaterThan(requestIndex); }); test("auto-resolves secret requests for voice turns (no secret-entry UI)", async () => { const conversation = createConversation( "voice bridge secret auto-resolve test", ); let clientHandler: (msg: AssistantEvent) => void = () => {}; const handleSecretCalls: Array<{ requestId: string; value?: string; delivery?: "store" | "transient_send"; }> = []; const session = { isProcessing: () => false, persistUserMessage: async () => ({ id: "test-msg-id", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (handler: (msg: AssistantEvent) => void) => { clientHandler = handler; return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { clientHandler({ type: "secret_request", requestId: "req-secret-1", service: "github", field: "token", label: "GitHub Token", } as AssistantEvent); }, handleConfirmationResponse: () => {}, handleSecretResponse: ( requestId: string, value?: string, delivery?: "store" | "transient_send", ) => { handleSecretCalls.push({ requestId, value, delivery }); }, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); await startVoiceTurn({ conversationId: conversation.id, content: "check github status", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "guardian", guardianExternalUserId: "+15550001111", guardianChatId: "+15550001111", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); expect(handleSecretCalls.length).toBe(1); expect(handleSecretCalls[0].requestId).toBe("req-secret-1"); expect(handleSecretCalls[0].value).toBeUndefined(); expect(handleSecretCalls[0].delivery).toBe("store"); }); test("forcePromptSideEffects does not leak when persistUserMessage fails", async () => { const conversation = createConversation( "voice bridge forcePromptSideEffects leak test", ); const session = { isProcessing: () => false, forcePromptSideEffects: false, callSessionId: undefined as string | undefined, persistUserMessage: async () => { throw new Error("simulated persistence failure"); }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => {}, handleConfirmationResponse: () => {}, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation & { forcePromptSideEffects: boolean }; injectDeps(() => session); // Non-guardian voice would normally set forcePromptSideEffects = true. // The setup must fail before that assignment happens so the flag stays // false and cannot leak into subsequent non-voice turns. let caught: Error | null = null; try { await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); } catch (err) { caught = err as Error; } expect(caught?.message).toBe("simulated persistence failure"); expect(session.forcePromptSideEffects).toBe(false); }); test("turn state does not leak when persistUserMessage fails", async () => { const conversation = createConversation( "voice bridge turn state leak test", ); const lastSetterValue: Record = {}; const recordLast = (name: string) => (value: unknown): void => { lastSetterValue[name] = value; }; const session = { isProcessing: () => false, forcePromptSideEffects: false, callSessionId: undefined as string | undefined, persistUserMessage: async () => { throw new Error("simulated persistence failure"); }, setChannelCapabilities: recordLast("setChannelCapabilities"), setAssistantId: recordLast("setAssistantId"), setTrustContext: recordLast("setTrustContext"), setCommandIntent: recordLast("setCommandIntent"), setTurnChannelContext: recordLast("setTurnChannelContext"), setTurnInterfaceContext: recordLast("setTurnInterfaceContext"), setVoiceCallControlPrompt: recordLast("setVoiceCallControlPrompt"), addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => {}, handleConfirmationResponse: () => {}, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation & { forcePromptSideEffects: boolean; callSessionId?: string; }; session.callSessionId = "session-leak-test-precondition"; injectDeps(() => session); let caught: Error | null = null; try { await startVoiceTurn({ conversationId: conversation.id, voiceSessionId: "session-leak-test", content: "Hello", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); } catch (err) { caught = err as Error; } expect(caught?.message).toBe("simulated persistence failure"); expect(lastSetterValue.setChannelCapabilities).toBeNull(); expect(lastSetterValue.setTrustContext).toBeNull(); expect(lastSetterValue.setCommandIntent).toBeNull(); expect(lastSetterValue.setAssistantId).toBe("self"); expect(lastSetterValue.setVoiceCallControlPrompt).toBeNull(); expect(session.callSessionId).toBeUndefined(); expect(session.forcePromptSideEffects).toBe(false); }); test("cleanup on early persistUserMessage throw does not detach a prior turn's observer", async () => { const conversation = createConversation( "voice bridge sender detach guard test", ); const addEventObserverCalls: unknown[] = []; const session = { isProcessing: () => false, forcePromptSideEffects: false, callSessionId: undefined as string | undefined, persistUserMessage: async () => { throw new Error("persist failed before bridge installed callback"); }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: (observer: unknown) => { addEventObserverCalls.push(observer); return () => {}; }, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => {}, handleConfirmationResponse: () => {}, abort: () => {}, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation & { forcePromptSideEffects: boolean; callSessionId?: string; }; injectDeps(() => session); let caught: Error | null = null; try { await startVoiceTurn({ conversationId: conversation.id, voiceSessionId: "session-sender-detach-test", content: "Hello", isInbound: true, trustContext: { sourceChannel: "phone", trustClass: "trusted_contact", }, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); } catch (err) { caught = err as Error; } expect(caught?.message).toBe( "persist failed before bridge installed callback", ); // The bridge never reached its `conversation.addEventObserver(...)` // install site, so no observer was registered for cleanup to detach. expect(addEventObserverCalls).toEqual([]); }); test("pre-aborted signal triggers immediate abort", async () => { const conversation = createConversation("voice bridge pre-abort test"); let abortCalled = false; const session = { isProcessing: () => false, currentRequestId: undefined as string | undefined, persistUserMessage: (options: { requestId?: string }) => { session.currentRequestId = options.requestId; return { id: "test-msg-id", deduplicated: false }; }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, setVoiceCallControlPrompt: () => {}, addEventObserver: () => () => {}, ensureActorScopedHistory: async () => {}, runAgentLoop: async () => { await new Promise((r) => setTimeout(r, 200)); }, handleConfirmationResponse: () => {}, abort: () => { abortCalled = true; }, // The image-bearing profile pin reads the leg's history. getMessages: () => [], } as unknown as Conversation; injectDeps(() => session); const ac = new AbortController(); ac.abort(); // Pre-abort before calling startVoiceTurn await startVoiceTurn({ conversationId: conversation.id, content: "Hello", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, signal: ac.signal, }); expect(abortCalled).toBe(true); }); test("broadcasts a user_message_echo before the assistant reply streams (JARVIS-1258)", async () => { const conversation = createConversation( "voice bridge user echo ordering test", ); const events: AssistantEvent[] = [ { type: "assistant_text_delta", text: "Hi ", conversationId: conversation.id, }, { type: "assistant_text_delta", text: "there", conversationId: conversation.id, }, { type: "message_complete", conversationId: conversation.id }, ]; const session = makeStreamingSession(events); injectDeps(() => session); const published: AssistantEvent[] = []; const subscription = assistantEventHub.subscribe({ type: "process", filter: { conversationId: conversation.id }, callback: (event) => { published.push(event.message); }, }); try { await startVoiceTurn({ conversationId: conversation.id, content: "Hello from caller", isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); const echoIndex = published.findIndex( (m) => m.type === "user_message_echo", ); const firstDeltaIndex = published.findIndex( (m) => m.type === "assistant_text_delta", ); // The user turn boundary must be broadcast, and must precede the // assistant deltas — otherwise the web client folds the reply into the // previous assistant bubble until a /messages reconcile splits them. expect(echoIndex).toBeGreaterThanOrEqual(0); expect(firstDeltaIndex).toBeGreaterThan(echoIndex); expect(published[echoIndex]).toMatchObject({ type: "user_message_echo", text: "Hello from caller", conversationId: conversation.id, }); } finally { subscription.dispose(); } }); test("suppresses the user_message_echo for synthetic opener prompts", async () => { const conversation = createConversation( "voice bridge opener echo suppression test", ); const events: AssistantEvent[] = [ { type: "message_complete", conversationId: conversation.id }, ]; const session = makeStreamingSession(events); injectDeps(() => session); const published: AssistantEvent[] = []; const subscription = assistantEventHub.subscribe({ type: "process", filter: { conversationId: conversation.id }, callback: (event) => { published.push(event.message); }, }); try { await startVoiceTurn({ conversationId: conversation.id, content: CALL_OPENING_MARKER, isInbound: true, onTextDelta: () => {}, onComplete: () => {}, onError: () => {}, }); await new Promise((r) => setTimeout(r, 50)); // The opener is internal scaffolding — it persists a row so the model // wakes, but it is not user speech and must not render as a user bubble. expect(published.some((m) => m.type === "user_message_echo")).toBe(false); } finally { subscription.dispose(); } }); });