Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion assistant/src/__tests__/call-pointer-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
116 changes: 116 additions & 0 deletions assistant/src/__tests__/guardian-verification-voice-binding.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, {
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');
});
});
14 changes: 10 additions & 4 deletions assistant/src/calls/call-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions assistant/src/calls/call-pointer-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' },
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down