From 709eda689950db1a7e2471b8a8883fdd6c2f1614 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 28 Feb 2026 19:13:22 -0500 Subject: [PATCH 1/5] feat(voice): add guardian-wait heartbeat, impatience handling, and silence suppression Improve phone-call UX during guardian approval wait states: - Add proactive spoken heartbeat updates while waiting for guardian response (~5s initially, then jittered 7-10s steady state) - Classify caller utterances during wait (patience check, impatient, callback opt-in/decline, neutral) and respond appropriately - Offer callback path when caller sounds impatient - Suppress 'Are you still there?' silence prompt during guardian wait - Use guardian display name/username in wait messages instead of generic 'your guardian' - Add cooldown to prevent TTS spam from repeated caller interjections - Add 4 new config fields for heartbeat cadence tuning Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/call-controller.test.ts | 71 ++- assistant/src/__tests__/relay-server.test.ts | 434 +++++++++++++++++- assistant/src/calls/call-constants.ts | 16 + assistant/src/calls/call-controller.ts | 7 + assistant/src/calls/relay-server.ts | 283 +++++++++++- assistant/src/config/calls-schema.ts | 24 + 6 files changed, 806 insertions(+), 29 deletions(-) diff --git a/assistant/src/__tests__/call-controller.test.ts b/assistant/src/__tests__/call-controller.test.ts index 93d83593c46..1b4aa01aaae 100644 --- a/assistant/src/__tests__/call-controller.test.ts +++ b/assistant/src/__tests__/call-controller.test.ts @@ -56,14 +56,22 @@ mock.module('../config/loader.js', () => ({ // ── Call constants mock ────────────────────────────────────────────── let mockConsultationTimeoutMs = 90_000; - -mock.module('../calls/call-constants.js', () => ({ - getMaxCallDurationMs: () => 12 * 60 * 1000, - getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs, - SILENCE_TIMEOUT_MS: 30_000, - MAX_CALL_DURATION_MS: 3600 * 1000, - USER_CONSULTATION_TIMEOUT_MS: 120 * 1000, -})); +let mockSilenceTimeoutMs = 30_000; + +mock.module('../calls/call-constants.js', () => { + const mod: Record = { + getMaxCallDurationMs: () => 12 * 60 * 1000, + getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs, + MAX_CALL_DURATION_MS: 3600 * 1000, + USER_CONSULTATION_TIMEOUT_MS: 120 * 1000, + }; + Object.defineProperty(mod, 'SILENCE_TIMEOUT_MS', { + get: () => mockSilenceTimeoutMs, + enumerable: true, + configurable: true, + }); + return mod; +}); // ── Voice session bridge mock ──────────────────────────────────────── @@ -154,6 +162,7 @@ interface MockRelay extends RelayConnection { sentTokens: Array<{ token: string; last: boolean }>; endCalled: boolean; endReason: string | undefined; + mockConnectionState: string; } function createMockRelay(): MockRelay { @@ -161,12 +170,15 @@ function createMockRelay(): MockRelay { sentTokens: [] as Array<{ token: string; last: boolean }>, _endCalled: false, _endReason: undefined as string | undefined, + _connectionState: 'connected', }; return { get sentTokens() { return state.sentTokens; }, get endCalled() { return state._endCalled; }, get endReason() { return state._endReason; }, + get mockConnectionState() { return state._connectionState; }, + set mockConnectionState(v: string) { state._connectionState = v; }, sendTextToken(token: string, last: boolean) { state.sentTokens.push({ token, last }); }, @@ -174,6 +186,9 @@ function createMockRelay(): MockRelay { state._endCalled = true; state._endReason = reason; }, + getConnectionState() { + return state._connectionState; + }, } as unknown as MockRelay; } @@ -236,6 +251,7 @@ describe('call-controller', () => { mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hello', ' there'])); // Reset consultation timeout to the default (long) value mockConsultationTimeoutMs = 90_000; + mockSilenceTimeoutMs = 30_000; }); // ── handleCallerUtterance ───────────────────────────────────────── @@ -1697,4 +1713,43 @@ describe('call-controller', () => { controller.destroy(); }); + + // ── Silence suppression during guardian wait ────────────────────── + + test('silence timeout suppressed during guardian wait: does not say "Are you still there?"', async () => { + mockSilenceTimeoutMs = 50; // Short timeout for testing + const { relay, controller } = setupController(); + + // Simulate guardian wait state on the relay + relay.mockConnectionState = 'awaiting_guardian_decision'; + + // Wait for the silence timeout to fire + await new Promise((r) => setTimeout(r, 200)); + + // "Are you still there?" should NOT have been sent + const silenceTokens = relay.sentTokens.filter((t) => + t.token.includes('Are you still there?'), + ); + expect(silenceTokens.length).toBe(0); + + controller.destroy(); + }); + + test('silence timeout fires normally when not in guardian wait', async () => { + mockSilenceTimeoutMs = 50; // Short timeout for testing + const { relay, controller } = setupController(); + + // Default connection state is 'connected' (not guardian wait) + + // Wait for the silence timeout to fire + await new Promise((r) => setTimeout(r, 200)); + + // "Are you still there?" SHOULD have been sent + const silenceTokens = relay.sentTokens.filter((t) => + t.token.includes('Are you still there?'), + ); + expect(silenceTokens.length).toBe(1); + + controller.destroy(); + }); }); diff --git a/assistant/src/__tests__/relay-server.test.ts b/assistant/src/__tests__/relay-server.test.ts index e1fac8dde6e..b346f74628c 100644 --- a/assistant/src/__tests__/relay-server.test.ts +++ b/assistant/src/__tests__/relay-server.test.ts @@ -58,6 +58,10 @@ const mockConfig = { userConsultTimeoutSeconds: 120, ttsPlaybackDelayMs: 0, accessRequestPollIntervalMs: 50, + guardianWaitUpdateInitialIntervalMs: 100, + guardianWaitUpdateInitialWindowMs: 300, + guardianWaitUpdateSteadyMinIntervalMs: 150, + guardianWaitUpdateSteadyMaxIntervalMs: 200, disclosure: { enabled: false, text: '' }, safety: { denyCategories: [] }, callerIdentity: { @@ -1959,7 +1963,7 @@ describe('relay-server', () => { // Should have transitioned to awaiting guardian decision expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); - // Should have sent the hold message + // Should have sent the hold message (guardian label defaults to "my guardian") const textMessages = ws.sentMessages .map((raw) => JSON.parse(raw) as { type: string; token?: string }) .filter((m) => m.type === 'text'); @@ -2010,10 +2014,10 @@ describe('relay-server', () => { relay.destroy(); }); - test('name capture flow: voice prompts ignored during guardian decision wait', async () => { - ensureConversation('conv-wait-prompt-ignore'); + test('name capture flow: voice prompts during guardian wait get reassurance response', async () => { + ensureConversation('conv-wait-prompt-reassure'); const session = createCallSession({ - conversationId: 'conv-wait-prompt-ignore', + conversationId: 'conv-wait-prompt-reassure', provider: 'twilio', fromNumber: '+15558882222', toNumber: '+15551111111', @@ -2024,7 +2028,7 @@ describe('relay-server', () => { await relay.handleMessage(JSON.stringify({ type: 'setup', - callSid: 'CA_wait_prompt_ignore', + callSid: 'CA_wait_prompt_reassure', from: '+15558882222', to: '+15551111111', })); @@ -2040,7 +2044,7 @@ describe('relay-server', () => { expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); const msgCountBefore = ws.sentMessages.length; - // Voice prompts during guardian wait should be ignored + // Voice prompts during guardian wait should get a reassurance reply await relay.handleMessage(JSON.stringify({ type: 'prompt', voicePrompt: 'Are you still there?', @@ -2048,8 +2052,13 @@ describe('relay-server', () => { last: true, })); - // No new messages sent - expect(ws.sentMessages.length).toBe(msgCountBefore); + // A reassurance message should have been sent + const newMessages = ws.sentMessages.slice(msgCountBefore); + const textMessages = newMessages + .map((raw) => JSON.parse(raw) as { type: string; token?: string }) + .filter((m) => m.type === 'text'); + expect(textMessages.length).toBeGreaterThan(0); + expect(textMessages.some((m) => (m.token ?? '').includes('still here'))).toBe(true); expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); relay.destroy(); @@ -2265,7 +2274,7 @@ describe('relay-server', () => { const textMessages = ws.sentMessages .map((raw) => JSON.parse(raw) as { type: string; token?: string }) .filter((m) => m.type === 'text'); - expect(textMessages.some((m) => (m.token ?? '').includes("my guardian says I'm not allowed"))).toBe(true); + expect(textMessages.some((m) => (m.token ?? '').includes("says I'm not allowed"))).toBe(true); // Session should be failed const updated = getCallSession(session.id); @@ -2324,7 +2333,7 @@ describe('relay-server', () => { const textMessages = ws.sentMessages .map((raw) => JSON.parse(raw) as { type: string; token?: string }) .filter((m) => m.type === 'text'); - expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of my guardian"))).toBe(true); + expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of"))).toBe(true); expect(textMessages.some((m) => (m.token ?? '').includes("let them know you called"))).toBe(true); // Session should be failed @@ -2384,4 +2393,409 @@ describe('relay-server', () => { relay.destroy(); }); + + // ── Guardian wait heartbeat and impatience handling ────────────────── + + test('guardian wait: heartbeat timer emits periodic updates', async () => { + ensureConversation('conv-heartbeat-basic'); + const session = createCallSession({ + conversationId: 'conv-heartbeat-basic', + provider: 'twilio', + fromNumber: '+15557770010', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_heartbeat_basic', + from: '+15557770010', + to: '+15551111111', + })); + + // Provide name to enter guardian wait + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Heartbeat Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + const msgCountAfterHold = ws.sentMessages.length; + + // Wait for at least one heartbeat (initial interval is 100ms in test config) + await new Promise((resolve) => setTimeout(resolve, 250)); + + const newMessages = ws.sentMessages.slice(msgCountAfterHold); + const textMessages = newMessages + .map((raw) => JSON.parse(raw) as { type: string; token?: string }) + .filter((m) => m.type === 'text'); + expect(textMessages.length).toBeGreaterThan(0); + // Heartbeat messages mention "waiting" or "guardian" + expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('waiting'))).toBe(true); + + // Verify heartbeat event was recorded + const events = getCallEvents(session.id); + expect(events.some((e) => e.eventType === 'voice_guardian_wait_heartbeat_sent')).toBe(true); + + relay.destroy(); + }); + + test('guardian wait: heartbeat stops on approval', async () => { + ensureConversation('conv-heartbeat-stop-approve'); + const session = createCallSession({ + conversationId: 'conv-heartbeat-stop-approve', + provider: 'twilio', + fromNumber: '+15557770011', + toNumber: '+15551111111', + assistantId: 'self', + }); + + mockSendMessage.mockImplementation(createMockProviderResponse(['Welcome!'])); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_heartbeat_stop_approve', + from: '+15557770011', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Approve Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + + // Approve the request + const pending = listCanonicalGuardianRequests({ + status: 'pending', + requesterExternalUserId: '+15557770011', + sourceChannel: 'voice', + kind: 'access_request', + }); + expect(pending.length).toBe(1); + + resolveCanonicalGuardianRequest(pending[0].id, 'pending', { + status: 'approved', + answerText: undefined, + decidedByExternalUserId: undefined, + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Connection should have transitioned + expect(relay.getConnectionState()).toBe('connected'); + + // Record message count after approval + const msgCountAfterApproval = ws.sentMessages.length; + + // Wait and verify no more heartbeats + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(ws.sentMessages.length).toBe(msgCountAfterApproval); + + relay.destroy(); + }); + + test('guardian wait: heartbeat stops on destroy', async () => { + ensureConversation('conv-heartbeat-stop-destroy'); + const session = createCallSession({ + conversationId: 'conv-heartbeat-stop-destroy', + provider: 'twilio', + fromNumber: '+15557770012', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_heartbeat_stop_destroy', + from: '+15557770012', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Destroy Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + + // Destroy should not throw and should clean up timers + expect(() => relay.destroy()).not.toThrow(); + }); + + test('guardian wait: impatience utterance triggers callback offer', async () => { + ensureConversation('conv-impatience-offer'); + const session = createCallSession({ + conversationId: 'conv-impatience-offer', + provider: 'twilio', + fromNumber: '+15557770013', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_impatience_offer', + from: '+15557770013', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Impatient Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + const msgCountBefore = ws.sentMessages.length; + + // Send an impatient utterance + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'This is taking too long!', + lang: 'en-US', + last: true, + })); + + const newMessages = ws.sentMessages.slice(msgCountBefore); + const textMessages = newMessages + .map((raw) => JSON.parse(raw) as { type: string; token?: string }) + .filter((m) => m.type === 'text'); + expect(textMessages.length).toBeGreaterThan(0); + // Should offer callback + expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('call you back'))).toBe(true); + + // Verify event + const events = getCallEvents(session.id); + expect(events.some((e) => e.eventType === 'voice_guardian_wait_callback_offer_sent')).toBe(true); + expect(events.some((e) => e.eventType === 'voice_guardian_wait_prompt_classified')).toBe(true); + + relay.destroy(); + }); + + test('guardian wait: explicit callback opt-in after offer is acknowledged', async () => { + ensureConversation('conv-callback-optin'); + const session = createCallSession({ + conversationId: 'conv-callback-optin', + provider: 'twilio', + fromNumber: '+15557770014', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_callback_optin', + from: '+15557770014', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'OptIn Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + + // Trigger impatience to get callback offer + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Hurry up please', + lang: 'en-US', + last: true, + })); + + // Wait for cooldown + await new Promise((resolve) => setTimeout(resolve, 3100)); + + const msgCountBeforeOptIn = ws.sentMessages.length; + + // Accept the callback offer + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Yes, please call me back', + lang: 'en-US', + last: true, + })); + + const newMessages = ws.sentMessages.slice(msgCountBeforeOptIn); + const textMessages = newMessages + .map((raw) => JSON.parse(raw) as { type: string; token?: string }) + .filter((m) => m.type === 'text'); + expect(textMessages.length).toBeGreaterThan(0); + // Should acknowledge the callback opt-in + expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('noted'))).toBe(true); + + // Verify events + const events = getCallEvents(session.id); + expect(events.some((e) => e.eventType === 'voice_guardian_wait_callback_opt_in_set')).toBe(true); + + // Connection should still be in guardian wait (callback not auto-dispatched) + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + + relay.destroy(); + }); + + test('guardian wait: neutral utterance gets acknowledgment', async () => { + ensureConversation('conv-wait-neutral'); + const session = createCallSession({ + conversationId: 'conv-wait-neutral', + provider: 'twilio', + fromNumber: '+15557770015', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_wait_neutral', + from: '+15557770015', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Neutral Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + const msgCountBefore = ws.sentMessages.length; + + // Send a neutral utterance + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'I just wanted to say thanks', + lang: 'en-US', + last: true, + })); + + const newMessages = ws.sentMessages.slice(msgCountBefore); + const textMessages = newMessages + .map((raw) => JSON.parse(raw) as { type: string; token?: string }) + .filter((m) => m.type === 'text'); + expect(textMessages.length).toBeGreaterThan(0); + // Should get an acknowledgment + expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('waiting'))).toBe(true); + + relay.destroy(); + }); + + test('guardian wait: empty utterance is ignored without response', async () => { + ensureConversation('conv-wait-empty'); + const session = createCallSession({ + conversationId: 'conv-wait-empty', + provider: 'twilio', + fromNumber: '+15557770016', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_wait_empty', + from: '+15557770016', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Empty Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + const msgCountBefore = ws.sentMessages.length; + + // Send an empty utterance + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: ' ', + lang: 'en-US', + last: true, + })); + + // No new messages should be sent + expect(ws.sentMessages.length).toBe(msgCountBefore); + + relay.destroy(); + }); + + test('guardian wait: cooldown prevents rapid-fire responses', async () => { + ensureConversation('conv-wait-cooldown'); + const session = createCallSession({ + conversationId: 'conv-wait-cooldown', + provider: 'twilio', + fromNumber: '+15557770017', + toNumber: '+15551111111', + assistantId: 'self', + }); + + const { ws, relay } = createMockWs(session.id); + + await relay.handleMessage(JSON.stringify({ + type: 'setup', + callSid: 'CA_wait_cooldown', + from: '+15557770017', + to: '+15551111111', + })); + + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Cooldown Tester', + lang: 'en-US', + last: true, + })); + + expect(relay.getConnectionState()).toBe('awaiting_guardian_decision'); + + // First utterance should get a response + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Hello?', + lang: 'en-US', + last: true, + })); + + const msgCountAfterFirst = ws.sentMessages.length; + + // Immediate second utterance should be suppressed by cooldown + await relay.handleMessage(JSON.stringify({ + type: 'prompt', + voicePrompt: 'Hello again?', + lang: 'en-US', + last: true, + })); + + // No new messages due to cooldown + expect(ws.sentMessages.length).toBe(msgCountAfterFirst); + + relay.destroy(); + }); }); diff --git a/assistant/src/calls/call-constants.ts b/assistant/src/calls/call-constants.ts index 546f8eb381b..4c6b8a3cefd 100644 --- a/assistant/src/calls/call-constants.ts +++ b/assistant/src/calls/call-constants.ts @@ -49,6 +49,22 @@ export function getAccessRequestPollIntervalMs(): number { return getConfig().calls.accessRequestPollIntervalMs; } +export function getGuardianWaitUpdateInitialIntervalMs(): number { + return getConfig().calls.guardianWaitUpdateInitialIntervalMs; +} + +export function getGuardianWaitUpdateInitialWindowMs(): number { + return getConfig().calls.guardianWaitUpdateInitialWindowMs; +} + +export function getGuardianWaitUpdateSteadyMinIntervalMs(): number { + return getConfig().calls.guardianWaitUpdateSteadyMinIntervalMs; +} + +export function getGuardianWaitUpdateSteadyMaxIntervalMs(): number { + return getConfig().calls.guardianWaitUpdateSteadyMaxIntervalMs; +} + export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds // Legacy named exports for backward compatibility (use functions above for config-backed values) diff --git a/assistant/src/calls/call-controller.ts b/assistant/src/calls/call-controller.ts index ddb5847d562..83ab3590fa3 100644 --- a/assistant/src/calls/call-controller.ts +++ b/assistant/src/calls/call-controller.ts @@ -1049,6 +1049,13 @@ export class CallController { private resetSilenceTimer(): void { if (this.silenceTimer) clearTimeout(this.silenceTimer); this.silenceTimer = setTimeout(() => { + // During guardian wait states, the relay heartbeat timer handles + // periodic updates — suppress the generic "Are you still there?" + // which is confusing when the caller is waiting on a decision. + if (this.relay.getConnectionState() === 'awaiting_guardian_decision') { + log.debug({ callSessionId: this.callSessionId }, 'Silence timeout suppressed during guardian wait'); + return; + } log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered'); this.relay.sendTextToken('Are you still there?', true); }, SILENCE_TIMEOUT_MS); diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index 718f198ec2f..3225bde3371 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -23,6 +23,7 @@ import { } from '../runtime/actor-trust-resolver.js'; import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js'; import { + getGuardianBinding, getPendingChallenge, validateAndConsumeChallenge, } from '../runtime/channel-guardian-service.js'; @@ -33,7 +34,15 @@ import { import { redeemVoiceInviteCode } from '../runtime/ingress-service.js'; import { parseJsonSafe } from '../util/json.js'; import { getLogger } from '../util/logger.js'; -import { getAccessRequestPollIntervalMs, getTtsPlaybackDelayMs, getUserConsultationTimeoutMs } from './call-constants.js'; +import { + getAccessRequestPollIntervalMs, + getGuardianWaitUpdateInitialIntervalMs, + getGuardianWaitUpdateInitialWindowMs, + getGuardianWaitUpdateSteadyMaxIntervalMs, + getGuardianWaitUpdateSteadyMinIntervalMs, + getTtsPlaybackDelayMs, + getUserConsultationTimeoutMs, +} from './call-constants.js'; import { CallController } from './call-controller.js'; import { persistCallCompletionMessage } from './call-conversation-messages.js'; import { addPointerMessage, formatDuration } from './call-pointer-messages.js'; @@ -197,6 +206,19 @@ export class RelayConnection { // Name capture timeout (unknown inbound callers) private nameCaptureTimeoutTimer: ReturnType | null = null; + // Guardian wait heartbeat state + private accessRequestHeartbeatTimer: ReturnType | null = null; + private accessRequestWaitStartedAt: number = 0; + private heartbeatSequence = 0; + + // In-wait prompt handling state + private lastInWaitReplyAt = 0; + private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000; + + // Callback offer state (in-memory per-call) + private callbackOfferMade = false; + private callbackOptIn = false; + constructor(ws: ServerWebSocket, callSessionId: string) { this.ws = ws; this.callSessionId = callSessionId; @@ -328,6 +350,10 @@ export class RelayConnection { clearTimeout(this.accessRequestTimeoutTimer); this.accessRequestTimeoutTimer = null; } + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } if (this.nameCaptureTimeoutTimer) { clearTimeout(this.nameCaptureTimeoutTimer); this.nameCaptureTimeoutTimer = null; @@ -1166,13 +1192,19 @@ export class RelayConnection { const timeoutMs = getUserConsultationTimeoutMs(); const pollIntervalMs = getAccessRequestPollIntervalMs(); + const guardianLabel = this.resolveGuardianLabel(); this.sendTextToken( - "Thank you. I've let my guardian know. Please hold while I check if I have permission to speak with you.", + `Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`, true, ); updateCallSession(this.callSessionId, { status: 'waiting_on_user' }); + // Start the heartbeat timer for periodic progress updates + this.accessRequestWaitStartedAt = Date.now(); + this.heartbeatSequence = 0; + this.scheduleNextHeartbeat(); + // Poll the canonical request status this.accessRequestPollTimer = setInterval(() => { if (!this.accessRequestWaitActive || !this.accessRequestId) { @@ -1224,6 +1256,10 @@ export class RelayConnection { clearTimeout(this.accessRequestTimeoutTimer); this.accessRequestTimeoutTimer = null; } + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } } /** @@ -1282,10 +1318,10 @@ export class RelayConnection { // Use handleUserInstruction to deliver the approval-aware greeting // through the normal session pipeline. - const guardianName = 'my guardian'; + const guardianLabel = this.resolveGuardianLabel(); if (this.controller) { this.controller.handleUserInstruction( - `Great, ${guardianName} approved! Now how can I help you?`, + `Great, ${guardianLabel} approved! Now how can I help you?`, ).catch((err) => { log.error({ err, callSessionId: this.callSessionId }, 'Failed to deliver approval greeting'); }); @@ -1298,13 +1334,15 @@ export class RelayConnection { private handleAccessRequestDenied(): void { this.clearAccessRequestWait(); + const guardianLabel = this.resolveGuardianLabel(); + recordCallEvent(this.callSessionId, 'inbound_acl_access_denied', { from: this.accessRequestFromNumber, requestId: this.accessRequestId, }); this.sendTextToken( - "Sorry, my guardian says I'm not allowed to speak with you. Goodbye.", + `Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`, true, ); @@ -1332,13 +1370,19 @@ export class RelayConnection { private handleAccessRequestTimeout(): void { this.clearAccessRequestWait(); + const guardianLabel = this.resolveGuardianLabel(); + recordCallEvent(this.callSessionId, 'inbound_acl_access_timeout', { from: this.accessRequestFromNumber, requestId: this.accessRequestId, + callbackOptIn: this.callbackOptIn, }); + const callbackNote = this.callbackOptIn + ? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.` + : ''; this.sendTextToken( - "Sorry, I can't get ahold of my guardian right now. I'll let them know you called.", + `Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`, true, ); @@ -1482,6 +1526,225 @@ export class RelayConnection { } } + // ── Guardian wait UX layer ───────────────────────────────────── + + /** + * Resolve a human-readable guardian label for voice wait copy. + * Prefers displayName from the guardian binding metadata, falls back + * to @username, then "my guardian". + */ + private resolveGuardianLabel(): string { + const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID; + const binding = getGuardianBinding(assistantId, 'voice'); + if (binding?.metadataJson) { + try { + const parsed = JSON.parse(binding.metadataJson) as Record; + if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) { + return parsed.displayName.trim(); + } + if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) { + return `@${parsed.username.trim()}`; + } + } catch { + // ignore malformed metadata + } + } + return 'my guardian'; + } + + /** + * Generate a non-repetitive heartbeat message for the caller based + * on the current sequence counter and guardian label. + */ + private getHeartbeatMessage(): string { + const guardianLabel = this.resolveGuardianLabel(); + const seq = this.heartbeatSequence++; + const messages = [ + `Still waiting to hear back from ${guardianLabel}. Thank you for your patience.`, + `I'm still trying to reach ${guardianLabel}. One moment please.`, + `Hang tight, still waiting on ${guardianLabel}.`, + `Still checking with ${guardianLabel}. I appreciate you waiting.`, + `I haven't heard back from ${guardianLabel} yet. Thanks for holding.`, + ]; + return messages[seq % messages.length]; + } + + /** + * Schedule the next heartbeat update. Uses the initial fixed interval + * during the initial window, then jitters between steady min/max. + */ + private scheduleNextHeartbeat(): void { + if (!this.accessRequestWaitActive) return; + + const elapsed = Date.now() - this.accessRequestWaitStartedAt; + const initialWindow = getGuardianWaitUpdateInitialWindowMs(); + const intervalMs = elapsed < initialWindow + ? getGuardianWaitUpdateInitialIntervalMs() + : getGuardianWaitUpdateSteadyMinIntervalMs() + + Math.floor(Math.random() * (getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs())); + + this.accessRequestHeartbeatTimer = setTimeout(() => { + if (!this.accessRequestWaitActive) return; + + const message = this.getHeartbeatMessage(); + this.sendTextToken(message, true); + + recordCallEvent(this.callSessionId, 'voice_guardian_wait_heartbeat_sent', { + sequence: this.heartbeatSequence - 1, + message, + }); + + log.debug( + { callSessionId: this.callSessionId, sequence: this.heartbeatSequence - 1 }, + 'Guardian wait heartbeat sent', + ); + + // Schedule the next heartbeat + this.scheduleNextHeartbeat(); + }, intervalMs); + } + + /** + * Classify a caller utterance during guardian wait into one of: + * - 'empty': whitespace or noise + * - 'patience_check': asking for status or checking in + * - 'impatient': expressing frustration or wanting to end + * - 'callback_opt_in': explicitly agreeing to a callback + * - 'callback_decline': explicitly declining a callback + * - 'neutral': anything else + */ + private classifyWaitUtterance(text: string): 'empty' | 'patience_check' | 'impatient' | 'callback_opt_in' | 'callback_decline' | 'neutral' { + const lower = text.toLowerCase().trim(); + if (lower.length === 0) return 'empty'; + + // Callback opt-in patterns (check before impatience to catch "yes call me back") + if (this.callbackOfferMade) { + if (/\b(yes|yeah|yep|sure|okay|ok|please)\b.*\b(call\s*(me\s*)?back|callback)\b/.test(lower) + || /\b(call\s*(me\s*)?back|callback)\b.*\b(yes|yeah|please|sure)\b/.test(lower) + || /^(yes|yeah|yep|sure|okay|ok|please)\s*[.,!]?\s*$/.test(lower) + || /\bcall\s*(me\s*)?back\b/.test(lower) + || /\bplease\s+do\b/.test(lower)) { + return 'callback_opt_in'; + } + if (/\b(no|nah|nope)\b/.test(lower) + || /\bi('?ll| will)\s+hold\b/.test(lower) + || /\bi('?ll| will)\s+wait\b/.test(lower)) { + return 'callback_decline'; + } + } + + // Impatience patterns + if (/\bhurry\s*(up)?\b/.test(lower) + || /\btaking\s+(too\s+|so\s+)?long\b/.test(lower) + || /\bforget\s+it\b/.test(lower) + || /\bnever\s*mind\b/.test(lower) + || /\bdon'?t\s+have\s+time\b/.test(lower) + || /\bhow\s+much\s+longer\b/.test(lower) + || /\bi('?m| am)\s+(getting\s+)?impatient\b/.test(lower) + || /\bthis\s+is\s+(ridiculous|absurd|crazy)\b/.test(lower) + || /\bcome\s+on\b/.test(lower) + || /\bi\s+(gotta|have\s+to|need\s+to)\s+go\b/.test(lower)) { + return 'impatient'; + } + + // Patience check / status inquiry patterns + if (/\bhello\??\s*$/.test(lower) + || /\bstill\s+there\b/.test(lower) + || /\bany\s+(update|news)\b/.test(lower) + || /\bwhat('?s| is)\s+(happening|going\s+on)\b/.test(lower) + || /\bare\s+you\s+still\b/.test(lower) + || /\bhow\s+(long|much\s+longer)\b/.test(lower) + || /\banyone\s+there\b/.test(lower)) { + return 'patience_check'; + } + + return 'neutral'; + } + + /** + * Handle a caller utterance during the guardian decision wait state. + * Provides reassurance, impatience detection, and callback offer. + */ + private handleWaitStatePrompt(text: string): void { + const now = Date.now(); + const classification = this.classifyWaitUtterance(text); + + recordCallEvent(this.callSessionId, 'voice_guardian_wait_prompt_classified', { + classification, + transcript: text, + }); + + if (classification === 'empty') return; + + // Enforce cooldown to prevent spam + if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) { + log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown'); + return; + } + this.lastInWaitReplyAt = now; + + const guardianLabel = this.resolveGuardianLabel(); + + switch (classification) { + case 'callback_opt_in': { + this.callbackOptIn = true; + recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {}); + this.sendTextToken( + `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`, + true, + ); + break; + } + case 'callback_decline': { + recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {}); + this.sendTextToken( + `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`, + true, + ); + break; + } + case 'impatient': { + if (!this.callbackOfferMade) { + this.callbackOfferMade = true; + recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_offer_sent', {}); + this.sendTextToken( + `I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`, + true, + ); + } else { + // Already offered callback — just reassure + this.sendTextToken( + `I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`, + true, + ); + } + break; + } + case 'patience_check': { + // Immediate reassurance — reset the heartbeat timer so we + // don't double up with a scheduled heartbeat + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } + this.sendTextToken( + `Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`, + true, + ); + this.scheduleNextHeartbeat(); + break; + } + case 'neutral': + default: { + this.sendTextToken( + `Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`, + true, + ); + break; + } + } + } + private async handlePrompt(msg: RelayPromptMessage): Promise { if (this.connectionState === 'disconnecting') { return; @@ -1509,12 +1772,10 @@ export class RelayConnection { return; } - // During guardian decision wait, ignore caller speech — they are on hold. + // During guardian decision wait, classify caller speech for + // reassurance, impatience detection, and callback offer. if (this.connectionState === 'awaiting_guardian_decision') { - log.debug( - { callSessionId: this.callSessionId }, - 'Ignoring voice prompt during guardian decision wait', - ); + this.handleWaitStatePrompt(msg.voicePrompt); return; } diff --git a/assistant/src/config/calls-schema.ts b/assistant/src/config/calls-schema.ts index 978737e6888..1dc43fca6ae 100644 --- a/assistant/src/config/calls-schema.ts +++ b/assistant/src/config/calls-schema.ts @@ -137,6 +137,30 @@ export const CallsConfigSchema = z.object({ .min(50, 'calls.accessRequestPollIntervalMs must be >= 50') .max(10_000, 'calls.accessRequestPollIntervalMs must be at most 10000') .default(500), + guardianWaitUpdateInitialIntervalMs: z + .number({ error: 'calls.guardianWaitUpdateInitialIntervalMs must be a number' }) + .int('calls.guardianWaitUpdateInitialIntervalMs must be an integer') + .min(1000, 'calls.guardianWaitUpdateInitialIntervalMs must be >= 1000') + .max(60_000, 'calls.guardianWaitUpdateInitialIntervalMs must be at most 60000') + .default(5000), + guardianWaitUpdateInitialWindowMs: z + .number({ error: 'calls.guardianWaitUpdateInitialWindowMs must be a number' }) + .int('calls.guardianWaitUpdateInitialWindowMs must be an integer') + .min(1000, 'calls.guardianWaitUpdateInitialWindowMs must be >= 1000') + .max(60_000, 'calls.guardianWaitUpdateInitialWindowMs must be at most 60000') + .default(30_000), + guardianWaitUpdateSteadyMinIntervalMs: z + .number({ error: 'calls.guardianWaitUpdateSteadyMinIntervalMs must be a number' }) + .int('calls.guardianWaitUpdateSteadyMinIntervalMs must be an integer') + .min(1000, 'calls.guardianWaitUpdateSteadyMinIntervalMs must be >= 1000') + .max(60_000, 'calls.guardianWaitUpdateSteadyMinIntervalMs must be at most 60000') + .default(7000), + guardianWaitUpdateSteadyMaxIntervalMs: z + .number({ error: 'calls.guardianWaitUpdateSteadyMaxIntervalMs must be a number' }) + .int('calls.guardianWaitUpdateSteadyMaxIntervalMs must be an integer') + .min(1000, 'calls.guardianWaitUpdateSteadyMaxIntervalMs must be >= 1000') + .max(60_000, 'calls.guardianWaitUpdateSteadyMaxIntervalMs must be at most 60000') + .default(10_000), disclosure: CallsDisclosureConfigSchema.default(CallsDisclosureConfigSchema.parse({})), safety: CallsSafetyConfigSchema.default(CallsSafetyConfigSchema.parse({})), voice: CallsVoiceConfigSchema.default(CallsVoiceConfigSchema.parse({})), From aaeb5f1bffe67c11782f49d3418d6ad79a326b67 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 28 Feb 2026 19:21:08 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20cooldown=20bypass=20for=20callbacks,=20decline=20re?= =?UTF-8?q?set,=20cross-channel=20guardian=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exempt callback_opt_in and callback_decline from cooldown guard so quick callback decisions are never dropped - Reset callbackOptIn to false on decline so timeout handler respects the caller's latest decision - Fall back to listActiveBindingsByAssistant when no voice-channel guardian binding exists, matching the pattern in access-request-helper Co-Authored-By: Claude Opus 4.6 --- assistant/src/calls/relay-server.ts | 50 ++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index 3225bde3371..cff5296b097 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -12,6 +12,7 @@ import type { ServerWebSocket } from 'bun'; import { getConfig } from '../config/loader.js'; import { getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js'; +import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js'; import * as conversationStore from '../memory/conversation-store.js'; import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js'; import { upsertMember } from '../memory/ingress-member-store.js'; @@ -1535,10 +1536,24 @@ export class RelayConnection { */ private resolveGuardianLabel(): string { const assistantId = this.accessRequestAssistantId ?? DAEMON_INTERNAL_ASSISTANT_ID; - const binding = getGuardianBinding(assistantId, 'voice'); - if (binding?.metadataJson) { + + // Try the voice-channel binding first, then fall back to any active + // binding for the assistant (mirrors the cross-channel fallback pattern + // in access-request-helper.ts). + let metadataJson: string | null = null; + const voiceBinding = getGuardianBinding(assistantId, 'voice'); + if (voiceBinding?.metadataJson) { + metadataJson = voiceBinding.metadataJson; + } else { + const allBindings = listActiveBindingsByAssistant(assistantId); + if (allBindings.length > 0 && allBindings[0].metadataJson) { + metadataJson = allBindings[0].metadataJson; + } + } + + if (metadataJson) { try { - const parsed = JSON.parse(binding.metadataJson) as Record; + const parsed = JSON.parse(metadataJson) as Record; if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) { return parsed.displayName.trim(); } @@ -1676,33 +1691,44 @@ export class RelayConnection { if (classification === 'empty') return; - // Enforce cooldown to prevent spam - if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) { - log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown'); - return; - } - this.lastInWaitReplyAt = now; - const guardianLabel = this.resolveGuardianLabel(); + // Callback decisions must always be processed regardless of cooldown — + // the caller is answering a direct question and dropping their response + // would silently discard their decision. switch (classification) { case 'callback_opt_in': { this.callbackOptIn = true; + this.lastInWaitReplyAt = now; recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {}); this.sendTextToken( `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`, true, ); - break; + return; } case 'callback_decline': { + this.callbackOptIn = false; + this.lastInWaitReplyAt = now; recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {}); this.sendTextToken( `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`, true, ); - break; + return; } + default: + break; + } + + // Enforce cooldown on non-callback utterances to prevent spam + if (now - this.lastInWaitReplyAt < RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS) { + log.debug({ callSessionId: this.callSessionId }, 'In-wait reply suppressed by cooldown'); + return; + } + this.lastInWaitReplyAt = now; + + switch (classification) { case 'impatient': { if (!this.callbackOfferMade) { this.callbackOfferMade = true; From 590f3d08d9f9e12e3bb8db7ff9cab6a793e982f4 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 28 Feb 2026 19:23:49 -0500 Subject: [PATCH 3/5] fix: add missing CallEventType literals, config test defaults, and clamp jitter math - Add 5 new voice_guardian_wait_* event types to CallEventType union - Add 4 new guardianWaitUpdate* config defaults to config-schema test - Clamp heartbeat jitter to Math.max(0, steadyMax - steadyMin) to handle misconfigured steadyMax < steadyMin gracefully Co-Authored-By: Claude Opus 4.6 --- assistant/src/__tests__/config-schema.test.ts | 4 ++++ assistant/src/calls/relay-server.ts | 2 +- assistant/src/calls/types.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/assistant/src/__tests__/config-schema.test.ts b/assistant/src/__tests__/config-schema.test.ts index 45065924196..228ab2a24ef 100644 --- a/assistant/src/__tests__/config-schema.test.ts +++ b/assistant/src/__tests__/config-schema.test.ts @@ -583,6 +583,10 @@ describe('AssistantConfigSchema', () => { userConsultTimeoutSeconds: 120, ttsPlaybackDelayMs: 3000, accessRequestPollIntervalMs: 500, + guardianWaitUpdateInitialIntervalMs: 5000, + guardianWaitUpdateInitialWindowMs: 30000, + guardianWaitUpdateSteadyMinIntervalMs: 7000, + guardianWaitUpdateSteadyMaxIntervalMs: 10000, disclosure: { enabled: true, text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".', diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index cff5296b097..1450023ad8d 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -1596,7 +1596,7 @@ export class RelayConnection { const intervalMs = elapsed < initialWindow ? getGuardianWaitUpdateInitialIntervalMs() : getGuardianWaitUpdateSteadyMinIntervalMs() + - Math.floor(Math.random() * (getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs())); + Math.floor(Math.random() * Math.max(0, getGuardianWaitUpdateSteadyMaxIntervalMs() - getGuardianWaitUpdateSteadyMinIntervalMs())); this.accessRequestHeartbeatTimer = setTimeout(() => { if (!this.accessRequestWaitActive) return; diff --git a/assistant/src/calls/types.ts b/assistant/src/calls/types.ts index 78c7094b24d..33eab550620 100644 --- a/assistant/src/calls/types.ts +++ b/assistant/src/calls/types.ts @@ -1,5 +1,5 @@ export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled'; -export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'inbound_acl_name_capture_started' | 'inbound_acl_name_captured' | 'inbound_acl_name_capture_timeout' | 'inbound_acl_access_approved' | 'inbound_acl_access_denied' | 'inbound_acl_access_timeout' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed'; +export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'inbound_acl_name_capture_started' | 'inbound_acl_name_captured' | 'inbound_acl_name_capture_timeout' | 'inbound_acl_access_approved' | 'inbound_acl_access_denied' | 'inbound_acl_access_timeout' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed' | 'voice_guardian_wait_heartbeat_sent' | 'voice_guardian_wait_prompt_classified' | 'voice_guardian_wait_callback_offer_sent' | 'voice_guardian_wait_callback_opt_in_set' | 'voice_guardian_wait_callback_opt_in_declined'; export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled'; /** From 6886e1fd6456906d37cc0023b840e946865da6c9 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 28 Feb 2026 19:31:00 -0500 Subject: [PATCH 4/5] fix: reset heartbeat timer after all wait-state responses to prevent double TTS Clear and reschedule the heartbeat timer in impatient, neutral, callback_opt_in, and callback_decline branches of handleWaitStatePrompt, matching the existing pattern in patience_check. Prevents a queued heartbeat from firing immediately after a wait-state response. Co-Authored-By: Claude Opus 4.6 --- assistant/src/calls/relay-server.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index 1450023ad8d..cbc90e47429 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -1701,20 +1701,30 @@ export class RelayConnection { this.callbackOptIn = true; this.lastInWaitReplyAt = now; recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_set', {}); + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } this.sendTextToken( `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`, true, ); + this.scheduleNextHeartbeat(); return; } case 'callback_decline': { this.callbackOptIn = false; this.lastInWaitReplyAt = now; recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_opt_in_declined', {}); + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } this.sendTextToken( `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`, true, ); + this.scheduleNextHeartbeat(); return; } default: @@ -1730,6 +1740,10 @@ export class RelayConnection { switch (classification) { case 'impatient': { + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } if (!this.callbackOfferMade) { this.callbackOfferMade = true; recordCallEvent(this.callSessionId, 'voice_guardian_wait_callback_offer_sent', {}); @@ -1744,6 +1758,7 @@ export class RelayConnection { true, ); } + this.scheduleNextHeartbeat(); break; } case 'patience_check': { @@ -1762,10 +1777,15 @@ export class RelayConnection { } case 'neutral': default: { + if (this.accessRequestHeartbeatTimer) { + clearTimeout(this.accessRequestHeartbeatTimer); + this.accessRequestHeartbeatTimer = null; + } this.sendTextToken( `Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`, true, ); + this.scheduleNextHeartbeat(); break; } } From 96774831f9b45fcb9dc820a5cbd1eb187b42e3fc Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sat, 28 Feb 2026 19:48:10 -0500 Subject: [PATCH 5/5] fix: convert SILENCE_TIMEOUT_MS to getter function for test mockability Replace the constant import SILENCE_TIMEOUT_MS with a getSilenceTimeoutMs() getter function in call-controller.ts so the test mock can effectively override the timeout value at runtime. The constant was bound at import time, making the Object.defineProperty getter in the test ineffective. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/call-controller.test.ts | 22 +++++++------------ assistant/src/calls/call-constants.ts | 5 +++++ assistant/src/calls/call-controller.ts | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/assistant/src/__tests__/call-controller.test.ts b/assistant/src/__tests__/call-controller.test.ts index 1b4aa01aaae..5f8d1f84aad 100644 --- a/assistant/src/__tests__/call-controller.test.ts +++ b/assistant/src/__tests__/call-controller.test.ts @@ -58,20 +58,14 @@ mock.module('../config/loader.js', () => ({ let mockConsultationTimeoutMs = 90_000; let mockSilenceTimeoutMs = 30_000; -mock.module('../calls/call-constants.js', () => { - const mod: Record = { - getMaxCallDurationMs: () => 12 * 60 * 1000, - getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs, - MAX_CALL_DURATION_MS: 3600 * 1000, - USER_CONSULTATION_TIMEOUT_MS: 120 * 1000, - }; - Object.defineProperty(mod, 'SILENCE_TIMEOUT_MS', { - get: () => mockSilenceTimeoutMs, - enumerable: true, - configurable: true, - }); - return mod; -}); +mock.module('../calls/call-constants.js', () => ({ + getMaxCallDurationMs: () => 12 * 60 * 1000, + getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs, + getSilenceTimeoutMs: () => mockSilenceTimeoutMs, + SILENCE_TIMEOUT_MS: 30_000, + MAX_CALL_DURATION_MS: 3600 * 1000, + USER_CONSULTATION_TIMEOUT_MS: 120 * 1000, +})); // ── Voice session bridge mock ──────────────────────────────────────── diff --git a/assistant/src/calls/call-constants.ts b/assistant/src/calls/call-constants.ts index 4c6b8a3cefd..4e075bd26ba 100644 --- a/assistant/src/calls/call-constants.ts +++ b/assistant/src/calls/call-constants.ts @@ -65,6 +65,11 @@ export function getGuardianWaitUpdateSteadyMaxIntervalMs(): number { return getConfig().calls.guardianWaitUpdateSteadyMaxIntervalMs; } +export function getSilenceTimeoutMs(): number { + return 30 * 1000; // 30 seconds +} + +/** @deprecated Use getSilenceTimeoutMs() for mockability in tests. */ export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds // Legacy named exports for backward compatibility (use functions above for config-backed values) diff --git a/assistant/src/calls/call-controller.ts b/assistant/src/calls/call-controller.ts index 83ab3590fa3..c7b487a3d58 100644 --- a/assistant/src/calls/call-controller.ts +++ b/assistant/src/calls/call-controller.ts @@ -22,7 +22,7 @@ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js'; import { computeToolApprovalDigest } from '../security/tool-approval-digest.js'; import { getLogger } from '../util/logger.js'; import { readHttpToken } from '../util/platform.js'; -import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js'; +import { getMaxCallDurationMs, getSilenceTimeoutMs, getUserConsultationTimeoutMs } from './call-constants.js'; import { persistCallCompletionMessage } from './call-conversation-messages.js'; import { addPointerMessage, formatDuration } from './call-pointer-messages.js'; import { fireCallCompletionNotifier, fireCallQuestionNotifier, fireCallTranscriptNotifier,registerCallController, unregisterCallController } from './call-state.js'; @@ -1058,6 +1058,6 @@ export class CallController { } log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered'); this.relay.sendTextToken('Are you still there?', true); - }, SILENCE_TIMEOUT_MS); + }, getSilenceTimeoutMs()); } }