voice-call: add tests for realtime config schema and handler
This commit is contained in:
parent
3fd5f44d05
commit
7429bca0cd
@ -3,6 +3,7 @@ import {
|
|||||||
validateProviderConfig,
|
validateProviderConfig,
|
||||||
normalizeVoiceCallConfig,
|
normalizeVoiceCallConfig,
|
||||||
resolveVoiceCallConfig,
|
resolveVoiceCallConfig,
|
||||||
|
VoiceCallRealtimeConfigSchema,
|
||||||
type VoiceCallConfig,
|
type VoiceCallConfig,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||||
@ -216,3 +217,115 @@ describe("normalizeVoiceCallConfig", () => {
|
|||||||
expect(normalized.tts?.elevenlabs?.voiceSettings).toEqual({ speed: 1.1 });
|
expect(normalized.tts?.elevenlabs?.voiceSettings).toEqual({ speed: 1.1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("VoiceCallRealtimeConfigSchema", () => {
|
||||||
|
it("defaults to disabled with empty tools array", () => {
|
||||||
|
const config = VoiceCallRealtimeConfigSchema.parse({});
|
||||||
|
expect(config.enabled).toBe(false);
|
||||||
|
expect(config.tools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid Realtime API voice names", () => {
|
||||||
|
const voices = ["alloy", "ash", "ballad", "cedar", "coral", "echo", "marin", "sage", "shimmer", "verse"];
|
||||||
|
for (const voice of voices) {
|
||||||
|
expect(() => VoiceCallRealtimeConfigSchema.parse({ voice })).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects voice names that are not in the Realtime API (e.g. nova, fable, onyx)", () => {
|
||||||
|
for (const voice of ["nova", "fable", "onyx"]) {
|
||||||
|
expect(() => VoiceCallRealtimeConfigSchema.parse({ voice })).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizeVoiceCallConfig propagates realtime sub-config", () => {
|
||||||
|
const normalized = normalizeVoiceCallConfig({
|
||||||
|
enabled: true,
|
||||||
|
provider: "mock",
|
||||||
|
realtime: { enabled: true, voice: "marin", instructions: "Be helpful." },
|
||||||
|
});
|
||||||
|
expect(normalized.realtime.enabled).toBe(true);
|
||||||
|
expect(normalized.realtime.voice).toBe("marin");
|
||||||
|
expect(normalized.realtime.instructions).toBe("Be helpful.");
|
||||||
|
expect(normalized.realtime.tools).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveVoiceCallConfig — realtime env vars", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-enables realtime from REALTIME_VOICE_ENABLED=true", () => {
|
||||||
|
process.env.REALTIME_VOICE_ENABLED = "true";
|
||||||
|
const resolved = resolveVoiceCallConfig(createVoiceCallBaseConfig());
|
||||||
|
expect(resolved.realtime.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-enable when REALTIME_VOICE_ENABLED is absent or not 'true'", () => {
|
||||||
|
delete process.env.REALTIME_VOICE_ENABLED;
|
||||||
|
expect(resolveVoiceCallConfig(createVoiceCallBaseConfig()).realtime.enabled).toBe(false);
|
||||||
|
|
||||||
|
process.env.REALTIME_VOICE_ENABLED = "false";
|
||||||
|
expect(resolveVoiceCallConfig(createVoiceCallBaseConfig()).realtime.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves model, voice, instructions, temperature from env vars", () => {
|
||||||
|
process.env.REALTIME_VOICE_MODEL = "gpt-4o-realtime-preview";
|
||||||
|
process.env.REALTIME_VOICE_VOICE = "ash";
|
||||||
|
process.env.REALTIME_VOICE_INSTRUCTIONS = "You are helpful.";
|
||||||
|
process.env.REALTIME_VOICE_TEMPERATURE = "0.8";
|
||||||
|
const resolved = resolveVoiceCallConfig(createVoiceCallBaseConfig());
|
||||||
|
expect(resolved.realtime.model).toBe("gpt-4o-realtime-preview");
|
||||||
|
expect(resolved.realtime.voice).toBe("ash");
|
||||||
|
expect(resolved.realtime.instructions).toBe("You are helpful.");
|
||||||
|
expect(resolved.realtime.temperature).toBeCloseTo(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves vadThreshold and silenceDurationMs from env vars", () => {
|
||||||
|
process.env.VAD_THRESHOLD = "0.7";
|
||||||
|
process.env.SILENCE_DURATION_MS = "1200";
|
||||||
|
const resolved = resolveVoiceCallConfig(createVoiceCallBaseConfig());
|
||||||
|
expect(resolved.realtime.vadThreshold).toBeCloseTo(0.7);
|
||||||
|
expect(resolved.realtime.silenceDurationMs).toBe(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("config values take precedence over env vars", () => {
|
||||||
|
process.env.REALTIME_VOICE_VOICE = "ash";
|
||||||
|
const base = createVoiceCallBaseConfig();
|
||||||
|
base.realtime = { enabled: false, voice: "coral", tools: [] };
|
||||||
|
const resolved = resolveVoiceCallConfig(base);
|
||||||
|
expect(resolved.realtime.voice).toBe("coral");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateProviderConfig — realtime mode", () => {
|
||||||
|
it("rejects realtime.enabled when inboundPolicy is 'disabled'", () => {
|
||||||
|
const config = createVoiceCallBaseConfig({ provider: "mock" });
|
||||||
|
config.realtime = { enabled: true, tools: [] };
|
||||||
|
// inboundPolicy defaults to "disabled" in createVoiceCallBaseConfig
|
||||||
|
const result = validateProviderConfig(config);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes("inboundPolicy"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes when realtime.enabled with inboundPolicy 'open'", () => {
|
||||||
|
const config = createVoiceCallBaseConfig({ provider: "mock" });
|
||||||
|
config.inboundPolicy = "open";
|
||||||
|
config.realtime = { enabled: true, tools: [] };
|
||||||
|
const result = validateProviderConfig(config);
|
||||||
|
expect(result.errors.some((e) => e.includes("inboundPolicy"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when both realtime.enabled and streaming.enabled are true", () => {
|
||||||
|
const config = createVoiceCallBaseConfig({ provider: "mock" });
|
||||||
|
config.inboundPolicy = "open";
|
||||||
|
config.realtime = { enabled: true, tools: [] };
|
||||||
|
config.streaming = { ...config.streaming, enabled: true };
|
||||||
|
const result = validateProviderConfig(config);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.includes("streaming"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
274
extensions/voice-call/src/webhook/realtime-handler.test.ts
Normal file
274
extensions/voice-call/src/webhook/realtime-handler.test.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CallManager } from "../manager.js";
|
||||||
|
import type { CallRecord } from "../types.js";
|
||||||
|
import type { VoiceCallProvider } from "../providers/base.js";
|
||||||
|
import { RealtimeCallHandler } from "./realtime-handler.js";
|
||||||
|
|
||||||
|
// Minimal realtime config used across tests
|
||||||
|
const baseRealtimeConfig = {
|
||||||
|
enabled: true,
|
||||||
|
voice: "ash" as const,
|
||||||
|
tools: [] as never[],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fake CallRecord for manager stubs
|
||||||
|
function makeCallRecord(overrides: Partial<CallRecord> = {}): CallRecord {
|
||||||
|
return {
|
||||||
|
callId: "call-rt-1",
|
||||||
|
providerCallId: "CA_test",
|
||||||
|
provider: "twilio",
|
||||||
|
direction: "inbound",
|
||||||
|
state: "answered",
|
||||||
|
from: "+15550001234",
|
||||||
|
to: "+15550005678",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
transcript: [],
|
||||||
|
processedEventIds: [],
|
||||||
|
metadata: {
|
||||||
|
initialMessage: "Hello! How can I help you today?",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeManager(record?: CallRecord): CallManager {
|
||||||
|
const storedRecord = record ?? makeCallRecord();
|
||||||
|
return {
|
||||||
|
processEvent: vi.fn(),
|
||||||
|
getCallByProviderCallId: vi.fn(() => storedRecord),
|
||||||
|
getCall: vi.fn(() => storedRecord),
|
||||||
|
} as unknown as CallManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProvider(): VoiceCallProvider {
|
||||||
|
return {
|
||||||
|
name: "twilio",
|
||||||
|
verifyWebhook: vi.fn(() => ({ ok: true, verifiedRequestKey: "mock:key" })),
|
||||||
|
parseWebhookEvent: vi.fn(() => ({ events: [] })),
|
||||||
|
initiateCall: vi.fn(async () => ({ providerCallId: "CA_test", status: "initiated" as const })),
|
||||||
|
hangupCall: vi.fn(async () => {}),
|
||||||
|
playTts: vi.fn(async () => {}),
|
||||||
|
startListening: vi.fn(async () => {}),
|
||||||
|
stopListening: vi.fn(async () => {}),
|
||||||
|
getCallStatus: vi.fn(async () => ({ status: "in-progress" as const, isTerminal: false })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(url: string, host = "example.ts.net"): http.IncomingMessage {
|
||||||
|
const req = new http.IncomingMessage(null as never);
|
||||||
|
req.url = url;
|
||||||
|
req.method = "POST";
|
||||||
|
req.headers = { host };
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RealtimeCallHandler", () => {
|
||||||
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildTwiMLPayload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("buildTwiMLPayload", () => {
|
||||||
|
it("returns TwiML <Connect><Stream> with wss URL derived from request host", () => {
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
makeManager(),
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const req = makeRequest("/voice/webhook", "gateway.ts.net");
|
||||||
|
const payload = handler.buildTwiMLPayload(req);
|
||||||
|
|
||||||
|
expect(payload.statusCode).toBe(200);
|
||||||
|
expect(payload.headers?.["Content-Type"]).toBe("text/xml");
|
||||||
|
expect(payload.body).toContain("<Connect>");
|
||||||
|
expect(payload.body).toContain("<Stream");
|
||||||
|
expect(payload.body).toContain('url="wss://gateway.ts.net/voice/stream/realtime"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to localhost when no host header is present", () => {
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
makeManager(),
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const req = makeRequest("/voice/webhook", "");
|
||||||
|
const payload = handler.buildTwiMLPayload(req);
|
||||||
|
|
||||||
|
expect(payload.body).toContain("wss://localhost:8443/voice/stream/realtime");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// registerCallInManager — greeting suppression
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("registerCallInManager (via handleCall)", () => {
|
||||||
|
it("clears metadata.initialMessage so the inboundGreeting TTS path is skipped", () => {
|
||||||
|
const callRecord = makeCallRecord({
|
||||||
|
metadata: { initialMessage: "Hello from config!" },
|
||||||
|
});
|
||||||
|
const manager = makeManager(callRecord);
|
||||||
|
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
manager,
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Access private method via type assertion for unit testing
|
||||||
|
(handler as unknown as { registerCallInManager: (sid: string) => string })
|
||||||
|
.registerCallInManager("CA_test");
|
||||||
|
|
||||||
|
// call.initiated + call.answered should both have been emitted
|
||||||
|
expect(vi.mocked(manager.processEvent)).toHaveBeenCalledTimes(2);
|
||||||
|
const eventTypes = vi.mocked(manager.processEvent).mock.calls.map(
|
||||||
|
([e]) => (e as { type: string }).type,
|
||||||
|
);
|
||||||
|
expect(eventTypes).toEqual(["call.initiated", "call.answered"]);
|
||||||
|
|
||||||
|
// initialMessage must be cleared before call.answered fires
|
||||||
|
expect(callRecord.metadata?.initialMessage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns callId from the manager-created call record", () => {
|
||||||
|
const callRecord = makeCallRecord({ callId: "manager-gen-id" });
|
||||||
|
const manager = makeManager(callRecord);
|
||||||
|
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
manager,
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (handler as unknown as { registerCallInManager: (sid: string) => string })
|
||||||
|
.registerCallInManager("CA_test");
|
||||||
|
|
||||||
|
expect(result).toBe("manager-gen-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to providerCallId when manager has no record", () => {
|
||||||
|
const manager = {
|
||||||
|
processEvent: vi.fn(),
|
||||||
|
getCallByProviderCallId: vi.fn(() => undefined),
|
||||||
|
} as unknown as CallManager;
|
||||||
|
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
manager,
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (handler as unknown as { registerCallInManager: (sid: string) => string })
|
||||||
|
.registerCallInManager("CA_fallback");
|
||||||
|
|
||||||
|
expect(result).toBe("CA_fallback");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool handler framework
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("registerToolHandler", () => {
|
||||||
|
it("routes tool calls to registered handlers and returns their result", async () => {
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
makeManager(),
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
handler.registerToolHandler("get_time", async () => ({ utc: "2026-03-10T00:00:00Z" }));
|
||||||
|
|
||||||
|
const fakeSubmit = vi.fn();
|
||||||
|
const fakeBridge = { submitToolResult: fakeSubmit } as never;
|
||||||
|
|
||||||
|
await (
|
||||||
|
handler as unknown as {
|
||||||
|
executeToolCall: (
|
||||||
|
bridge: never,
|
||||||
|
callId: string,
|
||||||
|
bridgeCallId: string,
|
||||||
|
name: string,
|
||||||
|
args: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).executeToolCall(fakeBridge, "call-1", "bridge-call-1", "get_time", {});
|
||||||
|
|
||||||
|
expect(fakeSubmit).toHaveBeenCalledWith("bridge-call-1", { utc: "2026-03-10T00:00:00Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error result for unregistered tool names", async () => {
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
makeManager(),
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fakeSubmit = vi.fn();
|
||||||
|
const fakeBridge = { submitToolResult: fakeSubmit } as never;
|
||||||
|
|
||||||
|
await (
|
||||||
|
handler as unknown as {
|
||||||
|
executeToolCall: (
|
||||||
|
bridge: never,
|
||||||
|
callId: string,
|
||||||
|
bridgeCallId: string,
|
||||||
|
name: string,
|
||||||
|
args: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).executeToolCall(fakeBridge, "call-1", "bridge-call-1", "unknown_tool", {});
|
||||||
|
|
||||||
|
expect(fakeSubmit).toHaveBeenCalledWith("bridge-call-1", {
|
||||||
|
error: 'Tool "unknown_tool" not available',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error result when a handler throws", async () => {
|
||||||
|
const handler = new RealtimeCallHandler(
|
||||||
|
baseRealtimeConfig,
|
||||||
|
makeManager(),
|
||||||
|
makeProvider(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
handler.registerToolHandler("boom", async () => {
|
||||||
|
throw new Error("handler blew up");
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeSubmit = vi.fn();
|
||||||
|
const fakeBridge = { submitToolResult: fakeSubmit } as never;
|
||||||
|
|
||||||
|
await (
|
||||||
|
handler as unknown as {
|
||||||
|
executeToolCall: (
|
||||||
|
bridge: never,
|
||||||
|
callId: string,
|
||||||
|
bridgeCallId: string,
|
||||||
|
name: string,
|
||||||
|
args: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).executeToolCall(fakeBridge, "call-1", "bridge-call-1", "boom", {});
|
||||||
|
|
||||||
|
expect(fakeSubmit).toHaveBeenCalledWith("bridge-call-1", { error: "handler blew up" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user