Skip to content

Commit e1cd850

Browse files
fix(voice-call): preserve live Twilio streams in stale reaper
1 parent 1a3ce7c commit e1cd850

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

extensions/voice-call/src/webhook.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,21 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
616616
});
617617
expect(endCall).not.toHaveBeenCalled();
618618
});
619+
620+
it("does not reap live calls in speaking state without answeredAt (inbound Twilio)", async () => {
621+
const { endCall } = await runStaleCallReaperCase({
622+
callAgeMs: 120_000,
623+
staleCallReaperSeconds: 60,
624+
advanceMs: 30_000,
625+
callOverrides: {
626+
state: "speaking",
627+
// answeredAt intentionally absent — inbound Twilio calls never fire
628+
// call.answered, so answeredAt stays undefined even while live.
629+
answeredAt: undefined,
630+
},
631+
});
632+
expect(endCall).not.toHaveBeenCalled();
633+
});
619634
});
620635

621636
describe("VoiceCallWebhookServer path matching", () => {

extensions/voice-call/src/webhook/stale-call-reaper.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
// Voice Call plugin module implements stale call reaper behavior.
22
import type { CallManager } from "../manager.js";
3+
import type { CallState } from "../types.js";
34
import { TerminalStates } from "../types.js";
45

56
// Background cleanup loop for calls that never reached answered/terminal state.
67

78
const CHECK_INTERVAL_MS = 30_000;
89

10+
/** States that indicate a live conversation with speech/transcription.
11+
* Inbound Twilio calls may never fire a call.answered event, so answeredAt
12+
* can be absent even while the call is actively transcribing. These states
13+
* prove the call is live and should not be reaped. */
14+
const LiveConversationStates: ReadonlySet<CallState> = new Set(["speaking", "listening"]);
15+
916
/** Start a stale-call reaper and return its cleanup callback. */
1017
export function startStaleCallReaper(params: {
1118
manager: CallManager;
@@ -20,7 +27,16 @@ export function startStaleCallReaper(params: {
2027
const interval = setInterval(() => {
2128
const now = Date.now();
2229
for (const call of params.manager.getActiveCalls()) {
23-
if (call.answeredAt || TerminalStates.has(call.state)) {
30+
// Skip calls that have been answered (answeredAt set) or are in a live
31+
// conversation state. Inbound Twilio calls may never fire a call.answered
32+
// event so answeredAt may be absent even when the call is actively
33+
// transcribing/responding. Without this state guard live calls in
34+
// speaking/listening state get reaped as stale.
35+
if (
36+
call.answeredAt ||
37+
TerminalStates.has(call.state) ||
38+
LiveConversationStates.has(call.state)
39+
) {
2440
continue;
2541
}
2642

0 commit comments

Comments
 (0)