@openclaw/voice-call 2026.2.15 → 2026.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
+ import { VoiceCallConfigSchema } from "./config.js";
5
+ import { CallManager } from "./manager.js";
4
6
  import type { VoiceCallProvider } from "./providers/base.js";
5
7
  import type {
6
8
  HangupCallInput,
@@ -13,13 +15,13 @@ import type {
13
15
  WebhookContext,
14
16
  WebhookVerificationResult,
15
17
  } from "./types.js";
16
- import { VoiceCallConfigSchema } from "./config.js";
17
- import { CallManager } from "./manager.js";
18
18
 
19
19
  class FakeProvider implements VoiceCallProvider {
20
20
  readonly name = "plivo" as const;
21
21
  readonly playTtsCalls: PlayTtsInput[] = [];
22
22
  readonly hangupCalls: HangupCallInput[] = [];
23
+ readonly startListeningCalls: StartListeningInput[] = [];
24
+ readonly stopListeningCalls: StopListeningInput[] = [];
23
25
 
24
26
  verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
25
27
  return { ok: true };
@@ -36,8 +38,12 @@ class FakeProvider implements VoiceCallProvider {
36
38
  async playTts(input: PlayTtsInput): Promise<void> {
37
39
  this.playTtsCalls.push(input);
38
40
  }
39
- async startListening(_input: StartListeningInput): Promise<void> {}
40
- async stopListening(_input: StopListeningInput): Promise<void> {}
41
+ async startListening(input: StartListeningInput): Promise<void> {
42
+ this.startListeningCalls.push(input);
43
+ }
44
+ async stopListening(input: StopListeningInput): Promise<void> {
45
+ this.stopListeningCalls.push(input);
46
+ }
41
47
  }
42
48
 
43
49
  describe("CallManager", () => {
@@ -261,4 +267,219 @@ describe("CallManager", () => {
261
267
 
262
268
  expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
263
269
  });
270
+
271
+ it("completes a closed-loop turn without live audio", async () => {
272
+ const config = VoiceCallConfigSchema.parse({
273
+ enabled: true,
274
+ provider: "plivo",
275
+ fromNumber: "+15550000000",
276
+ transcriptTimeoutMs: 5000,
277
+ });
278
+
279
+ const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
280
+ const provider = new FakeProvider();
281
+ const manager = new CallManager(config, storePath);
282
+ manager.initialize(provider, "https://example.com/voice/webhook");
283
+
284
+ const started = await manager.initiateCall("+15550000003");
285
+ expect(started.success).toBe(true);
286
+
287
+ manager.processEvent({
288
+ id: "evt-closed-loop-answered",
289
+ type: "call.answered",
290
+ callId: started.callId,
291
+ providerCallId: "request-uuid",
292
+ timestamp: Date.now(),
293
+ });
294
+
295
+ const turnPromise = manager.continueCall(started.callId, "How can I help?");
296
+ await new Promise((resolve) => setTimeout(resolve, 0));
297
+
298
+ manager.processEvent({
299
+ id: "evt-closed-loop-speech",
300
+ type: "call.speech",
301
+ callId: started.callId,
302
+ providerCallId: "request-uuid",
303
+ timestamp: Date.now(),
304
+ transcript: "Please check status",
305
+ isFinal: true,
306
+ });
307
+
308
+ const turn = await turnPromise;
309
+ expect(turn.success).toBe(true);
310
+ expect(turn.transcript).toBe("Please check status");
311
+ expect(provider.startListeningCalls).toHaveLength(1);
312
+ expect(provider.stopListeningCalls).toHaveLength(1);
313
+
314
+ const call = manager.getCall(started.callId);
315
+ expect(call?.transcript.map((entry) => entry.text)).toEqual([
316
+ "How can I help?",
317
+ "Please check status",
318
+ ]);
319
+ const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
320
+ expect(typeof metadata.lastTurnLatencyMs).toBe("number");
321
+ expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
322
+ expect(metadata.turnCount).toBe(1);
323
+ });
324
+
325
+ it("rejects overlapping continueCall requests for the same call", async () => {
326
+ const config = VoiceCallConfigSchema.parse({
327
+ enabled: true,
328
+ provider: "plivo",
329
+ fromNumber: "+15550000000",
330
+ transcriptTimeoutMs: 5000,
331
+ });
332
+
333
+ const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
334
+ const provider = new FakeProvider();
335
+ const manager = new CallManager(config, storePath);
336
+ manager.initialize(provider, "https://example.com/voice/webhook");
337
+
338
+ const started = await manager.initiateCall("+15550000004");
339
+ expect(started.success).toBe(true);
340
+
341
+ manager.processEvent({
342
+ id: "evt-overlap-answered",
343
+ type: "call.answered",
344
+ callId: started.callId,
345
+ providerCallId: "request-uuid",
346
+ timestamp: Date.now(),
347
+ });
348
+
349
+ const first = manager.continueCall(started.callId, "First prompt");
350
+ const second = await manager.continueCall(started.callId, "Second prompt");
351
+ expect(second.success).toBe(false);
352
+ expect(second.error).toBe("Already waiting for transcript");
353
+
354
+ manager.processEvent({
355
+ id: "evt-overlap-speech",
356
+ type: "call.speech",
357
+ callId: started.callId,
358
+ providerCallId: "request-uuid",
359
+ timestamp: Date.now(),
360
+ transcript: "Done",
361
+ isFinal: true,
362
+ });
363
+
364
+ const firstResult = await first;
365
+ expect(firstResult.success).toBe(true);
366
+ expect(firstResult.transcript).toBe("Done");
367
+ expect(provider.startListeningCalls).toHaveLength(1);
368
+ expect(provider.stopListeningCalls).toHaveLength(1);
369
+ });
370
+
371
+ it("tracks latency metadata across multiple closed-loop turns", async () => {
372
+ const config = VoiceCallConfigSchema.parse({
373
+ enabled: true,
374
+ provider: "plivo",
375
+ fromNumber: "+15550000000",
376
+ transcriptTimeoutMs: 5000,
377
+ });
378
+
379
+ const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
380
+ const provider = new FakeProvider();
381
+ const manager = new CallManager(config, storePath);
382
+ manager.initialize(provider, "https://example.com/voice/webhook");
383
+
384
+ const started = await manager.initiateCall("+15550000005");
385
+ expect(started.success).toBe(true);
386
+
387
+ manager.processEvent({
388
+ id: "evt-multi-answered",
389
+ type: "call.answered",
390
+ callId: started.callId,
391
+ providerCallId: "request-uuid",
392
+ timestamp: Date.now(),
393
+ });
394
+
395
+ const firstTurn = manager.continueCall(started.callId, "First question");
396
+ await new Promise((resolve) => setTimeout(resolve, 0));
397
+ manager.processEvent({
398
+ id: "evt-multi-speech-1",
399
+ type: "call.speech",
400
+ callId: started.callId,
401
+ providerCallId: "request-uuid",
402
+ timestamp: Date.now(),
403
+ transcript: "First answer",
404
+ isFinal: true,
405
+ });
406
+ await firstTurn;
407
+
408
+ const secondTurn = manager.continueCall(started.callId, "Second question");
409
+ await new Promise((resolve) => setTimeout(resolve, 0));
410
+ manager.processEvent({
411
+ id: "evt-multi-speech-2",
412
+ type: "call.speech",
413
+ callId: started.callId,
414
+ providerCallId: "request-uuid",
415
+ timestamp: Date.now(),
416
+ transcript: "Second answer",
417
+ isFinal: true,
418
+ });
419
+ const secondResult = await secondTurn;
420
+
421
+ expect(secondResult.success).toBe(true);
422
+
423
+ const call = manager.getCall(started.callId);
424
+ expect(call?.transcript.map((entry) => entry.text)).toEqual([
425
+ "First question",
426
+ "First answer",
427
+ "Second question",
428
+ "Second answer",
429
+ ]);
430
+ const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
431
+ expect(metadata.turnCount).toBe(2);
432
+ expect(typeof metadata.lastTurnLatencyMs).toBe("number");
433
+ expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
434
+ expect(provider.startListeningCalls).toHaveLength(2);
435
+ expect(provider.stopListeningCalls).toHaveLength(2);
436
+ });
437
+
438
+ it("handles repeated closed-loop turns without waiter churn", async () => {
439
+ const config = VoiceCallConfigSchema.parse({
440
+ enabled: true,
441
+ provider: "plivo",
442
+ fromNumber: "+15550000000",
443
+ transcriptTimeoutMs: 5000,
444
+ });
445
+
446
+ const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
447
+ const provider = new FakeProvider();
448
+ const manager = new CallManager(config, storePath);
449
+ manager.initialize(provider, "https://example.com/voice/webhook");
450
+
451
+ const started = await manager.initiateCall("+15550000006");
452
+ expect(started.success).toBe(true);
453
+
454
+ manager.processEvent({
455
+ id: "evt-loop-answered",
456
+ type: "call.answered",
457
+ callId: started.callId,
458
+ providerCallId: "request-uuid",
459
+ timestamp: Date.now(),
460
+ });
461
+
462
+ for (let i = 1; i <= 5; i++) {
463
+ const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
464
+ await new Promise((resolve) => setTimeout(resolve, 0));
465
+ manager.processEvent({
466
+ id: `evt-loop-speech-${i}`,
467
+ type: "call.speech",
468
+ callId: started.callId,
469
+ providerCallId: "request-uuid",
470
+ timestamp: Date.now(),
471
+ transcript: `Answer ${i}`,
472
+ isFinal: true,
473
+ });
474
+ const result = await turnPromise;
475
+ expect(result.success).toBe(true);
476
+ expect(result.transcript).toBe(`Answer ${i}`);
477
+ }
478
+
479
+ const call = manager.getCall(started.callId);
480
+ const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
481
+ expect(metadata.turnCount).toBe(5);
482
+ expect(provider.startListeningCalls).toHaveLength(5);
483
+ expect(provider.stopListeningCalls).toHaveLength(5);
484
+ });
264
485
  });
package/src/manager.ts CHANGED
@@ -3,8 +3,6 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { VoiceCallConfig } from "./config.js";
5
5
  import type { CallManagerContext } from "./manager/context.js";
6
- import type { VoiceCallProvider } from "./providers/base.js";
7
- import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js";
8
6
  import { processEvent as processManagerEvent } from "./manager/events.js";
9
7
  import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
10
8
  import {
@@ -15,6 +13,8 @@ import {
15
13
  speakInitialMessage as speakInitialMessageWithContext,
16
14
  } from "./manager/outbound.js";
17
15
  import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
16
+ import type { VoiceCallProvider } from "./providers/base.js";
17
+ import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js";
18
18
  import { resolveUserPath } from "./utils.js";
19
19
 
20
20
  function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
@@ -47,6 +47,7 @@ export class CallManager {
47
47
  private config: VoiceCallConfig;
48
48
  private storePath: string;
49
49
  private webhookUrl: string | null = null;
50
+ private activeTurnCalls = new Set<CallId>();
50
51
  private transcriptWaiters = new Map<
51
52
  CallId,
52
53
  {
@@ -137,6 +138,7 @@ export class CallManager {
137
138
  config: this.config,
138
139
  storePath: this.storePath,
139
140
  webhookUrl: this.webhookUrl,
141
+ activeTurnCalls: this.activeTurnCalls,
140
142
  transcriptWaiters: this.transcriptWaiters,
141
143
  maxDurationTimers: this.maxDurationTimers,
142
144
  onCallAnswered: (call) => {
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { MediaStreamHandler } from "./media-stream.js";
2
3
  import type {
3
4
  OpenAIRealtimeSTTProvider,
4
5
  RealtimeSTTSession,
5
6
  } from "./providers/stt-openai-realtime.js";
6
- import { MediaStreamHandler } from "./media-stream.js";
7
7
 
8
8
  const createStubSession = (): RealtimeSTTSession => ({
9
9
  connect: async () => {},
@@ -12,9 +12,9 @@ import type {
12
12
  WebhookContext,
13
13
  WebhookVerificationResult,
14
14
  } from "../types.js";
15
- import type { VoiceCallProvider } from "./base.js";
16
15
  import { escapeXml } from "../voice-mapping.js";
17
16
  import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
17
+ import type { VoiceCallProvider } from "./base.js";
18
18
 
19
19
  export interface PlivoProviderOptions {
20
20
  /** Override public URL origin for signature verification */
@@ -22,6 +22,37 @@ function decodeBase64Url(input: string): Buffer {
22
22
  return Buffer.from(padded, "base64");
23
23
  }
24
24
 
25
+ function expectWebhookVerificationSucceeds(params: {
26
+ publicKey: string;
27
+ privateKey: crypto.KeyObject;
28
+ }) {
29
+ const provider = new TelnyxProvider(
30
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: params.publicKey },
31
+ { skipVerification: false },
32
+ );
33
+
34
+ const rawBody = JSON.stringify({
35
+ event_type: "call.initiated",
36
+ payload: { call_control_id: "x" },
37
+ });
38
+ const timestamp = String(Math.floor(Date.now() / 1000));
39
+ const signedPayload = `${timestamp}|${rawBody}`;
40
+ const signature = crypto
41
+ .sign(null, Buffer.from(signedPayload), params.privateKey)
42
+ .toString("base64");
43
+
44
+ const result = provider.verifyWebhook(
45
+ createCtx({
46
+ rawBody,
47
+ headers: {
48
+ "telnyx-signature-ed25519": signature,
49
+ "telnyx-timestamp": timestamp,
50
+ },
51
+ }),
52
+ );
53
+ expect(result.ok).toBe(true);
54
+ }
55
+
25
56
  describe("TelnyxProvider.verifyWebhook", () => {
26
57
  it("fails closed when public key is missing and skipVerification is false", () => {
27
58
  const provider = new TelnyxProvider(
@@ -63,59 +94,13 @@ describe("TelnyxProvider.verifyWebhook", () => {
63
94
 
64
95
  const rawPublicKey = decodeBase64Url(jwk.x as string);
65
96
  const rawPublicKeyBase64 = rawPublicKey.toString("base64");
66
-
67
- const provider = new TelnyxProvider(
68
- { apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
69
- { skipVerification: false },
70
- );
71
-
72
- const rawBody = JSON.stringify({
73
- event_type: "call.initiated",
74
- payload: { call_control_id: "x" },
75
- });
76
- const timestamp = String(Math.floor(Date.now() / 1000));
77
- const signedPayload = `${timestamp}|${rawBody}`;
78
- const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
79
-
80
- const result = provider.verifyWebhook(
81
- createCtx({
82
- rawBody,
83
- headers: {
84
- "telnyx-signature-ed25519": signature,
85
- "telnyx-timestamp": timestamp,
86
- },
87
- }),
88
- );
89
- expect(result.ok).toBe(true);
97
+ expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
90
98
  });
91
99
 
92
100
  it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
93
101
  const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
94
102
  const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
95
103
  const spkiDerBase64 = spkiDer.toString("base64");
96
-
97
- const provider = new TelnyxProvider(
98
- { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
99
- { skipVerification: false },
100
- );
101
-
102
- const rawBody = JSON.stringify({
103
- event_type: "call.initiated",
104
- payload: { call_control_id: "x" },
105
- });
106
- const timestamp = String(Math.floor(Date.now() / 1000));
107
- const signedPayload = `${timestamp}|${rawBody}`;
108
- const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
109
-
110
- const result = provider.verifyWebhook(
111
- createCtx({
112
- rawBody,
113
- headers: {
114
- "telnyx-signature-ed25519": signature,
115
- "telnyx-timestamp": timestamp,
116
- },
117
- }),
118
- );
119
- expect(result.ok).toBe(true);
104
+ expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
120
105
  });
121
106
  });
@@ -13,8 +13,8 @@ import type {
13
13
  WebhookContext,
14
14
  WebhookVerificationResult,
15
15
  } from "../types.js";
16
- import type { VoiceCallProvider } from "./base.js";
17
16
  import { verifyTelnyxWebhook } from "../webhook-security.js";
17
+ import type { VoiceCallProvider } from "./base.js";
18
18
 
19
19
  /**
20
20
  * Telnyx Voice API provider implementation.
@@ -1,6 +1,6 @@
1
1
  import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
2
- import type { TwilioProviderOptions } from "../twilio.js";
3
2
  import { verifyTwilioWebhook } from "../../webhook-security.js";
3
+ import type { TwilioProviderOptions } from "../twilio.js";
4
4
 
5
5
  export function verifyTwilioProviderWebhook(params: {
6
6
  ctx: WebhookContext;
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
3
3
  import type { MediaStreamHandler } from "../media-stream.js";
4
+ import { chunkAudio } from "../telephony-audio.js";
4
5
  import type { TelephonyTtsProvider } from "../telephony-tts.js";
5
6
  import type {
6
7
  HangupCallInput,
@@ -14,9 +15,8 @@ import type {
14
15
  WebhookContext,
15
16
  WebhookVerificationResult,
16
17
  } from "../types.js";
17
- import type { VoiceCallProvider } from "./base.js";
18
- import { chunkAudio } from "../telephony-audio.js";
19
18
  import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
19
+ import type { VoiceCallProvider } from "./base.js";
20
20
  import { twilioApiRequest } from "./twilio/api.js";
21
21
  import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
22
22
 
package/src/runtime.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import type { VoiceCallConfig } from "./config.js";
2
- import type { CoreConfig } from "./core-bridge.js";
3
- import type { VoiceCallProvider } from "./providers/base.js";
4
- import type { TelephonyTtsRuntime } from "./telephony-tts.js";
5
2
  import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js";
3
+ import type { CoreConfig } from "./core-bridge.js";
6
4
  import { CallManager } from "./manager.js";
5
+ import type { VoiceCallProvider } from "./providers/base.js";
7
6
  import { MockProvider } from "./providers/mock.js";
8
7
  import { PlivoProvider } from "./providers/plivo.js";
9
8
  import { TelnyxProvider } from "./providers/telnyx.js";
10
9
  import { TwilioProvider } from "./providers/twilio.js";
10
+ import type { TelephonyTtsRuntime } from "./telephony-tts.js";
11
11
  import { createTelephonyTtsProvider } from "./telephony-tts.js";
12
12
  import { startTunnel, type TunnelResult } from "./tunnel.js";
13
13
  import {
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import type { VoiceCallTtsConfig } from "./config.js";
3
+ import type { CoreConfig } from "./core-bridge.js";
4
+ import { createTelephonyTtsProvider } from "./telephony-tts.js";
5
+
6
+ function createCoreConfig(): CoreConfig {
7
+ const tts: VoiceCallTtsConfig = {
8
+ provider: "openai",
9
+ openai: {
10
+ model: "gpt-4o-mini-tts",
11
+ voice: "alloy",
12
+ },
13
+ };
14
+ return { messages: { tts } };
15
+ }
16
+
17
+ async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
18
+ let mergedConfig: CoreConfig | undefined;
19
+ const provider = createTelephonyTtsProvider({
20
+ coreConfig: createCoreConfig(),
21
+ ttsOverride: override as VoiceCallTtsConfig,
22
+ runtime: {
23
+ textToSpeechTelephony: async ({ cfg }) => {
24
+ mergedConfig = cfg;
25
+ return {
26
+ success: true,
27
+ audioBuffer: Buffer.alloc(2),
28
+ sampleRate: 8000,
29
+ };
30
+ },
31
+ },
32
+ });
33
+
34
+ await provider.synthesizeForTelephony("hello");
35
+ expect(mergedConfig?.messages?.tts).toBeDefined();
36
+ return mergedConfig?.messages?.tts as Record<string, unknown>;
37
+ }
38
+
39
+ afterEach(() => {
40
+ delete (Object.prototype as Record<string, unknown>).polluted;
41
+ });
42
+
43
+ describe("createTelephonyTtsProvider deepMerge hardening", () => {
44
+ it("merges safe nested overrides", async () => {
45
+ const tts = await mergeOverride({
46
+ openai: { voice: "coral" },
47
+ });
48
+ const openai = tts.openai as Record<string, unknown>;
49
+
50
+ expect(openai.voice).toBe("coral");
51
+ expect(openai.model).toBe("gpt-4o-mini-tts");
52
+ });
53
+
54
+ it("blocks top-level __proto__ keys", async () => {
55
+ const tts = await mergeOverride(
56
+ JSON.parse('{"__proto__":{"polluted":"top"},"openai":{"voice":"coral"}}'),
57
+ );
58
+ const openai = tts.openai as Record<string, unknown>;
59
+
60
+ expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
61
+ expect(tts.polluted).toBeUndefined();
62
+ expect(openai.voice).toBe("coral");
63
+ });
64
+
65
+ it("blocks nested __proto__ keys", async () => {
66
+ const tts = await mergeOverride(
67
+ JSON.parse('{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}'),
68
+ );
69
+ const openai = tts.openai as Record<string, unknown>;
70
+
71
+ expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
72
+ expect(openai.polluted).toBeUndefined();
73
+ expect(openai.model).toBe("safe");
74
+ });
75
+ });
@@ -20,6 +20,8 @@ export type TelephonyTtsProvider = {
20
20
  synthesizeForTelephony: (text: string) => Promise<Buffer>;
21
21
  };
22
22
 
23
+ const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
24
+
23
25
  export function createTelephonyTtsProvider(params: {
24
26
  coreConfig: CoreConfig;
25
27
  ttsOverride?: VoiceCallTtsConfig;
@@ -86,7 +88,7 @@ function deepMerge<T>(base: T, override: T): T {
86
88
  }
87
89
  const result: Record<string, unknown> = { ...base };
88
90
  for (const [key, value] of Object.entries(override)) {
89
- if (value === undefined) {
91
+ if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) {
90
92
  continue;
91
93
  }
92
94
  const existing = (base as Record<string, unknown>)[key];
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js";
3
+ import type { CallManager } from "./manager.js";
4
+ import type { VoiceCallProvider } from "./providers/base.js";
5
+ import type { CallRecord } from "./types.js";
6
+ import { VoiceCallWebhookServer } from "./webhook.js";
7
+
8
+ const provider: VoiceCallProvider = {
9
+ name: "mock",
10
+ verifyWebhook: () => ({ ok: true }),
11
+ parseWebhookEvent: () => ({ events: [] }),
12
+ initiateCall: async () => ({ providerCallId: "provider-call", status: "initiated" }),
13
+ hangupCall: async () => {},
14
+ playTts: async () => {},
15
+ startListening: async () => {},
16
+ stopListening: async () => {},
17
+ };
18
+
19
+ const createConfig = (overrides: Partial<VoiceCallConfig> = {}): VoiceCallConfig => {
20
+ const base = VoiceCallConfigSchema.parse({});
21
+ base.serve.port = 0;
22
+
23
+ return {
24
+ ...base,
25
+ ...overrides,
26
+ serve: {
27
+ ...base.serve,
28
+ ...(overrides.serve ?? {}),
29
+ },
30
+ };
31
+ };
32
+
33
+ const createCall = (startedAt: number): CallRecord => ({
34
+ callId: "call-1",
35
+ providerCallId: "provider-call-1",
36
+ provider: "mock",
37
+ direction: "outbound",
38
+ state: "initiated",
39
+ from: "+15550001234",
40
+ to: "+15550005678",
41
+ startedAt,
42
+ transcript: [],
43
+ processedEventIds: [],
44
+ });
45
+
46
+ const createManager = (calls: CallRecord[]) => {
47
+ const endCall = vi.fn(async () => ({ success: true }));
48
+ const manager = {
49
+ getActiveCalls: () => calls,
50
+ endCall,
51
+ } as unknown as CallManager;
52
+
53
+ return { manager, endCall };
54
+ };
55
+
56
+ describe("VoiceCallWebhookServer stale call reaper", () => {
57
+ beforeEach(() => {
58
+ vi.useFakeTimers();
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.useRealTimers();
63
+ });
64
+
65
+ it("ends calls older than staleCallReaperSeconds", async () => {
66
+ const now = new Date("2026-02-16T00:00:00Z");
67
+ vi.setSystemTime(now);
68
+
69
+ const call = createCall(now.getTime() - 120_000);
70
+ const { manager, endCall } = createManager([call]);
71
+ const config = createConfig({ staleCallReaperSeconds: 60 });
72
+ const server = new VoiceCallWebhookServer(config, manager, provider);
73
+
74
+ try {
75
+ await server.start();
76
+ await vi.advanceTimersByTimeAsync(30_000);
77
+ expect(endCall).toHaveBeenCalledWith(call.callId);
78
+ } finally {
79
+ await server.stop();
80
+ }
81
+ });
82
+
83
+ it("skips calls that are younger than the threshold", async () => {
84
+ const now = new Date("2026-02-16T00:00:00Z");
85
+ vi.setSystemTime(now);
86
+
87
+ const call = createCall(now.getTime() - 10_000);
88
+ const { manager, endCall } = createManager([call]);
89
+ const config = createConfig({ staleCallReaperSeconds: 60 });
90
+ const server = new VoiceCallWebhookServer(config, manager, provider);
91
+
92
+ try {
93
+ await server.start();
94
+ await vi.advanceTimersByTimeAsync(30_000);
95
+ expect(endCall).not.toHaveBeenCalled();
96
+ } finally {
97
+ await server.stop();
98
+ }
99
+ });
100
+
101
+ it("does not run when staleCallReaperSeconds is disabled", async () => {
102
+ const now = new Date("2026-02-16T00:00:00Z");
103
+ vi.setSystemTime(now);
104
+
105
+ const call = createCall(now.getTime() - 120_000);
106
+ const { manager, endCall } = createManager([call]);
107
+ const config = createConfig({ staleCallReaperSeconds: 0 });
108
+ const server = new VoiceCallWebhookServer(config, manager, provider);
109
+
110
+ try {
111
+ await server.start();
112
+ await vi.advanceTimersByTimeAsync(60_000);
113
+ expect(endCall).not.toHaveBeenCalled();
114
+ } finally {
115
+ await server.stop();
116
+ }
117
+ });
118
+ });