@openclaw/voice-call 2026.3.13 → 2026.5.2-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 +27 -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 +943 -0
  6. package/index.ts +379 -149
  7. package/openclaw.plugin.json +384 -157
  8. package/package.json +35 -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 +273 -12
  17. package/src/config.ts +355 -72
  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 +243 -19
  24. package/src/manager/events.ts +61 -31
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +528 -0
  28. package/src/manager/outbound.ts +163 -57
  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 +321 -0
  64. package/src/response-generator.ts +213 -53
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +429 -0
  68. package/src/runtime.ts +270 -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 +28 -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 +523 -102
  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
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { createManagerHarness } from "./manager.test-harness.js";
2
+ import { FakeProvider, createManagerHarness } from "./manager.test-harness.js";
3
3
 
4
4
  describe("CallManager inbound allowlist", () => {
5
5
  it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
@@ -19,8 +19,9 @@ describe("CallManager inbound allowlist", () => {
19
19
  });
20
20
 
21
21
  expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
22
- expect(provider.hangupCalls).toHaveLength(1);
23
- expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
22
+ expect(provider.hangupCalls).toEqual([
23
+ expect.objectContaining({ providerCallId: "provider-missing" }),
24
+ ]);
24
25
  });
25
26
 
26
27
  it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
@@ -41,8 +42,9 @@ describe("CallManager inbound allowlist", () => {
41
42
  });
42
43
 
43
44
  expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
44
- expect(provider.hangupCalls).toHaveLength(1);
45
- expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
45
+ expect(provider.hangupCalls).toEqual([
46
+ expect.objectContaining({ providerCallId: "provider-anon" }),
47
+ ]);
46
48
  });
47
49
 
48
50
  it("rejects inbound calls that only match allowlist suffixes", async () => {
@@ -63,8 +65,9 @@ describe("CallManager inbound allowlist", () => {
63
65
  });
64
66
 
65
67
  expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
66
- expect(provider.hangupCalls).toHaveLength(1);
67
- expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
68
+ expect(provider.hangupCalls).toEqual([
69
+ expect.objectContaining({ providerCallId: "provider-suffix" }),
70
+ ]);
68
71
  });
69
72
 
70
73
  it("rejects duplicate inbound events with a single hangup call", async () => {
@@ -95,8 +98,60 @@ describe("CallManager inbound allowlist", () => {
95
98
  });
96
99
 
97
100
  expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
98
- expect(provider.hangupCalls).toHaveLength(1);
99
- expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
101
+ expect(provider.hangupCalls).toEqual([
102
+ expect.objectContaining({ providerCallId: "provider-dup" }),
103
+ ]);
104
+ });
105
+
106
+ it("retries rejected inbound hangup after a transient provider failure", async () => {
107
+ class FlakyHangupProvider extends FakeProvider {
108
+ hangupFailuresRemaining = 1;
109
+
110
+ override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
111
+ this.hangupCalls.push(input);
112
+ if (this.hangupFailuresRemaining > 0) {
113
+ this.hangupFailuresRemaining -= 1;
114
+ throw new Error("provider down");
115
+ }
116
+ }
117
+ }
118
+
119
+ const provider = new FlakyHangupProvider();
120
+ const { manager } = await createManagerHarness(
121
+ {
122
+ inboundPolicy: "disabled",
123
+ },
124
+ provider,
125
+ );
126
+
127
+ manager.processEvent({
128
+ id: "evt-reject-fail-init",
129
+ type: "call.initiated",
130
+ callId: "provider-flaky",
131
+ providerCallId: "provider-flaky",
132
+ timestamp: Date.now(),
133
+ direction: "inbound",
134
+ from: "+15553333333",
135
+ to: "+15550000000",
136
+ });
137
+ await Promise.resolve();
138
+
139
+ manager.processEvent({
140
+ id: "evt-reject-fail-ring",
141
+ type: "call.ringing",
142
+ callId: "provider-flaky",
143
+ providerCallId: "provider-flaky",
144
+ timestamp: Date.now(),
145
+ direction: "inbound",
146
+ from: "+15553333333",
147
+ to: "+15550000000",
148
+ });
149
+
150
+ expect(manager.getCallByProviderCallId("provider-flaky")).toBeUndefined();
151
+ expect(provider.hangupCalls).toEqual([
152
+ expect.objectContaining({ providerCallId: "provider-flaky" }),
153
+ expect.objectContaining({ providerCallId: "provider-flaky" }),
154
+ ]);
100
155
  });
101
156
 
102
157
  it("accepts inbound calls that exactly match the allowlist", async () => {
@@ -116,6 +171,18 @@ describe("CallManager inbound allowlist", () => {
116
171
  to: "+15550000000",
117
172
  });
118
173
 
119
- expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
174
+ const call = manager.getCallByProviderCallId("provider-exact");
175
+ if (!call) {
176
+ throw new Error("expected exact allowlist match to keep the inbound call");
177
+ }
178
+ expect(call).toMatchObject({
179
+ providerCallId: "provider-exact",
180
+ direction: "inbound",
181
+ from: "+15550001234",
182
+ to: "+15550000000",
183
+ });
184
+ expect(call.callId).toMatch(
185
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
186
+ );
120
187
  });
121
188
  });
@@ -1,6 +1,119 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { createManagerHarness, FakeProvider } from "./manager.test-harness.js";
3
3
 
4
+ class FailFirstPlayTtsProvider extends FakeProvider {
5
+ private failed = false;
6
+
7
+ override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
8
+ this.playTtsCalls.push(input);
9
+ if (!this.failed) {
10
+ this.failed = true;
11
+ throw new Error("synthetic tts failure");
12
+ }
13
+ }
14
+ }
15
+
16
+ class DelayedPlayTtsProvider extends FakeProvider {
17
+ private releasePlayTts: (() => void) | null = null;
18
+ private resolvePlayTtsStarted: (() => void) | null = null;
19
+ readonly playTtsStarted = vi.fn();
20
+ readonly playTtsStartedPromise = new Promise<void>((resolve) => {
21
+ this.resolvePlayTtsStarted = resolve;
22
+ });
23
+
24
+ override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
25
+ this.playTtsCalls.push(input);
26
+ this.playTtsStarted();
27
+ this.resolvePlayTtsStarted?.();
28
+ this.resolvePlayTtsStarted = null;
29
+ await new Promise<void>((resolve) => {
30
+ this.releasePlayTts = resolve;
31
+ });
32
+ }
33
+
34
+ releaseCurrentPlayback(): void {
35
+ this.releasePlayTts?.();
36
+ this.releasePlayTts = null;
37
+ }
38
+ }
39
+
40
+ class FailStartListeningProvider extends FakeProvider {
41
+ override async startListening(
42
+ input: Parameters<FakeProvider["startListening"]>[0],
43
+ ): Promise<void> {
44
+ this.startListeningCalls.push(input);
45
+ throw new Error("synthetic start listening failure");
46
+ }
47
+ }
48
+
49
+ function requireCall(
50
+ manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
51
+ callId: string,
52
+ ) {
53
+ const call = manager.getCall(callId);
54
+ if (!call) {
55
+ throw new Error(`expected active call ${callId}`);
56
+ }
57
+ return call;
58
+ }
59
+
60
+ function requireMappedCall(
61
+ manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
62
+ providerCallId: string,
63
+ ) {
64
+ const call = manager.getCallByProviderCallId(providerCallId);
65
+ if (!call) {
66
+ throw new Error(`expected mapped provider call ${providerCallId}`);
67
+ }
68
+ return call;
69
+ }
70
+
71
+ function requireFirstPlayTtsCall(provider: FakeProvider) {
72
+ const call = provider.playTtsCalls[0];
73
+ if (!call) {
74
+ throw new Error("expected provider.playTts to be called once");
75
+ }
76
+ return call;
77
+ }
78
+
79
+ type HarnessManager = Awaited<ReturnType<typeof createManagerHarness>>["manager"];
80
+
81
+ async function waitForPlaybackDispatch() {
82
+ await new Promise((resolve) => setTimeout(resolve, 0));
83
+ }
84
+
85
+ async function initiateCallWithMessage(
86
+ manager: HarnessManager,
87
+ to: string,
88
+ message: string,
89
+ mode: "notify" | "conversation",
90
+ ) {
91
+ const { callId, success } = await manager.initiateCall(to, undefined, { message, mode });
92
+ expect(success).toBe(true);
93
+ return callId;
94
+ }
95
+
96
+ async function answerCall(
97
+ manager: HarnessManager,
98
+ callId: string,
99
+ eventId: string,
100
+ providerCallId = "call-uuid",
101
+ ) {
102
+ manager.processEvent({
103
+ id: eventId,
104
+ type: "call.answered",
105
+ callId,
106
+ providerCallId,
107
+ timestamp: Date.now(),
108
+ });
109
+ await waitForPlaybackDispatch();
110
+ }
111
+
112
+ function expectFirstPlayTtsText(provider: FakeProvider, text: string) {
113
+ expect(provider.playTtsCalls).toHaveLength(1);
114
+ expect(requireFirstPlayTtsCall(provider).text).toBe(text);
115
+ }
116
+
4
117
  describe("CallManager notify and mapping", () => {
5
118
  it("upgrades providerCallId mapping when provider ID changes", async () => {
6
119
  const { manager } = await createManagerHarness();
@@ -9,8 +122,8 @@ describe("CallManager notify and mapping", () => {
9
122
  expect(success).toBe(true);
10
123
  expect(error).toBeUndefined();
11
124
 
12
- expect(manager.getCall(callId)?.providerCallId).toBe("request-uuid");
13
- expect(manager.getCallByProviderCallId("request-uuid")?.callId).toBe(callId);
125
+ expect(requireCall(manager, callId).providerCallId).toBe("request-uuid");
126
+ expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId);
14
127
 
15
128
  manager.processEvent({
16
129
  id: "evt-1",
@@ -20,8 +133,8 @@ describe("CallManager notify and mapping", () => {
20
133
  timestamp: Date.now(),
21
134
  });
22
135
 
23
- expect(manager.getCall(callId)?.providerCallId).toBe("call-uuid");
24
- expect(manager.getCallByProviderCallId("call-uuid")?.callId).toBe(callId);
136
+ expect(requireCall(manager, callId).providerCallId).toBe("call-uuid");
137
+ expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId);
25
138
  expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
26
139
  });
27
140
 
@@ -30,24 +143,235 @@ describe("CallManager notify and mapping", () => {
30
143
  async (providerName) => {
31
144
  const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
32
145
 
33
- const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
34
- message: "Hello there",
35
- mode: "notify",
36
- });
37
- expect(success).toBe(true);
146
+ const callId = await initiateCallWithMessage(
147
+ manager,
148
+ "+15550000002",
149
+ "Hello there",
150
+ "notify",
151
+ );
152
+ await answerCall(manager, callId, `evt-2-${providerName}`);
38
153
 
39
- manager.processEvent({
40
- id: `evt-2-${providerName}`,
41
- type: "call.answered",
154
+ expectFirstPlayTtsText(provider, "Hello there");
155
+ },
156
+ );
157
+
158
+ it("speaks initial message on answered for conversation mode with non-stream provider", async () => {
159
+ const { manager, provider } = await createManagerHarness({}, new FakeProvider("plivo"));
160
+
161
+ const callId = await initiateCallWithMessage(
162
+ manager,
163
+ "+15550000003",
164
+ "Hello from conversation",
165
+ "conversation",
166
+ );
167
+ await answerCall(manager, callId, "evt-conversation-plivo");
168
+
169
+ expectFirstPlayTtsText(provider, "Hello from conversation");
170
+ });
171
+
172
+ it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => {
173
+ const { manager, provider } = await createManagerHarness(
174
+ { streaming: { enabled: false } },
175
+ new FakeProvider("twilio"),
176
+ );
177
+
178
+ const callId = await initiateCallWithMessage(
179
+ manager,
180
+ "+15550000004",
181
+ "Twilio non-stream",
182
+ "conversation",
183
+ );
184
+ await answerCall(manager, callId, "evt-conversation-twilio-no-stream");
185
+
186
+ expectFirstPlayTtsText(provider, "Twilio non-stream");
187
+ });
188
+
189
+ it("lets realtime conversations own the initial greeting instead of posting legacy TwiML", async () => {
190
+ const { manager, provider } = await createManagerHarness(
191
+ { realtime: { enabled: true, provider: "openai" } },
192
+ new FakeProvider("twilio"),
193
+ );
194
+
195
+ const callId = await initiateCallWithMessage(
196
+ manager,
197
+ "+15550000010",
198
+ "Tell Nana dinner is at 6pm.",
199
+ "conversation",
200
+ );
201
+ await answerCall(manager, callId, "evt-conversation-twilio-realtime");
202
+
203
+ expect(provider.playTtsCalls).toHaveLength(0);
204
+ expect(requireCall(manager, callId).metadata).toEqual(
205
+ expect.objectContaining({ initialMessage: "Tell Nana dinner is at 6pm." }),
206
+ );
207
+ });
208
+
209
+ it("still speaks initial message in notify mode when realtime is enabled", async () => {
210
+ const { manager, provider } = await createManagerHarness(
211
+ { realtime: { enabled: true, provider: "openai" } },
212
+ new FakeProvider("twilio"),
213
+ );
214
+
215
+ const callId = await initiateCallWithMessage(manager, "+15550000011", "Notify text", "notify");
216
+ await answerCall(manager, callId, "evt-notify-twilio-realtime");
217
+
218
+ expectFirstPlayTtsText(provider, "Notify text");
219
+ });
220
+
221
+ it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => {
222
+ const { manager, provider } = await createManagerHarness(
223
+ { streaming: { enabled: true } },
224
+ new FakeProvider("twilio"),
225
+ );
226
+
227
+ const callId = await initiateCallWithMessage(
228
+ manager,
229
+ "+15550000005",
230
+ "Twilio stream",
231
+ "conversation",
232
+ );
233
+ await answerCall(manager, callId, "evt-conversation-twilio-stream");
234
+
235
+ expect(provider.playTtsCalls).toHaveLength(0);
236
+ });
237
+
238
+ it("speaks on answered when Twilio streaming is enabled but stream-connect path is unavailable", async () => {
239
+ const twilioProvider = new FakeProvider("twilio");
240
+ twilioProvider.twilioStreamConnectEnabled = false;
241
+ const { manager, provider } = await createManagerHarness(
242
+ { streaming: { enabled: true } },
243
+ twilioProvider,
244
+ );
245
+
246
+ const callId = await initiateCallWithMessage(
247
+ manager,
248
+ "+15550000009",
249
+ "Twilio stream unavailable",
250
+ "conversation",
251
+ );
252
+ await answerCall(manager, callId, "evt-conversation-twilio-stream-unavailable");
253
+
254
+ expectFirstPlayTtsText(provider, "Twilio stream unavailable");
255
+ });
256
+
257
+ it("starts listening after the initial greeting for Telnyx conversation calls", async () => {
258
+ const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx"));
259
+
260
+ const callId = await initiateCallWithMessage(
261
+ manager,
262
+ "+15550000012",
263
+ "Telnyx hello",
264
+ "conversation",
265
+ );
266
+ await answerCall(manager, callId, "evt-conversation-telnyx");
267
+
268
+ expectFirstPlayTtsText(provider, "Telnyx hello");
269
+ expect(provider.startListeningCalls).toEqual([
270
+ expect.objectContaining({
42
271
  callId,
43
272
  providerCallId: "call-uuid",
44
- timestamp: Date.now(),
45
- });
273
+ }),
274
+ ]);
275
+ expect(requireCall(manager, callId).state).toBe("listening");
276
+ });
46
277
 
47
- await new Promise((resolve) => setTimeout(resolve, 0));
278
+ it("logs fire-and-forget initial-message failures instead of leaking unhandled rejections", async () => {
279
+ const provider = new FailStartListeningProvider("twilio");
280
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
281
+ try {
282
+ const { manager } = await createManagerHarness({ streaming: { enabled: false } }, provider);
48
283
 
49
- expect(provider.playTtsCalls).toHaveLength(1);
50
- expect(provider.playTtsCalls[0]?.text).toBe("Hello there");
51
- },
52
- );
284
+ const callId = await initiateCallWithMessage(
285
+ manager,
286
+ "+15550000013",
287
+ "Twilio hello",
288
+ "conversation",
289
+ );
290
+ await answerCall(manager, callId, "evt-initial-message-start-listening-fails");
291
+
292
+ expectFirstPlayTtsText(provider, "Twilio hello");
293
+ expect(provider.startListeningCalls).toEqual([
294
+ expect.objectContaining({
295
+ callId,
296
+ providerCallId: "call-uuid",
297
+ }),
298
+ ]);
299
+ expect(warn).toHaveBeenCalledWith(
300
+ expect.stringContaining(
301
+ `[voice-call] Failed to speak initial message for call ${callId}: synthetic start listening failure`,
302
+ ),
303
+ );
304
+ } finally {
305
+ warn.mockRestore();
306
+ }
307
+ });
308
+
309
+ it("preserves initialMessage after a failed first playback and retries on next trigger", async () => {
310
+ const provider = new FailFirstPlayTtsProvider("plivo");
311
+ const { manager } = await createManagerHarness({}, provider);
312
+
313
+ const callId = await initiateCallWithMessage(manager, "+15550000006", "Retry me", "notify");
314
+ await answerCall(manager, callId, "evt-retry-1");
315
+
316
+ const afterFailure = requireCall(manager, callId);
317
+ expect(provider.playTtsCalls).toHaveLength(1);
318
+ expect(afterFailure.metadata).toEqual(expect.objectContaining({ initialMessage: "Retry me" }));
319
+ expect(afterFailure.state).toBe("listening");
320
+
321
+ await answerCall(manager, callId, "evt-retry-2");
322
+
323
+ const afterSuccess = requireCall(manager, callId);
324
+ expect(provider.playTtsCalls).toHaveLength(2);
325
+ expect(afterSuccess.metadata).not.toHaveProperty("initialMessage");
326
+ });
327
+
328
+ it("speaks initial message only once on repeated stream-connect triggers", async () => {
329
+ const { manager, provider } = await createManagerHarness(
330
+ { streaming: { enabled: true } },
331
+ new FakeProvider("twilio"),
332
+ );
333
+
334
+ const callId = await initiateCallWithMessage(
335
+ manager,
336
+ "+15550000007",
337
+ "Stream hello",
338
+ "conversation",
339
+ );
340
+ await answerCall(manager, callId, "evt-stream-answered");
341
+ expect(provider.playTtsCalls).toHaveLength(0);
342
+
343
+ await manager.speakInitialMessage("call-uuid");
344
+ await manager.speakInitialMessage("call-uuid");
345
+
346
+ expectFirstPlayTtsText(provider, "Stream hello");
347
+ });
348
+
349
+ it("prevents concurrent initial-message replays while first playback is in flight", async () => {
350
+ const provider = new DelayedPlayTtsProvider("twilio");
351
+ const { manager } = await createManagerHarness({ streaming: { enabled: true } }, provider);
352
+
353
+ const callId = await initiateCallWithMessage(
354
+ manager,
355
+ "+15550000008",
356
+ "In-flight hello",
357
+ "conversation",
358
+ );
359
+ await answerCall(manager, callId, "evt-stream-answered-concurrent");
360
+ expect(provider.playTtsCalls).toHaveLength(0);
361
+
362
+ const first = manager.speakInitialMessage("call-uuid");
363
+ await provider.playTtsStartedPromise;
364
+ expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
365
+
366
+ const second = manager.speakInitialMessage("call-uuid");
367
+ await waitForPlaybackDispatch();
368
+ expect(provider.playTtsCalls).toHaveLength(1);
369
+
370
+ provider.releaseCurrentPlayback();
371
+ await Promise.all([first, second]);
372
+
373
+ const call = requireCall(manager, callId);
374
+ expect(call.metadata).not.toHaveProperty("initialMessage");
375
+ expectFirstPlayTtsText(provider, "In-flight hello");
376
+ });
53
377
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { VoiceCallConfigSchema } from "./config.js";
3
3
  import { CallManager } from "./manager.js";
4
4
  import {
@@ -7,8 +7,23 @@ import {
7
7
  makePersistedCall,
8
8
  writeCallsToStore,
9
9
  } from "./manager.test-harness.js";
10
+ import { flushPendingCallRecordWritesForTest, loadActiveCallsFromStore } from "./manager/store.js";
11
+
12
+ function requireSingleActiveCall(manager: CallManager) {
13
+ const activeCalls = manager.getActiveCalls();
14
+ expect(activeCalls).toHaveLength(1);
15
+ const activeCall = activeCalls[0];
16
+ if (!activeCall) {
17
+ throw new Error("expected restored active call");
18
+ }
19
+ return activeCall;
20
+ }
10
21
 
11
22
  describe("CallManager verification on restore", () => {
23
+ afterEach(() => {
24
+ vi.useRealTimers();
25
+ });
26
+
12
27
  async function initializeManager(params?: {
13
28
  callOverrides?: Parameters<typeof makePersistedCall>[0];
14
29
  providerResult?: FakeProvider["getCallStatusResult"];
@@ -34,7 +49,7 @@ describe("CallManager verification on restore", () => {
34
49
  const manager = new CallManager(config, storePath);
35
50
  await manager.initialize(provider, "https://example.com/voice/webhook");
36
51
 
37
- return { call, manager };
52
+ return { call, manager, provider, storePath };
38
53
  }
39
54
 
40
55
  it("skips stale calls reported terminal by provider", async () => {
@@ -50,20 +65,22 @@ describe("CallManager verification on restore", () => {
50
65
  providerResult: { status: "in-progress", isTerminal: false },
51
66
  });
52
67
 
53
- expect(manager.getActiveCalls()).toHaveLength(1);
54
- expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
68
+ const activeCall = requireSingleActiveCall(manager);
69
+ expect(activeCall.callId).toBe(call.callId);
55
70
  });
56
71
 
57
72
  it("keeps calls when provider returns unknown (transient error)", async () => {
58
- const { manager } = await initializeManager({
73
+ const { call, manager } = await initializeManager({
59
74
  providerResult: { status: "error", isTerminal: false, isUnknown: true },
60
75
  });
61
76
 
62
- expect(manager.getActiveCalls()).toHaveLength(1);
77
+ const activeCall = requireSingleActiveCall(manager);
78
+ expect(activeCall.callId).toBe(call.callId);
79
+ expect(activeCall.state).toBe(call.state);
63
80
  });
64
81
 
65
82
  it("skips calls older than maxDurationSeconds", async () => {
66
- const { manager } = await initializeManager({
83
+ const { manager, provider, storePath } = await initializeManager({
67
84
  callOverrides: {
68
85
  startedAt: Date.now() - 600_000,
69
86
  answeredAt: Date.now() - 590_000,
@@ -72,6 +89,14 @@ describe("CallManager verification on restore", () => {
72
89
  });
73
90
 
74
91
  expect(manager.getActiveCalls()).toHaveLength(0);
92
+ expect(provider.hangupCalls).toEqual([
93
+ expect.objectContaining({
94
+ reason: "timeout",
95
+ }),
96
+ ]);
97
+
98
+ await flushPendingCallRecordWritesForTest();
99
+ expect(loadActiveCallsFromStore(storePath).activeCalls.size).toBe(0);
75
100
  });
76
101
 
77
102
  it("skips calls without providerCallId", async () => {
@@ -83,7 +108,7 @@ describe("CallManager verification on restore", () => {
83
108
  });
84
109
 
85
110
  it("keeps call when getCallStatus throws (verification failure)", async () => {
86
- const { manager } = await initializeManager({
111
+ const { call, manager } = await initializeManager({
87
112
  configureProvider: (provider) => {
88
113
  provider.getCallStatus = async () => {
89
114
  throw new Error("network failure");
@@ -91,6 +116,68 @@ describe("CallManager verification on restore", () => {
91
116
  },
92
117
  });
93
118
 
119
+ const activeCall = requireSingleActiveCall(manager);
120
+ expect(activeCall.callId).toBe(call.callId);
121
+ expect(activeCall.state).toBe(call.state);
122
+ });
123
+
124
+ it("uses only remaining max duration for restored answered calls", async () => {
125
+ vi.useFakeTimers();
126
+ const now = new Date("2026-03-17T03:07:00Z");
127
+ vi.setSystemTime(now);
128
+ const { manager, provider } = await initializeManager({
129
+ callOverrides: {
130
+ startedAt: now.getTime() - 290_000,
131
+ answeredAt: now.getTime() - 290_000,
132
+ state: "answered",
133
+ },
134
+ configOverrides: { maxDurationSeconds: 300 },
135
+ });
136
+
94
137
  expect(manager.getActiveCalls()).toHaveLength(1);
138
+ await vi.advanceTimersByTimeAsync(9_000);
139
+ expect(manager.getActiveCalls()).toHaveLength(1);
140
+ expect(provider.hangupCalls).toHaveLength(0);
141
+
142
+ await vi.advanceTimersByTimeAsync(1_100);
143
+ expect(manager.getActiveCalls()).toHaveLength(0);
144
+ expect(provider.hangupCalls).toEqual([
145
+ expect.objectContaining({
146
+ reason: "timeout",
147
+ }),
148
+ ]);
149
+ });
150
+
151
+ it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
152
+ const storePath = createTestStorePath();
153
+ const persisted = makePersistedCall({
154
+ state: "completed",
155
+ endedAt: Date.now() - 5_000,
156
+ endReason: "completed",
157
+ processedEventIds: ["evt-terminal-init"],
158
+ });
159
+ writeCallsToStore(storePath, [persisted]);
160
+
161
+ const provider = new FakeProvider();
162
+ const config = VoiceCallConfigSchema.parse({
163
+ enabled: true,
164
+ provider: "plivo",
165
+ fromNumber: "+15550000000",
166
+ });
167
+ const manager = new CallManager(config, storePath);
168
+ await manager.initialize(provider, "https://example.com/voice/webhook");
169
+
170
+ manager.processEvent({
171
+ id: "evt-terminal-init",
172
+ type: "call.initiated",
173
+ callId: String(persisted.providerCallId),
174
+ providerCallId: String(persisted.providerCallId),
175
+ timestamp: Date.now(),
176
+ direction: "outbound",
177
+ from: "+15550000000",
178
+ to: "+15550000001",
179
+ });
180
+
181
+ expect(manager.getActiveCalls()).toHaveLength(0);
95
182
  });
96
183
  });