From 3f657157c4a2c229f069dbebf47832bd7c929033 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Wed, 25 Feb 2026 21:16:32 -0500 Subject: [PATCH] fix: prevent voice/phone call threads from leaking into desktop side nav Voice-bound conversations were appearing in the macOS desktop thread list after session restore because the filter explicitly allowed sourceChannel=voice. Guardian verification calls also leaked because they had no channel binding. Pointer messages set userMessageChannel=voice which contaminated origin channel. - Remove voice exception from desktop thread filters (restore + pagination) - Add voice channel binding to guardian verification conversations - Remove misleading "See voice thread" pointer text and channel metadata Co-Authored-By: Claude Opus 4.6 --- .../__tests__/call-pointer-messages.test.ts | 13 +- ...uardian-verification-voice-binding.test.ts | 116 ++++++++++++++++++ assistant/src/calls/call-domain.ts | 14 ++- assistant/src/calls/call-pointer-messages.ts | 7 +- .../Features/MainWindow/ThreadManager.swift | 2 +- .../MainWindow/ThreadSessionRestorer.swift | 2 +- .../ThreadSessionRestorerTests.swift | 58 ++++++++- 7 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 assistant/src/__tests__/guardian-verification-voice-binding.test.ts diff --git a/assistant/src/__tests__/call-pointer-messages.test.ts b/assistant/src/__tests__/call-pointer-messages.test.ts index 2b96fcdb7ed..8c2be3dfbd5 100644 --- a/assistant/src/__tests__/call-pointer-messages.test.ts +++ b/assistant/src/__tests__/call-pointer-messages.test.ts @@ -103,7 +103,18 @@ describe('addPointerMessage', () => { addPointerMessage(convId, 'started', '+15551234567'); const text = getLatestAssistantText(convId); expect(text).toContain('Call to +15551234567 started'); - expect(text).toContain('See voice thread'); + expect(text).not.toContain('See voice thread'); + }); + + test('started pointer message does not set userMessageChannel metadata', () => { + const convId = 'conv-ptr-no-channel'; + ensureConversation(convId); + addPointerMessage(convId, 'started', '+15551234567'); + const rows = getMessages(convId).filter((m) => m.role === 'assistant'); + expect(rows.length).toBe(1); + const metadata = rows[0].metadata ? JSON.parse(rows[0].metadata) : null; + // metadata should be null/undefined — no userMessageChannel set + expect(metadata?.userMessageChannel).toBeUndefined(); }); test('adds a started pointer message with verification code', () => { diff --git a/assistant/src/__tests__/guardian-verification-voice-binding.test.ts b/assistant/src/__tests__/guardian-verification-voice-binding.test.ts new file mode 100644 index 00000000000..201b2ef3730 --- /dev/null +++ b/assistant/src/__tests__/guardian-verification-voice-binding.test.ts @@ -0,0 +1,116 @@ +/** + * Regression test: guardian verification calls must create a voice channel + * binding so the conversation never appears as an unbound desktop thread. + */ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, mock, test } from 'bun:test'; + +const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'guardian-verify-binding-test-'))); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => + new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../calls/twilio-config.js', () => ({ + getTwilioConfig: () => ({ + accountSid: 'AC_test', + authToken: 'test_token', + phoneNumber: '+15550001111', + webhookBaseUrl: 'https://test.example.com', + wssBaseUrl: 'wss://test.example.com', + }), +})); + +mock.module('../calls/twilio-provider.js', () => ({ + TwilioConversationRelayProvider: class { + async checkCallerIdEligibility() { + return { eligible: true }; + } + async initiateCall() { + return { callSid: 'CA_test_guardian_verify' }; + } + }, +})); + +mock.module('../security/secure-keys.js', () => ({ + getSecureKey: () => null, +})); + +mock.module('../config/env.js', () => ({ + getTwilioUserPhoneNumber: () => null, +})); + +mock.module('../inbound/public-ingress-urls.js', () => ({ + getTwilioVoiceWebhookUrl: () => 'https://test.example.com/voice', + getTwilioStatusCallbackUrl: () => 'https://test.example.com/status', +})); + +mock.module('../config/loader.js', () => ({ + loadConfig: () => ({ + calls: { + callerIdentity: { + allowPerCallOverride: true, + }, + }, + }), +})); + +mock.module('../runtime/channel-guardian-service.js', () => ({ + isGuardian: () => false, +})); + +mock.module('../memory/conversation-title-service.js', () => ({ + queueGenerateConversationTitle: () => {}, +})); + +import { startGuardianVerificationCall } from '../calls/call-domain.js'; +import { getBindingByConversation } from '../memory/external-conversation-store.js'; +import { getOrCreateConversation } from '../memory/conversation-key-store.js'; +import { initializeDb, resetDb } from '../memory/db.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +describe('startGuardianVerificationCall — voice binding', () => { + test('creates a voice channel binding for the guardian verification conversation', async () => { + const sessionId = 'gv-session-001'; + const result = await startGuardianVerificationCall({ + phoneNumber: '+15559999999', + guardianVerificationSessionId: sessionId, + }); + + expect(result.ok).toBe(true); + + // Look up the conversation that was created for this guardian verification + const convKey = `guardian-verify:${sessionId}`; + const { conversationId } = getOrCreateConversation(convKey); + + // The conversation must have a voice channel binding + const binding = getBindingByConversation(conversationId); + expect(binding).not.toBeNull(); + expect(binding!.sourceChannel).toBe('voice'); + }); +}); diff --git a/assistant/src/calls/call-domain.ts b/assistant/src/calls/call-domain.ts index 11f16bc19d5..e940c87ddee 100644 --- a/assistant/src/calls/call-domain.ts +++ b/assistant/src/calls/call-domain.ts @@ -581,7 +581,7 @@ export type StartGuardianVerificationCallResult = /** * Initiate an outbound call to the guardian's phone for verification. * - * Creates a minimal call session (no task, no voice conversation) and + * Creates a minimal call session with a voice channel binding and * passes `guardianVerificationSessionId` as a custom parameter so the * relay server can detect this is a guardian verification call. */ @@ -606,12 +606,18 @@ export async function startGuardianVerificationCall( return { ok: false, error: identityResult.error, status: 400 }; } - // Create a minimal conversation so the call session has a valid FK. - // The relay will detect the guardianVerificationSessionId custom param - // and enter verification mode instead of starting a normal agent flow. + // Create a minimal conversation so the call session has a valid FK, + // and bind it to the voice channel so it never appears as an unbound + // desktop thread. const convKey = `guardian-verify:${guardianVerificationSessionId}`; const { conversationId } = getOrCreateConversation(convKey); + upsertBinding({ + conversationId, + sourceChannel: 'voice', + externalChatId: `guardian-verify:${guardianVerificationSessionId}`, + }); + const session = createCallSession({ conversationId, provider: 'twilio', diff --git a/assistant/src/calls/call-pointer-messages.ts b/assistant/src/calls/call-pointer-messages.ts index b132d2ef284..efc854830f2 100644 --- a/assistant/src/calls/call-pointer-messages.ts +++ b/assistant/src/calls/call-pointer-messages.ts @@ -19,7 +19,7 @@ export function addPointerMessage( case 'started': text = extra?.verificationCode ? `\u{1F4DE} Call to ${phoneNumber} started. Verification code: ${extra.verificationCode}` - : `\u{1F4DE} Call to ${phoneNumber} started. See voice thread for details.`; + : `\u{1F4DE} Call to ${phoneNumber} started.`; break; case 'completed': text = extra?.duration @@ -33,11 +33,14 @@ export function addPointerMessage( break; } + // Pointer messages are assistant-generated status updates in the initiating + // desktop thread. Do not set userMessageChannel — doing so would mark the + // conversation's origin channel as voice, causing it to leak into the + // desktop thread list as a channel-bound session. conversationStore.addMessage( conversationId, 'assistant', JSON.stringify([{ type: 'text', text }]), - { userMessageChannel: 'voice', assistantMessageChannel: 'voice', userMessageInterface: 'voice', assistantMessageInterface: 'voice' }, ); } diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift index ae712ff12f2..fe0c146d212 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift @@ -338,7 +338,7 @@ final class ThreadManager: ObservableObject, ThreadRestorerDelegate { serverOffset += response.sessions.count let recentSessions = response.sessions.filter { - $0.threadType != "private" && ($0.channelBinding?.sourceChannel == nil || $0.channelBinding?.sourceChannel == "voice") + $0.threadType != "private" && $0.channelBinding?.sourceChannel == nil } for session in recentSessions { diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift index 235a8c7b064..1cac9bba506 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadSessionRestorer.swift @@ -174,7 +174,7 @@ final class ThreadSessionRestorer { // (e.g. Telegram). External channel-bound sessions belong to their own // lane and should not appear in the desktop conversation list. let recentSessions = response.sessions.filter { - $0.threadType != "private" && ($0.channelBinding?.sourceChannel == nil || $0.channelBinding?.sourceChannel == "voice") + $0.threadType != "private" && $0.channelBinding?.sourceChannel == nil } let defaultThreadIsEmpty = delegate.threads.count == 1 diff --git a/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift b/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift index 135f712270c..ad954fd736e 100644 --- a/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift +++ b/clients/macos/vellum-assistantTests/ThreadSessionRestorerTests.swift @@ -105,11 +105,11 @@ private func makeSessionListResponse(sessions: [(id: String, title: String, upda } /// Build an IPCHistoryResponse via JSON round-trip. -private func makeHistoryResponse(sessionId: String, messages: [(role: String, text: String)]) -> HistoryResponseMessage { +private func makeHistoryResponse(sessionId: String, messages: [(role: String, text: String)], hasMore: Bool = false) -> HistoryResponseMessage { let msgDicts = messages.map { msg -> [String: Any] in ["role": msg.role, "text": msg.text, "timestamp": 1000.0] } - let dict: [String: Any] = ["type": "history_response", "sessionId": sessionId, "messages": msgDicts] + let dict: [String: Any] = ["type": "history_response", "sessionId": sessionId, "messages": msgDicts, "hasMore": hasMore] let data = try! JSONSerialization.data(withJSONObject: dict) return try! JSONDecoder().decode(HistoryResponseMessage.self, from: data) } @@ -519,6 +519,60 @@ struct ThreadSessionRestorerTests { #expect(delegate.createThreadCallCount == 1) } + @Test @MainActor + func voiceBoundSessionIsExcludedFromRestore() { + let dc = DaemonClient() + let restorer = ThreadSessionRestorer(daemonClient: dc) + let delegate = MockThreadRestorerDelegate(daemonClient: dc) + restorer.delegate = delegate + + let defaultThread = ThreadModel() + delegate.threads = [defaultThread] + delegate.viewModels[defaultThread.id] = delegate.makeViewModel() + + let response = makeSessionListResponse(sessions: [ + (id: "s1", title: "Voice Call", updatedAt: 2000, threadType: nil, + channelBinding: ["sourceChannel": "voice", "externalChatId": "call-123"]), + ]) + restorer.handleSessionListResponse(response) + + // Voice-bound session filtered out; empty default removed; new thread created + #expect(delegate.threads.count == 1) + #expect(delegate.threads[0].kind == .standard) + #expect(delegate.threads[0].sessionId == nil) + #expect(delegate.createThreadCallCount == 1) + } + + @Test @MainActor + func mixedDesktopVoiceAndTelegramRestoresOnlyDesktop() { + let dc = DaemonClient() + let restorer = ThreadSessionRestorer(daemonClient: dc) + let delegate = MockThreadRestorerDelegate(daemonClient: dc) + restorer.delegate = delegate + + let defaultThread = ThreadModel() + delegate.threads = [defaultThread] + delegate.viewModels[defaultThread.id] = delegate.makeViewModel() + + let response = makeSessionListResponse(sessions: [ + (id: "s1", title: "Desktop Chat", updatedAt: 4000, threadType: nil, channelBinding: nil), + (id: "s2", title: "Telegram Chat", updatedAt: 3000, threadType: nil, + channelBinding: ["sourceChannel": "telegram", "externalChatId": "789"]), + (id: "s3", title: "Voice Call", updatedAt: 2000, threadType: nil, + channelBinding: ["sourceChannel": "voice", "externalChatId": "call-456"]), + (id: "s4", title: "Another Desktop Chat", updatedAt: 1000, threadType: nil, channelBinding: nil), + ]) + restorer.handleSessionListResponse(response) + + // Only the two desktop sessions should be restored + #expect(delegate.threads.count == 2) + #expect(delegate.threads[0].sessionId == "s1") + #expect(delegate.threads[0].title == "Desktop Chat") + #expect(delegate.threads[1].sessionId == "s4") + #expect(delegate.threads[1].title == "Another Desktop Chat") + #expect(delegate.createThreadCallCount == 0) + } + @Test @MainActor func mixedDesktopAndTelegramRestoresOnlyDesktop() { let dc = DaemonClient()