diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index 880d344bcd9..8c9a32269ea 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -116,7 +116,7 @@ export class CallManager { ctx: this.getContext(), callId, onTimeout: async (id) => { - await endCallWithContext(this.getContext(), id); + await endCallWithContext(this.getContext(), id, { reason: "timeout" }); }, }); console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`); diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 668369e0c35..a084a5c40f5 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -2,16 +2,12 @@ import crypto from "node:crypto"; import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js"; import type { CallRecord, CallState, NormalizedEvent } from "../types.js"; import type { CallManagerContext } from "./context.js"; +import { finalizeCall } from "./lifecycle.js"; import { findCall } from "./lookup.js"; import { endCall } from "./outbound.js"; import { addTranscriptEntry, transitionState } from "./state.js"; import { persistCallRecord } from "./store.js"; -import { - clearMaxDurationTimer, - rejectTranscriptWaiter, - resolveTranscriptWaiter, - startMaxDurationTimer, -} from "./timers.js"; +import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js"; type EventContext = Pick< CallManagerContext, @@ -193,7 +189,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { ctx, callId: call.callId, onTimeout: async (callId) => { - await endCall(ctx, callId); + await endCall(ctx, callId, { reason: "timeout" }); }, }); ctx.onCallAnswered?.(call); @@ -228,28 +224,24 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { break; case "call.ended": - call.endedAt = event.timestamp; - call.endReason = event.reason; - transitionState(call, event.reason as CallState); - clearMaxDurationTimer(ctx, call.callId); - rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`); - ctx.activeCalls.delete(call.callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } - break; + finalizeCall({ + ctx, + call, + endReason: event.reason, + endedAt: event.timestamp, + }); + return; case "call.error": if (!event.retryable) { - call.endedAt = event.timestamp; - call.endReason = "error"; - transitionState(call, "error"); - clearMaxDurationTimer(ctx, call.callId); - rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`); - ctx.activeCalls.delete(call.callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } + finalizeCall({ + ctx, + call, + endReason: "error", + endedAt: event.timestamp, + transcriptRejectReason: `Call error: ${event.error}`, + }); + return; } break; } diff --git a/extensions/voice-call/src/manager/lifecycle.ts b/extensions/voice-call/src/manager/lifecycle.ts new file mode 100644 index 00000000000..93bd6242539 --- /dev/null +++ b/extensions/voice-call/src/manager/lifecycle.ts @@ -0,0 +1,53 @@ +import type { CallRecord, EndReason } from "../types.js"; +import type { CallManagerContext } from "./context.js"; +import { transitionState } from "./state.js"; +import { persistCallRecord } from "./store.js"; +import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js"; + +type CallLifecycleContext = Pick< + CallManagerContext, + "activeCalls" | "providerCallIdMap" | "storePath" +> & + Partial>; + +function removeProviderCallMapping( + providerCallIdMap: Map, + call: Pick, +): void { + if (!call.providerCallId) { + return; + } + const mappedCallId = providerCallIdMap.get(call.providerCallId); + if (mappedCallId === call.callId) { + providerCallIdMap.delete(call.providerCallId); + } +} + +export function finalizeCall(params: { + ctx: CallLifecycleContext; + call: CallRecord; + endReason: EndReason; + endedAt?: number; + transcriptRejectReason?: string; +}): void { + const { ctx, call, endReason } = params; + + call.endedAt = params.endedAt ?? Date.now(); + call.endReason = endReason; + transitionState(call, endReason); + persistCallRecord(ctx.storePath, call); + + if (ctx.maxDurationTimers) { + clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId); + } + if (ctx.transcriptWaiters) { + rejectTranscriptWaiter( + { transcriptWaiters: ctx.transcriptWaiters }, + call.callId, + params.transcriptRejectReason ?? `Call ended: ${endReason}`, + ); + } + + ctx.activeCalls.delete(call.callId); + removeProviderCallMapping(ctx.providerCallIdMap, call); +} diff --git a/extensions/voice-call/src/manager/outbound.test.ts b/extensions/voice-call/src/manager/outbound.test.ts index 9ba9e4fac0b..d726b8e4630 100644 --- a/extensions/voice-call/src/manager/outbound.test.ts +++ b/extensions/voice-call/src/manager/outbound.test.ts @@ -231,10 +231,11 @@ describe("voice-call outbound helpers", () => { }); expect(call).toEqual( expect.objectContaining({ - state: "hangup-bot", endReason: "hangup-bot", + endedAt: expect.any(Number), }), ); + expect(transitionStateMock).toHaveBeenCalledWith(call, "hangup-bot"); expect(clearMaxDurationTimerMock).toHaveBeenCalledWith(ctx, "call-1"); expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith( ctx, @@ -245,6 +246,36 @@ describe("voice-call outbound helpers", () => { expect(ctx.providerCallIdMap.size).toBe(0); }); + it("preserves timeout reasons when ending timed out calls", async () => { + const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; + const hangupCall = vi.fn(async () => {}); + const ctx = { + activeCalls: new Map([["call-1", call]]), + providerCallIdMap: new Map([["provider-1", "call-1"]]), + provider: { hangupCall }, + storePath: "/tmp/voice-call.json", + transcriptWaiters: new Map(), + maxDurationTimers: new Map(), + }; + + await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({ + success: true, + }); + expect(hangupCall).toHaveBeenCalledWith({ + callId: "call-1", + providerCallId: "provider-1", + reason: "timeout", + }); + expect(call).toEqual( + expect.objectContaining({ + endReason: "timeout", + endedAt: expect.any(Number), + }), + ); + expect(transitionStateMock).toHaveBeenCalledWith(call, "timeout"); + expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(ctx, "call-1", "Call ended: timeout"); + }); + it("handles missing, disconnected, and already-ended calls", async () => { await expect( speak( diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index de8277be08b..c1f82b0e569 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { CallMode } from "../config.js"; import { + type EndReason, TerminalStates, type CallId, type CallRecord, @@ -8,15 +9,11 @@ import { } from "../types.js"; import { mapVoiceToPolly } from "../voice-mapping.js"; import type { CallManagerContext } from "./context.js"; +import { finalizeCall } from "./lifecycle.js"; import { getCallByProviderCallId } from "./lookup.js"; import { addTranscriptEntry, transitionState } from "./state.js"; import { persistCallRecord } from "./store.js"; -import { - clearMaxDurationTimer, - clearTranscriptWaiter, - rejectTranscriptWaiter, - waitForFinalTranscript, -} from "./timers.js"; +import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js"; import { generateNotifyTwiml } from "./twiml.js"; type InitiateContext = Pick< @@ -186,14 +183,11 @@ export async function initiateCall( return { callId, success: true }; } catch (err) { - callRecord.state = "failed"; - callRecord.endedAt = Date.now(); - callRecord.endReason = "failed"; - persistCallRecord(ctx.storePath, callRecord); - ctx.activeCalls.delete(callId); - if (callRecord.providerCallId) { - ctx.providerCallIdMap.delete(callRecord.providerCallId); - } + finalizeCall({ + ctx, + call: callRecord, + endReason: "failed", + }); return { callId, @@ -369,6 +363,7 @@ export async function continueCall( export async function endCall( ctx: EndCallContext, callId: CallId, + options?: { reason?: EndReason }, ): Promise<{ success: boolean; error?: string }> { const lookup = lookupConnectedCall(ctx, callId); if (lookup.kind === "error") { @@ -378,24 +373,20 @@ export async function endCall( return { success: true }; } const { call, providerCallId, provider } = lookup; + const reason = options?.reason ?? "hangup-bot"; try { await provider.hangupCall({ callId, providerCallId, - reason: "hangup-bot", + reason, }); - call.state = "hangup-bot"; - call.endedAt = Date.now(); - call.endReason = "hangup-bot"; - persistCallRecord(ctx.storePath, call); - - clearMaxDurationTimer(ctx, callId); - rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); - - ctx.activeCalls.delete(callId); - ctx.providerCallIdMap.delete(providerCallId); + finalizeCall({ + ctx, + call, + endReason: reason, + }); return { success: true }; } catch (err) { diff --git a/extensions/voice-call/src/manager/timers.test.ts b/extensions/voice-call/src/manager/timers.test.ts index 5f6392478e8..0436653ed6b 100644 --- a/extensions/voice-call/src/manager/timers.test.ts +++ b/extensions/voice-call/src/manager/timers.test.ts @@ -8,14 +8,6 @@ import { waitForFinalTranscript, } from "./timers.js"; -const { persistCallRecordMock } = vi.hoisted(() => ({ - persistCallRecordMock: vi.fn(), -})); - -vi.mock("./store.js", () => ({ - persistCallRecord: persistCallRecordMock, -})); - describe("voice-call manager timers", () => { beforeEach(() => { vi.useFakeTimers(); @@ -26,13 +18,12 @@ describe("voice-call manager timers", () => { vi.useRealTimers(); }); - it("starts and clears max duration timers, persisting timed out active calls", async () => { + it("starts and clears max duration timers, delegating timeout handling", async () => { const call = { id: "call-1", state: "active" }; const ctx = { activeCalls: new Map([["call-1", call]]), maxDurationTimers: new Map(), config: { maxDurationSeconds: 5 }, - storePath: "/tmp/voice-call.json", }; const onTimeout = vi.fn(async () => {}); @@ -46,8 +37,7 @@ describe("voice-call manager timers", () => { await vi.advanceTimersByTimeAsync(5_000); - expect(call).toEqual({ id: "call-1", state: "active", endReason: "timeout" }); - expect(persistCallRecordMock).toHaveBeenCalledWith("/tmp/voice-call.json", call); + expect(call).toEqual({ id: "call-1", state: "active" }); expect(onTimeout).toHaveBeenCalledWith("call-1"); expect(ctx.maxDurationTimers.has("call-1")).toBe(false); @@ -65,7 +55,6 @@ describe("voice-call manager timers", () => { activeCalls: new Map([["call-1", { id: "call-1", state: "completed" }]]), maxDurationTimers: new Map(), config: { maxDurationSeconds: 5 }, - storePath: "/tmp/voice-call.json", }; const onTimeout = vi.fn(async () => {}); @@ -77,7 +66,6 @@ describe("voice-call manager timers", () => { await vi.advanceTimersByTimeAsync(5_000); - expect(persistCallRecordMock).not.toHaveBeenCalled(); expect(onTimeout).not.toHaveBeenCalled(); }); diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index 595ddb993f4..9ec6dadb212 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -1,15 +1,11 @@ import { TerminalStates, type CallId } from "../types.js"; import type { CallManagerContext } from "./context.js"; -import { persistCallRecord } from "./store.js"; type TimerContext = Pick< CallManagerContext, - "activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters" ->; -type MaxDurationTimerContext = Pick< - TimerContext, - "activeCalls" | "maxDurationTimers" | "config" | "storePath" + "activeCalls" | "maxDurationTimers" | "config" | "transcriptWaiters" >; +type MaxDurationTimerContext = Pick; type TranscriptWaiterContext = Pick; export function clearMaxDurationTimer( @@ -42,8 +38,6 @@ export function startMaxDurationTimer(params: { console.log( `[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`, ); - call.endReason = "timeout"; - persistCallRecord(params.ctx.storePath, call); await params.onTimeout(params.callId); } }, maxDurationMs);