diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index d200a994555c..27cdad8b3aac 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1993,17 +1993,20 @@ describe("RealtimeCallHandler path routing", () => { } }); - it("restores the prior transcript owner when replacement bridge creation fails", async () => { + it("preserves the predecessor when replacement closes with error during creation", async () => { const callbacks: RealtimeBridgeRequest[] = []; + const oldTriggerGreeting = vi.fn(); const createBridge = vi.fn((request: RealtimeBridgeRequest) => { callbacks.push(request); if (callbacks.length === 1) { - return makeBridge(); + return makeBridge({ triggerGreeting: oldTriggerGreeting }); } request.onTranscript?.("user", "Failed ", false); + request.onClose?.("error"); throw new Error("replacement bridge failed"); }); const processEvent = vi.fn(); + const hangupCall = vi.fn(async () => {}); const sharedCallSid = "CA-transcript-rollback"; const call = makeCallRecord(sharedCallSid); const handler = makeHandler(undefined, { @@ -2011,6 +2014,7 @@ describe("RealtimeCallHandler path routing", () => { getCallByProviderCallId: vi.fn(() => call), processEvent, }, + provider: { hangupCall }, realtimeProvider: makeRealtimeProvider(createBridge), }); const oldServer = await startRealtimeServer(handler); @@ -2043,6 +2047,17 @@ describe("RealtimeCallHandler path routing", () => { expect(createBridge).toHaveBeenCalledTimes(2); }); + expect(handler.speak(call.callId, "Continue the existing call.")).toEqual({ + success: true, + }); + expect(oldTriggerGreeting).toHaveBeenCalledWith("Continue the existing call."); + expect(hangupCall).not.toHaveBeenCalled(); + expect( + processEvent.mock.calls + .map(([event]) => event as NormalizedEvent) + .filter((event) => event.type === "call.ended"), + ).toHaveLength(0); + callbacks[0]?.onTranscript?.("user", "caller", true); await waitForRealtimeTest(() => { expect( diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index e8db0cb850c6..25b534c54470 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -771,6 +771,7 @@ export class RealtimeCallHandler { typeof this.providerConfig.interruptResponseOnInputAudio === "boolean" ? this.providerConfig.interruptResponseOnInputAudio : undefined; + const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId); // Providers may close synchronously before createBridge returns; no consult can exist yet. const nativeConsultOwner: { current?: ActiveRealtimeVoiceBridge } = {}; // Provisional ownership accepts callbacks fired during createBridge. Commit @@ -1014,7 +1015,12 @@ export class RealtimeCallHandler { if (ws.readyState === WebSocket.OPEN) { ws.close(1011, "Bridge disconnected"); } - if (owner && !ownsCallState) { + // A provisional replacement may fail before its bridge owner is assigned. + // The active predecessor still owns call termination until creation succeeds. + if ( + (owner && !ownsCallState) || + (!owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) + ) { return; } emitCallEnd("error");