fix(voice-call): keep active call when replacement setup fails (#117960)

* commit 'b65d010408f23da0247a0f91d8459a6549dab759':
  fix(voice-call): avoid retaining replaced bridges
  fix(voice-call): preserve predecessor on provisional close
  test(voice-call): cover provisional replacement close
This commit is contained in:
Vincent Koc
2026-08-02 18:53:59 +08:00
2 changed files with 24 additions and 3 deletions
@@ -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(
@@ -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");