From 081ab9c99ded2001c09d5035740bfa722bbaa6ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:28:23 +0000 Subject: [PATCH] fix(voice-call): tighten manager outbound behavior --- extensions/voice-call/src/manager.test.ts | 177 +++++------------- .../voice-call/src/manager/events.test.ts | 90 ++++----- extensions/voice-call/src/manager/outbound.ts | 82 +++++--- 3 files changed, 139 insertions(+), 210 deletions(-) diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index 3d02cb323be..d92dbc11f85 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider { } } +let storeSeq = 0; + +function createTestStorePath(): string { + storeSeq += 1; + return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`); +} + +function createManagerHarness( + configOverrides: Record = {}, + provider = new FakeProvider(), +): { + manager: CallManager; + provider: FakeProvider; +} { + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + ...configOverrides, + }); + const manager = new CallManager(config, createTestStorePath()); + manager.initialize(provider, "https://example.com/voice/webhook"); + return { manager, provider }; +} + +function markCallAnswered(manager: CallManager, callId: string, eventId: string): void { + manager.processEvent({ + id: eventId, + type: "call.answered", + callId, + providerCallId: "request-uuid", + timestamp: Date.now(), + }); +} + describe("CallManager", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); + const { manager } = createManagerHarness(); const { callId, success, error } = await manager.initiateCall("+15550000001"); expect(success).toBe(true); @@ -81,16 +108,7 @@ describe("CallManager", () => { }); it("speaks initial message on answered for notify mode (non-Twilio)", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - }); - - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); + const { manager, provider } = createManagerHarness(); const { callId, success } = await manager.initiateCall("+15550000002", undefined, { message: "Hello there", @@ -113,19 +131,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with missing caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-missing", type: "call.initiated", @@ -142,19 +152,11 @@ describe("CallManager", () => { }); it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-anon", type: "call.initiated", @@ -172,19 +174,11 @@ describe("CallManager", () => { }); it("rejects inbound calls that only match allowlist suffixes", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-suffix", type: "call.initiated", @@ -202,18 +196,10 @@ describe("CallManager", () => { }); it("rejects duplicate inbound events with a single hangup call", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ inboundPolicy: "disabled", }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-reject-init", type: "call.initiated", @@ -242,18 +228,11 @@ describe("CallManager", () => { }); it("accepts inbound calls that exactly match the allowlist", () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager } = createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const manager = new CallManager(config, storePath); - manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); - manager.processEvent({ id: "evt-allowlist-exact", type: "call.initiated", @@ -269,28 +248,14 @@ describe("CallManager", () => { }); it("completes a closed-loop turn without live audio", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000003"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-closed-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-closed-loop-answered"); const turnPromise = manager.continueCall(started.callId, "How can I help?"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -323,28 +288,14 @@ describe("CallManager", () => { }); it("rejects overlapping continueCall requests for the same call", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000004"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-overlap-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-overlap-answered"); const first = manager.continueCall(started.callId, "First prompt"); const second = await manager.continueCall(started.callId, "Second prompt"); @@ -369,28 +320,14 @@ describe("CallManager", () => { }); it("tracks latency metadata across multiple closed-loop turns", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000005"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-multi-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-multi-answered"); const firstTurn = manager.continueCall(started.callId, "First question"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -436,28 +373,14 @@ describe("CallManager", () => { }); it("handles repeated closed-loop turns without waiter churn", async () => { - const config = VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", + const { manager, provider } = createManagerHarness({ transcriptTimeoutMs: 5000, }); - const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`); - const provider = new FakeProvider(); - const manager = new CallManager(config, storePath); - manager.initialize(provider, "https://example.com/voice/webhook"); - const started = await manager.initiateCall("+15550000006"); expect(started.success).toBe(true); - manager.processEvent({ - id: "evt-loop-answered", - type: "call.answered", - callId: started.callId, - providerCallId: "request-uuid", - timestamp: Date.now(), - }); + markCallAnswered(manager, started.callId, "evt-loop-answered"); for (let i = 1; i <= 5; i++) { const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`); diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 74d1f10e46c..f1d5b5d6f03 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -45,6 +45,32 @@ function createProvider(overrides: Partial = {}): VoiceCallPr }; } +function createInboundDisabledConfig() { + return VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + inboundPolicy: "disabled", + }); +} + +function createInboundInitiatedEvent(params: { + id: string; + providerCallId: string; + from: string; +}): NormalizedEvent { + return { + id: params.id, + type: "call.initiated", + callId: params.providerCallId, + providerCallId: params.providerCallId, + timestamp: Date.now(), + direction: "inbound", + from: params.from, + to: "+15550000000", + }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { const hangupCalls: HangupCallInput[] = []; @@ -55,24 +81,14 @@ describe("processEvent (functional)", () => { }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-1", - type: "call.initiated", - callId: "prov-1", providerCallId: "prov-1", - timestamp: Date.now(), - direction: "inbound", from: "+15559999999", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -87,24 +103,14 @@ describe("processEvent (functional)", () => { it("does not call hangup when provider is null", () => { const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider: null, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-2", - type: "call.initiated", - callId: "prov-2", providerCallId: "prov-2", - timestamp: Date.now(), - direction: "inbound", from: "+15551111111", - to: "+15550000000", - }; + }); processEvent(ctx, event); @@ -119,24 +125,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event1: NormalizedEvent = { + const event1 = createInboundInitiatedEvent({ id: "evt-init", - type: "call.initiated", - callId: "prov-dup", providerCallId: "prov-dup", - timestamp: Date.now(), - direction: "inbound", from: "+15552222222", - to: "+15550000000", - }; + }); const event2: NormalizedEvent = { id: "evt-ring", type: "call.ringing", @@ -228,24 +224,14 @@ describe("processEvent (functional)", () => { }, }); const ctx = createContext({ - config: VoiceCallConfigSchema.parse({ - enabled: true, - provider: "plivo", - fromNumber: "+15550000000", - inboundPolicy: "disabled", - }), + config: createInboundDisabledConfig(), provider, }); - const event: NormalizedEvent = { + const event = createInboundInitiatedEvent({ id: "evt-fail", - type: "call.initiated", - callId: "prov-fail", providerCallId: "prov-fail", - timestamp: Date.now(), - direction: "inbound", from: "+15553333333", - to: "+15550000000", - }; + }); expect(() => processEvent(ctx, event)).not.toThrow(); expect(ctx.activeCalls.size).toBe(0); diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index d94c9da99ed..38978b6791c 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -51,6 +51,32 @@ type EndCallContext = Pick< | "maxDurationTimers" >; +type ConnectedCallContext = Pick; + +type ConnectedCallLookup = + | { kind: "error"; error: string } + | { kind: "ended"; call: CallRecord } + | { + kind: "ok"; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + +function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup { + const call = ctx.activeCalls.get(callId); + if (!call) { + return { kind: "error", error: "Call not found" }; + } + if (!ctx.provider || !call.providerCallId) { + return { kind: "error", error: "Call not connected" }; + } + if (TerminalStates.has(call.state)) { + return { kind: "ended", call }; + } + return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider }; +} + export async function initiateCall( ctx: InitiateContext, to: string, @@ -149,26 +175,25 @@ export async function speak( callId: CallId, text: string, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + try { transitionState(call, "speaking"); persistCallRecord(ctx.storePath, call); addTranscriptEntry(call, "bot", text); - const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; - await ctx.provider.playTts({ + const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined; + await provider.playTts({ callId, - providerCallId: call.providerCallId, + providerCallId, text, voice, }); @@ -232,16 +257,15 @@ export async function continueCall( callId: CallId, prompt: string, ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: false, error: "Call has ended" }; } + const { call, providerCallId, provider } = lookup; + if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) { return { success: false, error: "Already waiting for transcript" }; } @@ -256,13 +280,13 @@ export async function continueCall( persistCallRecord(ctx.storePath, call); const listenStartedAt = Date.now(); - await ctx.provider.startListening({ callId, providerCallId: call.providerCallId }); + await provider.startListening({ callId, providerCallId }); const transcript = await waitForFinalTranscript(ctx, callId); const transcriptReceivedAt = Date.now(); // Best-effort: stop listening after final transcript. - await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId }); + await provider.stopListening({ callId, providerCallId }); const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt; const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt; @@ -302,21 +326,19 @@ export async function endCall( ctx: EndCallContext, callId: CallId, ): Promise<{ success: boolean; error?: string }> { - const call = ctx.activeCalls.get(callId); - if (!call) { - return { success: false, error: "Call not found" }; + const lookup = lookupConnectedCall(ctx, callId); + if (lookup.kind === "error") { + return { success: false, error: lookup.error }; } - if (!ctx.provider || !call.providerCallId) { - return { success: false, error: "Call not connected" }; - } - if (TerminalStates.has(call.state)) { + if (lookup.kind === "ended") { return { success: true }; } + const { call, providerCallId, provider } = lookup; try { - await ctx.provider.hangupCall({ + await provider.hangupCall({ callId, - providerCallId: call.providerCallId, + providerCallId, reason: "hangup-bot", }); @@ -329,9 +351,7 @@ export async function endCall( rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot"); ctx.activeCalls.delete(callId); - if (call.providerCallId) { - ctx.providerCallIdMap.delete(call.providerCallId); - } + ctx.providerCallIdMap.delete(providerCallId); return { success: true }; } catch (err) {