diff --git a/extensions/voice-call/src/manager.restore.test.ts b/extensions/voice-call/src/manager.restore.test.ts index 55ac14ab33b9..a7c7a064fab8 100644 --- a/extensions/voice-call/src/manager.restore.test.ts +++ b/extensions/voice-call/src/manager.restore.test.ts @@ -305,6 +305,42 @@ describe("CallManager verification on restore", () => { expect(hangupCall.reason).toBe("timeout"); }); + it.each(["speaking", "listening"] as const)( + "uses call start as max-duration anchor for restored live %s calls without answeredAt", + async (state) => { + vi.useFakeTimers(); + const now = new Date("2026-03-17T03:07:00Z").getTime(); + vi.setSystemTime(now); + const startedAt = now - 290_000; + const { manager, provider, storePath } = await initializeManager({ + callOverrides: { + callId: `call-${state}`, + providerCallId: `provider-${state}`, + state, + startedAt, + answeredAt: undefined, + }, + configOverrides: { maxDurationSeconds: 300 }, + }); + + const activeCall = requireSingleActiveCall(manager); + expect(activeCall.state).toBe(state); + expect(activeCall.answeredAt).toBe(startedAt); + expect( + loadActiveCallsFromStore(storePath).activeCalls.get(activeCall.callId)?.answeredAt, + ).toBe(startedAt); + + await vi.advanceTimersByTimeAsync(9_000); + expect(manager.getActiveCalls()).toHaveLength(1); + expect(provider.hangupCalls).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1_100); + expect(manager.getActiveCalls()).toHaveLength(0); + const hangupCall = requireSingleHangupCall(provider); + expect(hangupCall.reason).toBe("timeout"); + }, + ); + it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => { const storePath = createTestStorePath(); const persisted = makePersistedCall({ diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index 7e296e1bf4af..3e04ed38e925 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -47,6 +47,13 @@ function incrementRestoreStatusCount( counts.set(key, (counts.get(key) ?? 0) + 1); } +function resolveRestoredMaxDurationAnchor(call: CallRecord): number | undefined { + return ( + call.answeredAt ?? + (call.state === "speaking" || call.state === "listening" ? call.startedAt : undefined) + ); +} + function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string { const rawOverride = storePath?.trim() || config.store?.trim(); if (rawOverride) { @@ -126,11 +133,12 @@ export class CallManager { } } - // Restart max-duration timers for restored calls that are past the answered state + // Restart max-duration timers for restored calls that are past the answered/live state. let skippedAlreadyElapsedTimers = 0; for (const [callId, call] of verified) { - if (call.answeredAt && !TerminalStates.has(call.state)) { - const elapsed = Date.now() - call.answeredAt; + const maxDurationAnchor = resolveRestoredMaxDurationAnchor(call); + if (maxDurationAnchor !== undefined && !TerminalStates.has(call.state)) { + const elapsed = Date.now() - maxDurationAnchor; const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds); if (elapsed >= maxDurationMs) { // Already expired — remove instead of keeping @@ -141,6 +149,12 @@ export class CallManager { skippedAlreadyElapsedTimers += 1; continue; } + if (call.answeredAt === undefined) { + // Twilio streams can restore directly in speaking/listening without an + // answered webhook; anchoring at startedAt preserves bounded duration. + call.answeredAt = maxDurationAnchor; + persistCallRecord(this.storePath, call); + } startMaxDurationTimer({ ctx: this.getContext(), callId,