// Voice Call tests cover config plugin behavior. import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { VoiceCallConfigSchema, resolveVoiceCallAgentSessionKey, resolveTwilioAuthToken, resolveVoiceCallEffectiveConfig, resolveVoiceCallNumberRouteKey, resolveVoiceCallNumberRouteKeyForCall, resolveVoiceCallSessionKey, validateProviderConfig, normalizeVoiceCallConfig, resolveVoiceCallConfig, type VoiceCallConfig, } from "./config.js"; import { createVoiceCallBaseConfig } from "./test-fixtures.js"; function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig { return createVoiceCallBaseConfig({ provider }); } function envRef(id: string) { return { source: "env" as const, provider: "default", id }; } function requireElevenLabsTtsConfig(config: Pick) { const tts = config.tts; const elevenlabs = tts?.providers?.elevenlabs; if (!elevenlabs || typeof elevenlabs !== "object") { throw new Error("voice-call config did not preserve nested elevenlabs TTS config"); } return { tts, elevenlabs }; } describe("validateProviderConfig", () => { const originalEnv = { ...process.env }; const clearProviderEnv = () => { delete process.env.TWILIO_ACCOUNT_SID; delete process.env.TWILIO_AUTH_TOKEN; delete process.env.TWILIO_FROM_NUMBER; delete process.env.TELNYX_API_KEY; delete process.env.TELNYX_CONNECTION_ID; delete process.env.TELNYX_PUBLIC_KEY; delete process.env.PLIVO_AUTH_ID; delete process.env.PLIVO_AUTH_TOKEN; }; beforeEach(() => { clearProviderEnv(); }); afterEach(() => { // Restore original env process.env = { ...originalEnv }; }); describe("provider credential sources", () => { it("passes validation when credentials come from config or environment", () => { for (const provider of ["twilio", "telnyx", "plivo"] as const) { clearProviderEnv(); const fromConfig = createBaseConfig(provider); if (provider === "twilio") { fromConfig.twilio = { accountSid: "AC123", authToken: "secret" }; } else if (provider === "telnyx") { fromConfig.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key", }; } else { fromConfig.plivo = { authId: "MA123", authToken: "secret" }; } expect(validateProviderConfig(fromConfig)).toEqual({ valid: true, errors: [] }); clearProviderEnv(); if (provider === "twilio") { process.env.TWILIO_ACCOUNT_SID = "AC123"; process.env.TWILIO_AUTH_TOKEN = "secret"; process.env.TWILIO_FROM_NUMBER = "+15550001234"; } else if (provider === "telnyx") { process.env.TELNYX_API_KEY = "KEY123"; process.env.TELNYX_CONNECTION_ID = "CONN456"; process.env.TELNYX_PUBLIC_KEY = "public-key"; } else { process.env.PLIVO_AUTH_ID = "MA123"; process.env.PLIVO_AUTH_TOKEN = "secret"; } const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider)); expect(validateProviderConfig(fromEnv)).toEqual({ valid: true, errors: [] }); } }); }); describe("twilio provider", () => { it("accepts SecretRef-backed auth tokens before runtime resolution", () => { const config = VoiceCallConfigSchema.parse({ enabled: true, provider: "twilio", fromNumber: "+15550001234", twilio: { accountSid: "AC123", authToken: envRef("TWILIO_AUTH_TOKEN"), }, }); expect(config.twilio?.authToken).toEqual(envRef("TWILIO_AUTH_TOKEN")); expect(validateProviderConfig(config)).toEqual({ valid: true, errors: [] }); expect(() => resolveTwilioAuthToken(config)).toThrow( 'plugins.entries.voice-call.config.twilio.authToken: unresolved SecretRef "env:default:TWILIO_AUTH_TOKEN"', ); }); it("passes validation with mixed config and env vars", () => { process.env.TWILIO_AUTH_TOKEN = "secret"; let config = createBaseConfig("twilio"); config.twilio = { accountSid: "AC123" }; config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); expect(result.valid).toBe(true); expect(result.errors).toStrictEqual([]); }); it("resolves the Twilio from number from environment", () => { process.env.TWILIO_ACCOUNT_SID = "AC123"; process.env.TWILIO_AUTH_TOKEN = "secret"; process.env.TWILIO_FROM_NUMBER = "+15550001234"; const config = resolveVoiceCallConfig({ ...createBaseConfig("twilio"), fromNumber: undefined, }); expect(config.fromNumber).toBe("+15550001234"); expect(validateProviderConfig(config)).toEqual({ valid: true, errors: [] }); }); it("fails validation when required twilio credentials are missing", () => { process.env.TWILIO_AUTH_TOKEN = "secret"; const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio"))); expect(missingSid.valid).toBe(false); expect(missingSid.errors).toContain( "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)", ); delete process.env.TWILIO_AUTH_TOKEN; process.env.TWILIO_ACCOUNT_SID = "AC123"; const missingToken = validateProviderConfig( resolveVoiceCallConfig(createBaseConfig("twilio")), ); expect(missingToken.valid).toBe(false); expect(missingToken.errors).toContain( "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", ); }); }); describe("telnyx provider", () => { it("fails validation when apiKey is missing everywhere", () => { process.env.TELNYX_CONNECTION_ID = "CONN456"; let config = createBaseConfig("telnyx"); config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); expect(result.valid).toBe(false); expect(result.errors).toContain( "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)", ); }); it("requires a public key unless signature verification is skipped", () => { const missingPublicKey = createBaseConfig("telnyx"); missingPublicKey.inboundPolicy = "allowlist"; missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; const missingPublicKeyResult = validateProviderConfig(missingPublicKey); expect(missingPublicKeyResult.valid).toBe(false); expect(missingPublicKeyResult.errors).toContain( "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)", ); const withPublicKey = createBaseConfig("telnyx"); withPublicKey.inboundPolicy = "allowlist"; withPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key", }; expect(validateProviderConfig(withPublicKey)).toEqual({ valid: true, errors: [] }); const skippedVerification = createBaseConfig("telnyx"); skippedVerification.skipSignatureVerification = true; skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; expect(validateProviderConfig(skippedVerification)).toEqual({ valid: true, errors: [], }); }); }); describe("plivo provider", () => { it("fails validation when authId is missing everywhere", () => { process.env.PLIVO_AUTH_TOKEN = "secret"; let config = createBaseConfig("plivo"); config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); expect(result.valid).toBe(false); expect(result.errors).toContain( "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)", ); }); }); describe("disabled config", () => { it("skips validation when enabled is false", () => { const config = createBaseConfig("twilio"); config.enabled = false; const result = validateProviderConfig(config); expect(result.valid).toBe(true); expect(result.errors).toStrictEqual([]); }); }); describe("realtime config", () => { it("rejects disabled inbound policy for realtime mode", () => { const config = createBaseConfig("twilio"); config.realtime.enabled = true; config.inboundPolicy = "disabled"; const result = validateProviderConfig(config); expect(result.valid).toBe(false); expect(result.errors).toContain( 'plugins.entries.voice-call.config.inboundPolicy must not be "disabled" when realtime.enabled is true', ); }); it("rejects enabling realtime and streaming together", () => { const config = createBaseConfig("twilio"); config.realtime.enabled = true; config.streaming.enabled = true; config.inboundPolicy = "allowlist"; const result = validateProviderConfig(config); expect(result.valid).toBe(false); expect(result.errors).toContain( "plugins.entries.voice-call.config.realtime.enabled and plugins.entries.voice-call.config.streaming.enabled cannot both be true", ); }); it("accepts realtime.enabled with provider=telnyx", () => { const config = createBaseConfig("telnyx"); config.realtime.enabled = true; config.inboundPolicy = "allowlist"; const result = validateProviderConfig(config); expect(result.errors).not.toContain( 'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true', ); }); it("rejects realtime.enabled with providers that do not support it yet", () => { const config = createBaseConfig("plivo"); config.realtime.enabled = true; config.inboundPolicy = "allowlist"; const result = validateProviderConfig(config); expect(result.valid).toBe(false); expect(result.errors).toContain( 'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true', ); }); }); }); describe("resolveVoiceCallConfig session routing", () => { it("enables the pre-answer stale call reaper by default", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock" }); expect(config.staleCallReaperSeconds).toBe(120); }); it("keeps voice sessions scoped by phone by default", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock" }); expect(config.sessionScope).toBe("per-phone"); expect( resolveVoiceCallSessionKey({ config, callId: "call-123", phone: "+1 (555) 000-1111", }), ).toBe("agent:main:voice:15550001111"); }); it("scopes generated voice session keys by configured agent", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", agentId: "Voice", }); expect( resolveVoiceCallSessionKey({ config, callId: "CALL-123", phone: "+1 (555) 000-1111", }), ).toBe("agent:voice:voice:15550001111"); }); it("can scope voice sessions to each call", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", sessionScope: "per-call", }); expect(config.sessionScope).toBe("per-call"); expect( resolveVoiceCallSessionKey({ config, callId: "call-123", phone: "+1 (555) 000-1111", }), ).toBe("agent:main:voice:call:call-123"); }); it("scopes explicit voice session keys by configured agent", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", sessionScope: "per-call", }); expect( resolveVoiceCallSessionKey({ config, callId: "call-123", phone: "+1 (555) 000-1111", explicitSessionKey: "Meet-Room-1", }), ).toBe("agent:main:meet-room-1"); }); it("scopes persisted and explicit keys at the agent session boundary", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", agentId: "Voice", }); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "voice:call:legacy-call", }), ).toBe("agent:voice:voice:call:legacy-call"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "meet-room-1", }), ).toBe("agent:voice:meet-room-1"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:main:shared-room", }), ).toBe("agent:voice:agent:main:shared-room"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org", }), ).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org", }), ).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "Signal:Group:AbC123=", }), ).toBe("agent:voice:signal:group:AbC123="); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:broken", }), ).toBe("agent:voice:agent:broken"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent::broken", }), ).toBe("agent:voice:agent::broken"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org", }), ).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:other:room::part", }), ).toBe("agent:voice:agent:other:room::part"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:voice:room::part", }), ).toBe("agent:voice:room::part"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org", }), ).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:bad/id:room", }), ).toBe("agent:voice:agent:bad/id:room"); }); it("canonicalizes raw and scoped main aliases with the core session config", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", agentId: "Voice", }); for (const sessionKey of ["main", "agent:voice:main"]) { expect( resolveVoiceCallAgentSessionKey({ config, sessionKey, coreSession: { mainKey: "work" }, }), ).toBe("agent:voice:work"); } expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "main", coreSession: { scope: "global" }, }), ).toBe("global"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:main:main", coreSession: { mainKey: "work" }, }), ).toBe("agent:voice:agent:main:main"); expect( resolveVoiceCallAgentSessionKey({ config, sessionKey: "agent:main:main", coreSession: { scope: "global" }, }), ).toBe("agent:voice:agent:main:main"); }); it("resolves per-number inbound route overrides over global voice settings", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", inboundGreeting: "Hello from global.", agentId: "main", responseModel: "openai/gpt-5.4-mini", responseSystemPrompt: "Global voice assistant.", responseTimeoutMs: 10000, tts: { provider: "openai", providers: { openai: { voice: "coral", speed: 1 }, }, }, numbers: { "+15550001111": { inboundGreeting: "Silver Fox Cards, how can I help?", agentId: "cards", responseModel: "openai/gpt-5.5", responseSystemPrompt: "You are a baseball card expert.", responseTimeoutMs: 20000, tts: { providers: { openai: { voice: "alloy" }, }, }, }, }, }); expect(resolveVoiceCallNumberRouteKey(config, "+1 (555) 000-1111")).toBe("+15550001111"); const effective = resolveVoiceCallEffectiveConfig(config, "+1 (555) 000-1111"); expect(effective.numberRouteKey).toBe("+15550001111"); expect(effective.config.inboundGreeting).toBe("Silver Fox Cards, how can I help?"); expect(effective.config.agentId).toBe("cards"); expect(effective.config.responseModel).toBe("openai/gpt-5.5"); expect(effective.config.responseSystemPrompt).toBe("You are a baseball card expert."); expect(effective.config.responseTimeoutMs).toBe(20000); expect(effective.config.tts?.provider).toBe("openai"); expect(effective.config.tts?.providers?.openai).toEqual({ voice: "alloy", speed: 1 }); }); it("falls back to global voice settings when no per-number route matches", () => { const config = resolveVoiceCallConfig({ enabled: true, provider: "mock", inboundGreeting: "Hello from global.", numbers: { "+15550001111": { inboundGreeting: "Hello from route.", }, }, }); const effective = resolveVoiceCallEffectiveConfig(config, "+15550002222"); expect(effective.numberRouteKey).toBeUndefined(); expect(effective.config).toBe(config); expect(effective.config.inboundGreeting).toBe("Hello from global."); }); it("uses dialed-number fallback only for inbound calls", () => { expect( resolveVoiceCallNumberRouteKeyForCall({ direction: "inbound", to: "+15550001111", }), ).toBe("+15550001111"); expect( resolveVoiceCallNumberRouteKeyForCall({ direction: "outbound", to: "+15550001111", }), ).toBeUndefined(); expect( resolveVoiceCallNumberRouteKeyForCall({ direction: "inbound", to: "+15550001111", metadata: { numberRouteKey: "+15550002222" }, }), ).toBe("+15550002222"); expect( resolveVoiceCallNumberRouteKeyForCall({ direction: "outbound", to: "+15550001111", metadata: { numberRouteKey: "+15550002222" }, }), ).toBeUndefined(); }); }); describe("normalizeVoiceCallConfig", () => { it("fills nested runtime defaults from a partial config boundary", () => { const normalized = normalizeVoiceCallConfig({ enabled: true, provider: "mock", streaming: { enabled: true, streamPath: "/custom-stream", }, }); expect(normalized.serve.path).toBe("/voice/webhook"); expect(normalized.streaming.streamPath).toBe("/custom-stream"); expect(normalized.streaming.provider).toBeUndefined(); expect(normalized.streaming.providers).toStrictEqual({}); expect(normalized.realtime.streamPath).toBe("/voice/stream/realtime"); expect(normalized.realtime.toolPolicy).toBe("safe-read-only"); expect(normalized.realtime.consultPolicy).toBe("auto"); expect(normalized.realtime.fastContext).toEqual({ enabled: false, timeoutMs: 800, maxResults: 3, sources: ["memory", "sessions"], fallbackToConsult: false, }); expect(normalized.realtime.consultThinkingLevel).toBeUndefined(); expect(normalized.realtime.consultFastMode).toBeUndefined(); expect(normalized.realtime.agentContext).toEqual({ enabled: false, maxChars: 6000, includeIdentity: true, includeWorkspaceFiles: true, files: ["SOUL.md", "IDENTITY.md", "USER.md"], }); expect(normalized.realtime.instructions).toContain("openclaw_agent_consult"); expect(normalized.tunnel.provider).toBe("none"); expect(normalized.webhookSecurity.allowedHosts).toStrictEqual([]); }); it("derives the realtime stream path from a custom webhook path", () => { const normalized = normalizeVoiceCallConfig({ enabled: true, provider: "twilio", serve: { path: "/custom/webhook", }, }); expect(normalized.realtime.streamPath).toBe("/custom/stream/realtime"); }); it("accepts partial nested TTS overrides and preserves nested objects", () => { const normalized = normalizeVoiceCallConfig({ tts: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: { source: "env", provider: "elevenlabs", id: "ELEVENLABS_API_KEY", }, voiceSettings: { speed: 1.1, }, }, }, }, }); const { tts, elevenlabs } = requireElevenLabsTtsConfig(normalized); expect(tts.provider).toBe("elevenlabs"); expect(elevenlabs.apiKey).toEqual({ source: "env", provider: "elevenlabs", id: "ELEVENLABS_API_KEY", }); expect(elevenlabs.voiceSettings).toEqual({ speed: 1.1 }); }); }); describe("resolveVoiceCallConfig realtime settings", () => { it("preserves configured realtime instructions without env indirection", () => { const resolved = resolveVoiceCallConfig({ enabled: true, provider: "twilio", realtime: { enabled: true, instructions: "Stay concise.", }, }); expect(resolved.realtime.instructions).toBe("Stay concise."); expect(resolved.realtime.toolPolicy).toBe("safe-read-only"); expect(resolved.realtime.consultPolicy).toBe("auto"); expect(resolved.realtime.provider).toBeUndefined(); }); it("preserves configured realtime consult overrides", () => { const resolved = resolveVoiceCallConfig({ enabled: true, provider: "mock", realtime: { consultThinkingLevel: "low", consultFastMode: true, }, }); expect(resolved.realtime.consultThinkingLevel).toBe("low"); expect(resolved.realtime.consultFastMode).toBe(true); }); it("rejects invalid realtime consult thinking levels", () => { expect(() => resolveVoiceCallConfig({ enabled: true, provider: "mock", realtime: { consultThinkingLevel: "turbo", }, } as never), ).toThrow(/Invalid option/); }); it("leaves responseModel unset so voice responses can inherit runtime defaults", () => { const resolved = resolveVoiceCallConfig({ enabled: true, provider: "mock", }); expect(resolved.responseModel).toBeUndefined(); }); it("preserves the configured voice response agent id", () => { const resolved = resolveVoiceCallConfig({ enabled: true, provider: "mock", agentId: "voice", }); expect(resolved.agentId).toBe("voice"); }); });