// Voice Call tests cover outbound plugin behavior. import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { addTranscriptEntryMock, clearMaxDurationTimerMock, ensureMaxDurationTimerForLiveCallMock, generateDtmfRedirectTwimlMock, generateNotifyTwimlMock, getCallByProviderCallIdMock, mapVoiceToPollyMock, persistCallRecordMock, rejectTranscriptWaiterMock, transitionStateMock, } = vi.hoisted(() => ({ addTranscriptEntryMock: vi.fn(), clearMaxDurationTimerMock: vi.fn(), ensureMaxDurationTimerForLiveCallMock: vi.fn( (params: { call: { answeredAt?: number }; liveAt: number }) => { params.call.answeredAt ??= params.liveAt; }, ), generateDtmfRedirectTwimlMock: vi.fn(), generateNotifyTwimlMock: vi.fn(), getCallByProviderCallIdMock: vi.fn(), mapVoiceToPollyMock: vi.fn(), persistCallRecordMock: vi.fn(), rejectTranscriptWaiterMock: vi.fn(), transitionStateMock: vi.fn(), })); vi.mock("./state.js", () => ({ addTranscriptEntry: addTranscriptEntryMock, transitionState: transitionStateMock, })); vi.mock("./store.js", () => ({ persistCallRecord: persistCallRecordMock, })); vi.mock("./timers.js", () => ({ clearMaxDurationTimer: clearMaxDurationTimerMock, clearTranscriptWaiter: vi.fn(), ensureMaxDurationTimerForLiveCall: ensureMaxDurationTimerForLiveCallMock, rejectTranscriptWaiter: rejectTranscriptWaiterMock, waitForFinalTranscript: vi.fn(), })); vi.mock("./lookup.js", () => ({ getCallByProviderCallId: getCallByProviderCallIdMock, })); vi.mock("../voice-mapping.js", () => ({ mapVoiceToPolly: mapVoiceToPollyMock, })); vi.mock("./twiml.js", () => ({ generateDtmfRedirectTwiml: generateDtmfRedirectTwimlMock, generateNotifyTwiml: generateNotifyTwimlMock, })); import { endCall, initiateCall, sendDtmf, speak, speakInitialMessage } from "./outbound.js"; function createActiveCallContext(params: { hangupCall?: ReturnType } = {}) { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const hangupCall = params.hangupCall ?? vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map([["provider-1", "call-1"]]), provider: { hangupCall }, storePath: "/tmp/voice-call.json", transcriptWaiters: new Map(), maxDurationTimers: new Map(), }; return { call, ctx, hangupCall }; } describe("voice-call outbound helpers", () => { beforeEach(() => { vi.clearAllMocks(); mapVoiceToPollyMock.mockReturnValue("Polly.Joanna"); generateDtmfRedirectTwimlMock.mockReturnValue(""); generateNotifyTwimlMock.mockReturnValue(""); }); it("guards initiateCall when provider, webhook, capacity, or fromNumber are missing", async () => { const base = { activeCalls: new Map(), providerCallIdMap: new Map(), config: { maxConcurrentCalls: 1, outbound: { defaultMode: "conversation", notifyHangupDelaySec: 0 }, }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; await expect( initiateCall({ ...base, provider: undefined } as never, "+14155550123"), ).resolves.toEqual({ callId: "", success: false, error: "Provider not initialized", }); await expect( initiateCall( { ...base, provider: { name: "twilio" }, webhookUrl: undefined } as never, "+14155550123", ), ).resolves.toEqual({ callId: "", success: false, error: "Webhook URL not configured", }); const saturated = { ...base, activeCalls: new Map([["existing", {}]]), provider: { name: "twilio" }, }; await expect(initiateCall(saturated as never, "+14155550123")).resolves.toEqual({ callId: "", success: false, error: "Maximum concurrent calls (1) reached", }); await expect( initiateCall( { ...base, provider: { name: "twilio" }, config: { ...base.config, fromNumber: "" }, } as never, "+14155550123", ), ).resolves.toEqual({ callId: "", success: false, error: "fromNumber not configured", }); }); it("initiates notify-mode calls with inline TwiML and records provider ids", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", tts: { provider: "openai", providers: { openai: { voice: "nova" } } }, }, coreSession: { mainKey: "work" }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; const result = await initiateCall(ctx as never, "+14155550123", "main", { mode: "notify", message: "hello there", }); expect(result.success).toBe(true); expect(result.callId).toBeTypeOf("string"); expect(result.callId).not.toBe(""); const callId = result.callId; expect(mapVoiceToPollyMock).toHaveBeenCalledWith("nova"); expect(generateNotifyTwimlMock).toHaveBeenCalledWith("hello there", "Polly.Joanna"); expect(initiateProviderCall).toHaveBeenCalledWith({ callId, from: "+14155550100", to: "+14155550123", webhookUrl: "https://example.com/webhook", inlineTwiml: "", }); expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId); expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work"); expect(persistCallRecordMock).toHaveBeenCalledTimes(2); }); it("assigns per-call session keys to outbound calls when configured", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", sessionScope: "per-call", }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; const result = await initiateCall(ctx as never, "+14155550123"); expect(result.success).toBe(true); expect(result.callId).toBeTypeOf("string"); expect(result.callId).not.toBe(""); expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe( `agent:main:voice:call:${result.callId}`, ); }); it("initiates conversation calls with pre-connect DTMF TwiML", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; const result = await initiateCall(ctx as never, "+14155550123", "session-1", { mode: "conversation", message: "hello meet", dtmfSequence: "ww123456#", }); expect(result.success).toBe(true); expect(result.callId).toBeTypeOf("string"); expect(result.callId).not.toBe(""); const callId = result.callId; expect(generateDtmfRedirectTwimlMock).toHaveBeenCalledWith( "ww123456#", "https://example.com/webhook", ); expect(initiateProviderCall).toHaveBeenCalledWith({ callId, from: "+14155550100", to: "+14155550123", webhookUrl: "https://example.com/webhook", inlineTwiml: undefined, preConnectTwiml: "", }); const metadata = ( ctx.activeCalls.get(callId) as { metadata?: Record } | undefined )?.metadata; expect(metadata?.initialMessage).toBe("hello meet"); expect(metadata?.mode).toBe("conversation"); }); it("rejects DTMF sequences outside conversation mode", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "notify" }, fromNumber: "+14155550100", }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; await expect( initiateCall(ctx as never, "+14155550123", "session-1", { message: "hello", dtmfSequence: "123456#", }), ).resolves.toEqual({ callId: "", success: false, error: "dtmfSequence requires conversation mode", }); expect(initiateProviderCall).not.toHaveBeenCalled(); expect(ctx.activeCalls.size).toBe(0); }); it("fails initiateCall cleanly when provider initiation throws", async () => { const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "mock", initiateCall: vi.fn(async () => { throw new Error("provider down"); }), }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", }; const result = await initiateCall(ctx as never, "+14155550123"); expect(result.success).toBe(false); expect(result.error).toBe("provider down"); expect(result.callId).toBeTypeOf("string"); expect(result.callId).not.toBe(""); expect(ctx.activeCalls.size).toBe(0); }); it("speaks through connected calls and rolls back to listening on provider errors", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const playTts = vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "twilio", playTts }, config: { tts: { provider: "openai", providers: { openai: { voice: "alloy" } } } }, storePath: "/tmp/voice-call.json", }; await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); expect(transitionStateMock).toHaveBeenCalledWith(call, "speaking"); expect(playTts).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", text: "hello", voice: "alloy", }); expect(addTranscriptEntryMock).toHaveBeenCalledWith(call, "bot", "hello"); playTts.mockImplementationOnce(async () => { throw new Error("tts failed"); }); await expect(speak(ctx as never, "call-1", "hello again")).resolves.toEqual({ success: false, error: "tts failed", }); expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening"); }); it("passes configured voice ids through to Telnyx speak", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const playTts = vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "telnyx", playTts }, config: { tts: { provider: "telnyx", providers: { telnyx: { voiceId: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", }, }, }, }, storePath: "/tmp/voice-call.json", }; await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); expect(playTts).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", text: "hello", voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc", }); }); it("caps notify-mode auto-hangup delay before scheduling", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active", metadata: { initialMessage: "hello", mode: "notify" }, }; const playTts = vi.fn(async () => {}); const timeoutSpy = vi .spyOn(globalThis, "setTimeout") .mockReturnValue(1 as unknown as ReturnType); getCallByProviderCallIdMock.mockReturnValue(call); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map([["provider-1", "call-1"]]), provider: { name: "twilio", playTts }, initialMessageInFlight: new Set(), config: { outbound: { notifyHangupDelaySec: Number.MAX_SAFE_INTEGER }, tts: { provider: "openai", providers: { openai: { voice: "alloy" } } }, }, storePath: "/tmp/voice-call.json", }; try { await speakInitialMessage(ctx as never, "provider-1"); expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); } finally { timeoutSpy.mockRestore(); } }); it("uses per-number route TTS voice for routed inbound calls", async () => { const call = { callId: "call-1", providerCallId: "provider-1", direction: "inbound", state: "active", to: "+15550002222", metadata: { numberRouteKey: "+15550002222" }, }; const playTts = vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "twilio", playTts }, config: { tts: { provider: "openai", providers: { openai: { voice: "coral" } } }, numbers: { "+15550002222": { tts: { providers: { openai: { voice: "alloy" }, }, }, }, }, }, storePath: "/tmp/voice-call.json", }; await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); expect(playTts).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", text: "hello", voice: "alloy", }); }); it("keeps top-level TTS for outbound calls to a number with an inbound route", async () => { const call = { callId: "call-1", providerCallId: "provider-1", direction: "outbound", state: "active", to: "+15550002222", }; const playTts = vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "twilio", playTts }, config: { tts: { provider: "openai", providers: { openai: { voice: "coral" } } }, numbers: { "+15550002222": { tts: { providers: { openai: { voice: "alloy" } } }, }, }, }, storePath: "/tmp/voice-call.json", }; await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true }); expect(playTts).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", text: "hello", voice: "coral", }); }); it("sends DTMF through connected provider calls", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const sendDtmfProvider = vi.fn(async () => {}); const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "twilio", sendDtmf: sendDtmfProvider }, config: {}, storePath: "/tmp/voice-call.json", }; await expect(sendDtmf(ctx as never, "call-1", "ww123#")).resolves.toEqual({ success: true, }); expect(sendDtmfProvider).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", digits: "ww123#", }); }); it("rejects invalid or unsupported outbound DTMF", async () => { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; const ctx = { activeCalls: new Map([["call-1", call]]), providerCallIdMap: new Map(), provider: { name: "telnyx" }, config: {}, storePath: "/tmp/voice-call.json", }; await expect(sendDtmf(ctx as never, "call-1", "abc")).resolves.toEqual({ success: false, error: "digits may only contain digits, *, #, comma, w, p", }); await expect(sendDtmf(ctx as never, "call-1", "123#")).resolves.toEqual({ success: false, error: "telnyx does not support outbound DTMF", }); }); it("ends connected calls, clears timers, and rejects pending transcripts", async () => { const { call, ctx, hangupCall } = createActiveCallContext(); const beforeEndMs = Date.now(); await expect(endCall(ctx as never, "call-1")).resolves.toEqual({ success: true }); const afterEndMs = Date.now(); expect(hangupCall).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", reason: "hangup-bot", }); expect((call as { endReason?: string }).endReason).toBe("hangup-bot"); const endedAt = (call as { endedAt?: unknown }).endedAt; expect(endedAt).toBeTypeOf("number"); if (typeof endedAt === "number") { expect(endedAt).toBeGreaterThanOrEqual(beforeEndMs); expect(endedAt).toBeLessThanOrEqual(afterEndMs); } expect(transitionStateMock).toHaveBeenCalledWith(call, "hangup-bot"); expect(clearMaxDurationTimerMock).toHaveBeenCalledWith( { maxDurationTimers: ctx.maxDurationTimers }, "call-1", ); expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith( { transcriptWaiters: ctx.transcriptWaiters }, "call-1", "Call ended: hangup-bot", ); expect(ctx.activeCalls.size).toBe(0); expect(ctx.providerCallIdMap.size).toBe(0); }); it("preserves timeout reasons when ending timed out calls", async () => { const { call, ctx, hangupCall } = createActiveCallContext(); const beforeEndMs = Date.now(); await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({ success: true, }); const afterEndMs = Date.now(); expect(hangupCall).toHaveBeenCalledWith({ callId: "call-1", providerCallId: "provider-1", reason: "timeout", }); expect((call as { endReason?: string }).endReason).toBe("timeout"); const endedAt = (call as { endedAt?: unknown }).endedAt; expect(endedAt).toBeTypeOf("number"); if (typeof endedAt === "number") { expect(endedAt).toBeGreaterThanOrEqual(beforeEndMs); expect(endedAt).toBeLessThanOrEqual(afterEndMs); } expect(transitionStateMock).toHaveBeenCalledWith(call, "timeout"); expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith( { transcriptWaiters: ctx.transcriptWaiters }, "call-1", "Call ended: timeout", ); }); it("handles missing, disconnected, and already-ended calls", async () => { await expect( speak( { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", playTts: vi.fn() }, config: {}, storePath: "/tmp/voice-call.json", } as never, "missing", "hello", ), ).resolves.toEqual({ success: false, error: "Call not found" }); await expect( endCall( { activeCalls: new Map([ ["call-1", { callId: "call-1", state: "completed", providerCallId: "provider-1" }], ]), providerCallIdMap: new Map(), provider: { hangupCall: vi.fn() }, storePath: "/tmp/voice-call.json", transcriptWaiters: new Map(), maxDurationTimers: new Map(), } as never, "call-1", ), ).resolves.toEqual({ success: true }); }); it("issues a stream session and threads streamUrl + streamAuthToken through for Telnyx realtime", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "call-control-1" })); const streamSessionIssuer = vi.fn(() => ({ token: "token-xyz", streamUrl: "wss://example.test/voice/stream/realtime/token-xyz", })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "telnyx", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", realtime: { enabled: true }, }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", streamSessionIssuer, }; const result = await initiateCall(ctx as never, "+14155550123"); expect(result.success).toBe(true); expect(streamSessionIssuer).toHaveBeenCalledTimes(1); const issuerCall = ( streamSessionIssuer.mock.calls as unknown as Array< [{ providerName: string; direction: string; to: string }] > )[0]?.[0]; expect(issuerCall?.providerName).toBe("telnyx"); expect(issuerCall?.direction).toBe("outbound"); expect(issuerCall?.to).toBe("+14155550123"); const providerCall = ( initiateProviderCall.mock.calls as unknown as Array< [{ streamUrl?: string; streamAuthToken?: string }] > )[0]?.[0]; expect(providerCall?.streamUrl).toBe("wss://example.test/voice/stream/realtime/token-xyz"); expect(providerCall?.streamAuthToken).toBe("token-xyz"); }); it("skips the stream session for Twilio realtime (Twilio learns the URL from TwiML)", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" })); const streamSessionIssuer = vi.fn(() => ({ token: "should-not-be-used", streamUrl: "wss://example.test/should-not-be-used", })); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "twilio", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", realtime: { enabled: true }, }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", streamSessionIssuer, }; const result = await initiateCall(ctx as never, "+14155550123"); expect(result.success).toBe(true); expect(streamSessionIssuer).not.toHaveBeenCalled(); const providerCall = ( initiateProviderCall.mock.calls as unknown as Array<[Record]> )[0]?.[0]; expect(providerCall?.streamUrl).toBeUndefined(); expect(providerCall?.streamAuthToken).toBeUndefined(); }); it("does not issue a stream session when realtime is disabled", async () => { const initiateProviderCall = vi.fn(async () => ({ providerCallId: "call-control-1" })); const streamSessionIssuer = vi.fn(); const ctx = { activeCalls: new Map(), providerCallIdMap: new Map(), provider: { name: "telnyx", initiateCall: initiateProviderCall }, config: { maxConcurrentCalls: 3, outbound: { defaultMode: "conversation" }, fromNumber: "+14155550100", realtime: { enabled: false }, }, storePath: "/tmp/voice-call.json", webhookUrl: "https://example.com/webhook", streamSessionIssuer, }; await initiateCall(ctx as never, "+14155550123"); expect(streamSessionIssuer).not.toHaveBeenCalled(); }); });