@openclaw/voice-call 2026.3.13 → 2026.5.1-beta.1

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.
Files changed (103) hide show
  1. package/README.md +25 -5
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +866 -0
  6. package/index.ts +353 -148
  7. package/openclaw.plugin.json +336 -157
  8. package/package.json +33 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +160 -12
  17. package/src/config.ts +243 -74
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +179 -19
  24. package/src/manager/events.ts +48 -30
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +464 -0
  28. package/src/manager/outbound.ts +148 -55
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +95 -8
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +178 -6
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +425 -25
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +277 -0
  64. package/src/response-generator.ts +186 -40
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +351 -0
  68. package/src/runtime.ts +254 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +26 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +172 -21
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1145 -27
  94. package/src/webhook.ts +513 -100
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -121
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deepMergeDefined } from "./deep-merge.js";
3
+
4
+ describe("deepMergeDefined", () => {
5
+ it("deep merges nested plain objects and preserves base values for undefined overrides", () => {
6
+ expect(
7
+ deepMergeDefined(
8
+ {
9
+ provider: { voice: "alloy", language: "en" },
10
+ enabled: true,
11
+ },
12
+ {
13
+ provider: { voice: "echo", language: undefined },
14
+ enabled: undefined,
15
+ },
16
+ ),
17
+ ).toEqual({
18
+ provider: { voice: "echo", language: "en" },
19
+ enabled: true,
20
+ });
21
+ });
22
+
23
+ it("replaces non-objects directly and blocks dangerous prototype keys", () => {
24
+ expect(deepMergeDefined(["a"], ["b"])).toEqual(["b"]);
25
+ expect(deepMergeDefined("base", undefined)).toBe("base");
26
+ expect(
27
+ deepMergeDefined(
28
+ { safe: { keep: true } },
29
+ {
30
+ safe: { next: true },
31
+ __proto__: { polluted: true },
32
+ constructor: { polluted: true },
33
+ prototype: { polluted: true },
34
+ },
35
+ ),
36
+ ).toEqual({
37
+ safe: { keep: true, next: true },
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import type { VoiceCallConfig } from "./config.js";
4
+ import type { CoreConfig } from "./core-bridge.js";
5
+ import type { VoiceCallRuntime } from "./runtime.js";
6
+ import { TELEPHONY_DEFAULT_TTS_TIMEOUT_MS } from "./telephony-tts.js";
7
+
8
+ const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 30000;
9
+ const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 5 * 60 * 1000;
10
+
11
+ type VoiceCallContinueOperation =
12
+ | {
13
+ operationId: string;
14
+ status: "pending";
15
+ callId: string;
16
+ startedAtMs: number;
17
+ pollTimeoutMs: number;
18
+ }
19
+ | {
20
+ operationId: string;
21
+ status: "completed";
22
+ callId: string;
23
+ startedAtMs: number;
24
+ completedAtMs: number;
25
+ pollTimeoutMs: number;
26
+ result: { success: true; transcript?: string };
27
+ }
28
+ | {
29
+ operationId: string;
30
+ status: "failed";
31
+ callId: string;
32
+ startedAtMs: number;
33
+ completedAtMs: number;
34
+ pollTimeoutMs: number;
35
+ error: string;
36
+ };
37
+
38
+ type VoiceCallContinueOperationStartPayload = {
39
+ operationId: string;
40
+ status: "pending";
41
+ pollTimeoutMs: number;
42
+ };
43
+
44
+ type VoiceCallContinueOperationResultPayload =
45
+ | {
46
+ operationId: string;
47
+ status: "pending";
48
+ pollTimeoutMs: number;
49
+ }
50
+ | {
51
+ operationId: string;
52
+ status: "completed";
53
+ result: { success: true; transcript?: string };
54
+ }
55
+ | {
56
+ operationId: string;
57
+ status: "failed";
58
+ error: string;
59
+ };
60
+
61
+ type VoiceCallContinueOperationRequest = {
62
+ rt: VoiceCallRuntime;
63
+ callId: string;
64
+ message: string;
65
+ };
66
+
67
+ export function createVoiceCallContinueOperationStore(params: {
68
+ config: VoiceCallConfig;
69
+ coreConfig: CoreConfig;
70
+ }) {
71
+ const operations = new Map<string, VoiceCallContinueOperation>();
72
+
73
+ const resolvePollTimeoutMs = (rt: VoiceCallRuntime): number => {
74
+ const ttsTimeoutMs =
75
+ rt.config.tts?.timeoutMs ??
76
+ params.config.tts?.timeoutMs ??
77
+ params.coreConfig.messages?.tts?.timeoutMs ??
78
+ TELEPHONY_DEFAULT_TTS_TIMEOUT_MS;
79
+ return (
80
+ (rt.config.transcriptTimeoutMs ?? params.config.transcriptTimeoutMs) +
81
+ ttsTimeoutMs +
82
+ VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS
83
+ );
84
+ };
85
+
86
+ const scheduleCleanup = (operationId: string) => {
87
+ const timer = setTimeout(() => {
88
+ operations.delete(operationId);
89
+ }, VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS);
90
+ timer.unref?.();
91
+ };
92
+
93
+ const start = (
94
+ request: VoiceCallContinueOperationRequest,
95
+ ): VoiceCallContinueOperationStartPayload => {
96
+ const operationId = randomUUID();
97
+ const startedAtMs = Date.now();
98
+ const pollTimeoutMs = resolvePollTimeoutMs(request.rt);
99
+ operations.set(operationId, {
100
+ operationId,
101
+ status: "pending",
102
+ callId: request.callId,
103
+ startedAtMs,
104
+ pollTimeoutMs,
105
+ });
106
+
107
+ void request.rt.manager
108
+ .continueCall(request.callId, request.message)
109
+ .then((result) => {
110
+ const current = operations.get(operationId);
111
+ if (!current || current.status !== "pending") {
112
+ return;
113
+ }
114
+ if (!result.success) {
115
+ operations.set(operationId, {
116
+ operationId,
117
+ status: "failed",
118
+ callId: request.callId,
119
+ startedAtMs,
120
+ completedAtMs: Date.now(),
121
+ pollTimeoutMs,
122
+ error: result.error || "continue failed",
123
+ });
124
+ return;
125
+ }
126
+ operations.set(operationId, {
127
+ operationId,
128
+ status: "completed",
129
+ callId: request.callId,
130
+ startedAtMs,
131
+ completedAtMs: Date.now(),
132
+ pollTimeoutMs,
133
+ result: { success: true, transcript: result.transcript },
134
+ });
135
+ })
136
+ .catch((err) => {
137
+ const current = operations.get(operationId);
138
+ if (!current || current.status !== "pending") {
139
+ return;
140
+ }
141
+ operations.set(operationId, {
142
+ operationId,
143
+ status: "failed",
144
+ callId: request.callId,
145
+ startedAtMs,
146
+ completedAtMs: Date.now(),
147
+ pollTimeoutMs,
148
+ error: formatErrorMessage(err),
149
+ });
150
+ })
151
+ .finally(() => {
152
+ scheduleCleanup(operationId);
153
+ });
154
+
155
+ return { operationId, status: "pending", pollTimeoutMs };
156
+ };
157
+
158
+ const read = (
159
+ operationId: string,
160
+ ):
161
+ | { ok: true; payload: VoiceCallContinueOperationResultPayload }
162
+ | { ok: false; error: string } => {
163
+ const operation = operations.get(operationId);
164
+ if (!operation) {
165
+ return { ok: false, error: "operation not found" };
166
+ }
167
+ if (operation.status === "pending") {
168
+ return {
169
+ ok: true,
170
+ payload: {
171
+ operationId,
172
+ status: "pending",
173
+ pollTimeoutMs: operation.pollTimeoutMs,
174
+ },
175
+ };
176
+ }
177
+ if (operation.status === "failed") {
178
+ operations.delete(operationId);
179
+ return {
180
+ ok: true,
181
+ payload: {
182
+ operationId,
183
+ status: "failed",
184
+ error: operation.error,
185
+ },
186
+ };
187
+ }
188
+ operations.delete(operationId);
189
+ return {
190
+ ok: true,
191
+ payload: {
192
+ operationId,
193
+ status: "completed",
194
+ result: operation.result,
195
+ },
196
+ };
197
+ };
198
+
199
+ return { start, read };
200
+ }
@@ -1,10 +1,13 @@
1
- export type HttpHeaderMap = Record<string, string | string[] | undefined>;
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
+
3
+ type HttpHeaderMap = Record<string, string | string[] | undefined>;
2
4
 
3
5
  export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
4
- const target = name.toLowerCase();
6
+ const target = normalizeLowercaseStringOrEmpty(name);
5
7
  const direct = headers[target];
6
8
  const value =
7
- direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
9
+ direct ??
10
+ Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
8
11
  if (Array.isArray(value)) {
9
12
  return value[0];
10
13
  }
@@ -2,14 +2,14 @@ import type { VoiceCallConfig } from "../config.js";
2
2
  import type { VoiceCallProvider } from "../providers/base.js";
3
3
  import type { CallId, CallRecord } from "../types.js";
4
4
 
5
- export type TranscriptWaiter = {
5
+ type TranscriptWaiter = {
6
6
  resolve: (text: string) => void;
7
7
  reject: (err: Error) => void;
8
8
  timeout: NodeJS.Timeout;
9
9
  turnToken?: string;
10
10
  };
11
11
 
12
- export type CallManagerRuntimeState = {
12
+ type CallManagerRuntimeState = {
13
13
  activeCalls: Map<CallId, CallRecord>;
14
14
  providerCallIdMap: Map<string, CallId>;
15
15
  processedEventIds: Set<string>;
@@ -17,20 +17,21 @@ export type CallManagerRuntimeState = {
17
17
  rejectedProviderCallIds: Set<string>;
18
18
  };
19
19
 
20
- export type CallManagerRuntimeDeps = {
20
+ type CallManagerRuntimeDeps = {
21
21
  provider: VoiceCallProvider | null;
22
22
  config: VoiceCallConfig;
23
23
  storePath: string;
24
24
  webhookUrl: string | null;
25
25
  };
26
26
 
27
- export type CallManagerTransientState = {
27
+ type CallManagerTransientState = {
28
28
  activeTurnCalls: Set<CallId>;
29
29
  transcriptWaiters: Map<CallId, TranscriptWaiter>;
30
30
  maxDurationTimers: Map<CallId, NodeJS.Timeout>;
31
+ initialMessageInFlight: Set<CallId>;
31
32
  };
32
33
 
33
- export type CallManagerHooks = {
34
+ type CallManagerHooks = {
34
35
  /** Optional runtime hook invoked after an event transitions a call into answered state. */
35
36
  onCallAnswered?: (call: CallRecord) => void;
36
37
  };
@@ -1,17 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { describe, expect, it } from "vitest";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
5
  import { VoiceCallConfigSchema } from "../config.js";
6
6
  import type { VoiceCallProvider } from "../providers/base.js";
7
- import type { HangupCallInput, NormalizedEvent } from "../types.js";
7
+ import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js";
8
8
  import type { CallManagerContext } from "./context.js";
9
9
  import { processEvent } from "./events.js";
10
+ import { flushPendingCallRecordWritesForTest } from "./store.js";
11
+
12
+ const contexts: CallManagerContext[] = [];
13
+
14
+ afterEach(async () => {
15
+ for (const ctx of contexts.splice(0)) {
16
+ for (const timer of ctx.maxDurationTimers.values()) {
17
+ clearTimeout(timer);
18
+ }
19
+ ctx.maxDurationTimers.clear();
20
+ for (const waiter of ctx.transcriptWaiters.values()) {
21
+ clearTimeout(waiter.timeout);
22
+ }
23
+ ctx.transcriptWaiters.clear();
24
+ await flushPendingCallRecordWritesForTest();
25
+ fs.rmSync(ctx.storePath, { recursive: true, force: true });
26
+ }
27
+ });
10
28
 
11
29
  function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
12
- const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
13
- fs.mkdirSync(storePath, { recursive: true });
14
- return {
30
+ const storePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-events-test-"));
31
+ const ctx: CallManagerContext = {
15
32
  activeCalls: new Map(),
16
33
  providerCallIdMap: new Map(),
17
34
  processedEventIds: new Set(),
@@ -27,8 +44,11 @@ function createContext(overrides: Partial<CallManagerContext> = {}): CallManager
27
44
  activeTurnCalls: new Set(),
28
45
  transcriptWaiters: new Map(),
29
46
  maxDurationTimers: new Map(),
47
+ initialMessageInFlight: new Set(),
30
48
  ...overrides,
31
49
  };
50
+ contexts.push(ctx);
51
+ return ctx;
32
52
  }
33
53
 
34
54
  function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
@@ -89,6 +109,14 @@ function createRejectingInboundContext(): {
89
109
  return { ctx, hangupCalls };
90
110
  }
91
111
 
112
+ function requireFirstActiveCall(ctx: CallManagerContext) {
113
+ const call = [...ctx.activeCalls.values()][0];
114
+ if (!call) {
115
+ throw new Error("expected one active call");
116
+ }
117
+ return call;
118
+ }
119
+
92
120
  describe("processEvent (functional)", () => {
93
121
  it("calls provider hangup when rejecting inbound call", () => {
94
122
  const { ctx, hangupCalls } = createRejectingInboundContext();
@@ -147,8 +175,50 @@ describe("processEvent (functional)", () => {
147
175
  processEvent(ctx, event2);
148
176
 
149
177
  expect(ctx.activeCalls.size).toBe(0);
150
- expect(hangupCalls).toHaveLength(1);
151
- expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
178
+ expect(hangupCalls).toEqual([
179
+ expect.objectContaining({
180
+ providerCallId: "prov-dup",
181
+ reason: "hangup-bot",
182
+ }),
183
+ ]);
184
+ });
185
+
186
+ it("answers accepted inbound calls when the provider requires an answer command", () => {
187
+ const answerCalls: AnswerCallInput[] = [];
188
+ const provider = createProvider({
189
+ answerCall: async (input: AnswerCallInput): Promise<void> => {
190
+ answerCalls.push(input);
191
+ },
192
+ });
193
+ const ctx = createContext({
194
+ config: VoiceCallConfigSchema.parse({
195
+ enabled: true,
196
+ provider: "telnyx",
197
+ fromNumber: "+15550000000",
198
+ inboundPolicy: "open",
199
+ telnyx: {
200
+ apiKey: "KEY123",
201
+ connectionId: "CONN456",
202
+ },
203
+ skipSignatureVerification: true,
204
+ }),
205
+ provider,
206
+ });
207
+ const event = createInboundInitiatedEvent({
208
+ id: "evt-answer",
209
+ providerCallId: "call-control-1",
210
+ from: "+15552222222",
211
+ });
212
+
213
+ processEvent(ctx, event);
214
+
215
+ const call = requireFirstActiveCall(ctx);
216
+ expect(answerCalls).toEqual([
217
+ {
218
+ callId: call.callId,
219
+ providerCallId: "call-control-1",
220
+ },
221
+ ]);
152
222
  });
153
223
 
154
224
  it("updates providerCallId map when provider ID changes", () => {
@@ -177,11 +247,57 @@ describe("processEvent (functional)", () => {
177
247
  timestamp: now + 1,
178
248
  });
179
249
 
180
- expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
250
+ const activeCall = ctx.activeCalls.get("call-1");
251
+ if (!activeCall) {
252
+ throw new Error("expected active call after provider id change");
253
+ }
254
+ expect(activeCall.providerCallId).toBe("call-uuid");
181
255
  expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
182
256
  expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
183
257
  });
184
258
 
259
+ it("does not burn replay keys for unknown calls before a later replay can resolve them", () => {
260
+ const now = Date.now();
261
+ const ctx = createContext();
262
+ const event: NormalizedEvent = {
263
+ id: "evt-late-call",
264
+ dedupeKey: "stable-late-call",
265
+ type: "call.answered",
266
+ callId: "call-late",
267
+ providerCallId: "provider-late",
268
+ timestamp: now + 1,
269
+ };
270
+
271
+ processEvent(ctx, event);
272
+
273
+ expect(ctx.processedEventIds.size).toBe(0);
274
+
275
+ ctx.activeCalls.set("call-late", {
276
+ callId: "call-late",
277
+ providerCallId: "provider-late",
278
+ provider: "plivo",
279
+ direction: "inbound",
280
+ state: "ringing",
281
+ from: "+15550000002",
282
+ to: "+15550000000",
283
+ startedAt: now,
284
+ transcript: [],
285
+ processedEventIds: [],
286
+ metadata: {},
287
+ });
288
+ ctx.providerCallIdMap.set("provider-late", "call-late");
289
+
290
+ processEvent(ctx, event);
291
+
292
+ const call = ctx.activeCalls.get("call-late");
293
+ if (!call) {
294
+ throw new Error("expected replayed event to resolve after call registration");
295
+ }
296
+ expect(call.state).toBe("answered");
297
+ expect(call.answeredAt).toBe(now + 1);
298
+ expect(Array.from(ctx.processedEventIds)).toEqual(["stable-late-call"]);
299
+ });
300
+
185
301
  it("invokes onCallAnswered hook for answered events", () => {
186
302
  const now = Date.now();
187
303
  let answeredCallId: string | null = null;
@@ -253,12 +369,12 @@ describe("processEvent (functional)", () => {
253
369
 
254
370
  // Call should be registered in activeCalls and providerCallIdMap
255
371
  expect(ctx.activeCalls.size).toBe(1);
256
- expect(ctx.providerCallIdMap.get("CA-external-123")).toBeDefined();
257
- const call = [...ctx.activeCalls.values()][0];
258
- expect(call?.providerCallId).toBe("CA-external-123");
259
- expect(call?.direction).toBe("outbound");
260
- expect(call?.from).toBe("+15550000000");
261
- expect(call?.to).toBe("+15559876543");
372
+ const call = requireFirstActiveCall(ctx);
373
+ expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId);
374
+ expect(call.providerCallId).toBe("CA-external-123");
375
+ expect(call.direction).toBe("outbound");
376
+ expect(call.from).toBe("+15550000000");
377
+ expect(call.to).toBe("+15559876543");
262
378
  });
263
379
 
264
380
  it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
@@ -279,8 +395,8 @@ describe("processEvent (functional)", () => {
279
395
  // External outbound calls bypass inbound policy — they should be accepted
280
396
  expect(ctx.activeCalls.size).toBe(1);
281
397
  expect(hangupCalls).toHaveLength(0);
282
- const call = [...ctx.activeCalls.values()][0];
283
- expect(call?.direction).toBe("outbound");
398
+ const call = requireFirstActiveCall(ctx);
399
+ expect(call.direction).toBe("outbound");
284
400
  });
285
401
 
286
402
  it("preserves inbound direction for auto-registered inbound calls", () => {
@@ -306,8 +422,8 @@ describe("processEvent (functional)", () => {
306
422
  processEvent(ctx, event);
307
423
 
308
424
  expect(ctx.activeCalls.size).toBe(1);
309
- const call = [...ctx.activeCalls.values()][0];
310
- expect(call?.direction).toBe("inbound");
425
+ const call = requireFirstActiveCall(ctx);
426
+ expect(call.direction).toBe("inbound");
311
427
  });
312
428
 
313
429
  it("deduplicates by dedupeKey even when event IDs differ", () => {
@@ -351,7 +467,51 @@ describe("processEvent (functional)", () => {
351
467
  });
352
468
 
353
469
  const call = ctx.activeCalls.get("call-dedupe");
354
- expect(call?.transcript).toHaveLength(1);
470
+ if (!call) {
471
+ throw new Error("expected deduped call to remain active");
472
+ }
473
+ expect(call.transcript).toHaveLength(1);
355
474
  expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
356
475
  });
476
+
477
+ it("keeps retryable call.error events replayable", () => {
478
+ const now = Date.now();
479
+ const ctx = createContext();
480
+ ctx.activeCalls.set("call-retryable-error", {
481
+ callId: "call-retryable-error",
482
+ providerCallId: "provider-retryable-error",
483
+ provider: "plivo",
484
+ direction: "outbound",
485
+ state: "active",
486
+ from: "+15550000000",
487
+ to: "+15550000001",
488
+ startedAt: now,
489
+ transcript: [],
490
+ processedEventIds: [],
491
+ metadata: {},
492
+ });
493
+ ctx.providerCallIdMap.set("provider-retryable-error", "call-retryable-error");
494
+
495
+ const event: NormalizedEvent = {
496
+ id: "evt-retryable-error",
497
+ dedupeKey: "stable-retryable-error",
498
+ type: "call.error",
499
+ callId: "call-retryable-error",
500
+ providerCallId: "provider-retryable-error",
501
+ timestamp: now + 1,
502
+ error: "temporary upstream failure",
503
+ retryable: true,
504
+ };
505
+
506
+ processEvent(ctx, event);
507
+ processEvent(ctx, event);
508
+
509
+ const call = ctx.activeCalls.get("call-retryable-error");
510
+ if (!call) {
511
+ throw new Error("expected retryable error call to remain active");
512
+ }
513
+ expect(call.state).toBe("active");
514
+ expect(Array.from(ctx.processedEventIds)).toEqual([]);
515
+ expect(call.processedEventIds).toEqual([]);
516
+ });
357
517
  });