voice-call: add tests for realtime config schema and handler

This commit is contained in:
Forrest Blount 2026-03-11 19:56:57 +00:00
parent 3fd5f44d05
commit 7429bca0cd
2 changed files with 387 additions and 0 deletions

View File

@ -3,6 +3,7 @@ import {
validateProviderConfig,
normalizeVoiceCallConfig,
resolveVoiceCallConfig,
VoiceCallRealtimeConfigSchema,
type VoiceCallConfig,
} from "./config.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
@ -216,3 +217,115 @@ describe("normalizeVoiceCallConfig", () => {
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);
});
});

View 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" });
});
});
});