diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3bcc562f6..45caa8c6ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc. - Exec approvals: treat POSIX `exec` as a command carrier for inline eval, shell-wrapper, and eval/source detection, so approval explanations and command-risk checks do not miss payloads hidden behind `exec`. Thanks @vincentkoc. - Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model. - Diagnostics: handle missing session-tail files in cron recovery context without tripping extension test typecheck. Thanks @vincentkoc. diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index 0c8538e4bf6..7a846902736 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -337,6 +337,82 @@ describe("RealtimeCallHandler path routing", () => { } }); + it("marks realtime calls ended when the provider closes normally", async () => { + let callbacks: + | { + onClose?: (reason: "completed" | "error") => void; + } + | undefined; + const processEvent = vi.fn(); + const createBridge = vi.fn( + (request: Parameters[0]) => { + callbacks = request; + return makeBridge({ + close: () => { + callbacks?.onClose?.("completed"); + }, + }); + }, + ); + const getCallByProviderCallId = vi.fn( + (): CallRecord => ({ + callId: "call-1", + providerCallId: "CA-complete", + provider: "twilio", + direction: "inbound", + state: "ringing", + from: "+15550001234", + to: "+15550009999", + startedAt: Date.now(), + transcript: [], + processedEventIds: [], + metadata: {}, + }), + ); + const handler = makeHandler(undefined, { + manager: { + processEvent, + getCallByProviderCallId, + }, + realtimeProvider: makeRealtimeProvider(createBridge), + }); + const server = await startRealtimeServer(handler); + + try { + const ws = await connectWs(server.url); + try { + ws.send( + JSON.stringify({ + event: "start", + start: { streamSid: "MZ-complete", callSid: "CA-complete" }, + }), + ); + await vi.waitFor(() => { + expect(createBridge).toHaveBeenCalled(); + }); + + ws.send(JSON.stringify({ event: "stop" })); + + await vi.waitFor(() => { + expect(processEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "call.ended", + callId: "call-1", + providerCallId: "CA-complete", + reason: "completed", + }), + ); + }); + } finally { + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } + } finally { + await server.close(); + } + }); + it("submits continuing responses only for realtime agent consult calls", async () => { let callbacks: | { diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index 9d2961c1588..542d9d613a9 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -367,6 +367,7 @@ export class RealtimeCallHandler { this.activeBridgesByCallId.delete(callSid); this.partialUserTranscriptsByCallId.delete(callId); if (reason !== "error") { + emitCallEnd("completed"); return; } emitCallEnd("error");