@kodelyth/voice-call 2026.5.39 → 2026.5.42

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 (137) hide show
  1. package/README.md +167 -0
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/dist/api.js +2 -0
  6. package/dist/cli-metadata.js +12 -0
  7. package/dist/config-DAwbG2aw.js +621 -0
  8. package/dist/config-compat-BYfJ5ueI.js +129 -0
  9. package/dist/guarded-json-api-xAIbFPZh.js +591 -0
  10. package/dist/index.js +1341 -0
  11. package/dist/mock-jtSdKDQN.js +135 -0
  12. package/dist/plivo-L-JTeuEc.js +392 -0
  13. package/dist/realtime-handler-5pSItXxX.js +1227 -0
  14. package/dist/realtime-transcription.runtime-CAbQKwCN.js +2 -0
  15. package/dist/realtime-voice.runtime-vCpCAutg.js +2 -0
  16. package/dist/response-generator-B-MjbtsM.js +199 -0
  17. package/dist/runtime-api.js +6 -0
  18. package/dist/runtime-entry-ohPMJR46.js +3435 -0
  19. package/dist/runtime-entry.js +2 -0
  20. package/dist/setup-api.js +37 -0
  21. package/dist/telnyx-BWr9EZ4x.js +278 -0
  22. package/dist/twilio-D9B0zY1k.js +679 -0
  23. package/index.test.ts +1075 -0
  24. package/index.ts +863 -0
  25. package/klaw.plugin.json +30 -133
  26. package/package.json +3 -3
  27. package/runtime-api.ts +20 -0
  28. package/runtime-entry.ts +1 -0
  29. package/setup-api.ts +47 -0
  30. package/src/allowlist.test.ts +18 -0
  31. package/src/allowlist.ts +19 -0
  32. package/src/cli.test.ts +12 -0
  33. package/src/cli.ts +866 -0
  34. package/src/config-compat.test.ts +130 -0
  35. package/src/config-compat.ts +227 -0
  36. package/src/config.test.ts +542 -0
  37. package/src/config.ts +883 -0
  38. package/src/core-bridge.ts +14 -0
  39. package/src/deep-merge.test.ts +40 -0
  40. package/src/deep-merge.ts +23 -0
  41. package/src/gateway-continue-operation.ts +200 -0
  42. package/src/http-headers.test.ts +16 -0
  43. package/src/http-headers.ts +15 -0
  44. package/src/manager/context.ts +50 -0
  45. package/src/manager/events.test.ts +578 -0
  46. package/src/manager/events.ts +332 -0
  47. package/src/manager/lifecycle.ts +53 -0
  48. package/src/manager/lookup.test.ts +52 -0
  49. package/src/manager/lookup.ts +35 -0
  50. package/src/manager/outbound.test.ts +629 -0
  51. package/src/manager/outbound.ts +508 -0
  52. package/src/manager/state.ts +48 -0
  53. package/src/manager/store.ts +107 -0
  54. package/src/manager/timers.test.ts +127 -0
  55. package/src/manager/timers.ts +113 -0
  56. package/src/manager/twiml.test.ts +13 -0
  57. package/src/manager/twiml.ts +17 -0
  58. package/src/manager.closed-loop.test.ts +259 -0
  59. package/src/manager.inbound-allowlist.test.ts +183 -0
  60. package/src/manager.notify.test.ts +390 -0
  61. package/src/manager.restore.test.ts +310 -0
  62. package/src/manager.test-harness.ts +127 -0
  63. package/src/manager.ts +441 -0
  64. package/src/media-stream.test.ts +953 -0
  65. package/src/media-stream.ts +876 -0
  66. package/src/providers/base.ts +99 -0
  67. package/src/providers/mock.test.ts +86 -0
  68. package/src/providers/mock.ts +185 -0
  69. package/src/providers/plivo.test.ts +93 -0
  70. package/src/providers/plivo.ts +601 -0
  71. package/src/providers/shared/call-status.test.ts +24 -0
  72. package/src/providers/shared/call-status.ts +24 -0
  73. package/src/providers/shared/guarded-json-api.test.ts +127 -0
  74. package/src/providers/shared/guarded-json-api.ts +49 -0
  75. package/src/providers/telnyx.test.ts +489 -0
  76. package/src/providers/telnyx.ts +419 -0
  77. package/src/providers/twilio/api.test.ts +184 -0
  78. package/src/providers/twilio/api.ts +100 -0
  79. package/src/providers/twilio/twiml-policy.test.ts +84 -0
  80. package/src/providers/twilio/twiml-policy.ts +87 -0
  81. package/src/providers/twilio/webhook.ts +34 -0
  82. package/src/providers/twilio.test.ts +607 -0
  83. package/src/providers/twilio.ts +861 -0
  84. package/src/providers/twilio.types.ts +17 -0
  85. package/src/realtime-agent-context.test.ts +101 -0
  86. package/src/realtime-agent-context.ts +149 -0
  87. package/src/realtime-defaults.ts +3 -0
  88. package/src/realtime-fast-context.test.ts +74 -0
  89. package/src/realtime-fast-context.ts +27 -0
  90. package/src/realtime-transcription.runtime.ts +4 -0
  91. package/src/realtime-voice.runtime.ts +5 -0
  92. package/src/response-generator.test.ts +385 -0
  93. package/src/response-generator.ts +348 -0
  94. package/src/response-model.test.ts +71 -0
  95. package/src/response-model.ts +23 -0
  96. package/src/runtime.test.ts +625 -0
  97. package/src/runtime.ts +528 -0
  98. package/src/telephony-audio.test.ts +61 -0
  99. package/src/telephony-audio.ts +12 -0
  100. package/src/telephony-tts.test.ts +196 -0
  101. package/src/telephony-tts.ts +235 -0
  102. package/src/test-fixtures.ts +82 -0
  103. package/src/tts-provider-voice.test.ts +34 -0
  104. package/src/tts-provider-voice.ts +21 -0
  105. package/src/tunnel.test.ts +173 -0
  106. package/src/tunnel.ts +314 -0
  107. package/src/types.ts +311 -0
  108. package/src/utils.test.ts +17 -0
  109. package/src/utils.ts +14 -0
  110. package/src/voice-mapping.test.ts +32 -0
  111. package/src/voice-mapping.ts +65 -0
  112. package/src/webhook/realtime-audio-pacer.test.ts +146 -0
  113. package/src/webhook/realtime-audio-pacer.ts +204 -0
  114. package/src/webhook/realtime-handler.test.ts +1450 -0
  115. package/src/webhook/realtime-handler.ts +1382 -0
  116. package/src/webhook/stale-call-reaper.test.ts +89 -0
  117. package/src/webhook/stale-call-reaper.ts +38 -0
  118. package/src/webhook/stream-frame-adapter.test.ts +187 -0
  119. package/src/webhook/stream-frame-adapter.ts +219 -0
  120. package/src/webhook/tailscale.test.ts +216 -0
  121. package/src/webhook/tailscale.ts +129 -0
  122. package/src/webhook-exposure.test.ts +33 -0
  123. package/src/webhook-exposure.ts +84 -0
  124. package/src/webhook-security.test.ts +813 -0
  125. package/src/webhook-security.ts +982 -0
  126. package/src/webhook.hangup-once.lifecycle.test.ts +179 -0
  127. package/src/webhook.test.ts +1615 -0
  128. package/src/webhook.ts +933 -0
  129. package/src/webhook.types.ts +5 -0
  130. package/src/websocket-test-support.ts +72 -0
  131. package/tsconfig.json +16 -0
  132. package/api.js +0 -7
  133. package/cli-metadata.js +0 -7
  134. package/index.js +0 -7
  135. package/runtime-api.js +0 -7
  136. package/runtime-entry.js +0 -7
  137. package/setup-api.js +0 -7
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FakeProvider, createManagerHarness } from "./manager.test-harness.js";
3
+
4
+ describe("CallManager inbound allowlist", () => {
5
+ it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
6
+ const { manager, provider } = await createManagerHarness({
7
+ inboundPolicy: "allowlist",
8
+ allowFrom: ["+15550001234"],
9
+ });
10
+
11
+ manager.processEvent({
12
+ id: "evt-allowlist-missing",
13
+ type: "call.initiated",
14
+ callId: "call-missing",
15
+ providerCallId: "provider-missing",
16
+ timestamp: Date.now(),
17
+ direction: "inbound",
18
+ to: "+15550000000",
19
+ });
20
+
21
+ expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
22
+ expect(provider.hangupCalls).toHaveLength(1);
23
+ expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
24
+ });
25
+
26
+ it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
27
+ const { manager, provider } = await createManagerHarness({
28
+ inboundPolicy: "allowlist",
29
+ allowFrom: ["+15550001234"],
30
+ });
31
+
32
+ manager.processEvent({
33
+ id: "evt-allowlist-anon",
34
+ type: "call.initiated",
35
+ callId: "call-anon",
36
+ providerCallId: "provider-anon",
37
+ timestamp: Date.now(),
38
+ direction: "inbound",
39
+ from: "anonymous",
40
+ to: "+15550000000",
41
+ });
42
+
43
+ expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
44
+ expect(provider.hangupCalls).toHaveLength(1);
45
+ expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
46
+ });
47
+
48
+ it("rejects inbound calls that only match allowlist suffixes", async () => {
49
+ const { manager, provider } = await createManagerHarness({
50
+ inboundPolicy: "allowlist",
51
+ allowFrom: ["+15550001234"],
52
+ });
53
+
54
+ manager.processEvent({
55
+ id: "evt-allowlist-suffix",
56
+ type: "call.initiated",
57
+ callId: "call-suffix",
58
+ providerCallId: "provider-suffix",
59
+ timestamp: Date.now(),
60
+ direction: "inbound",
61
+ from: "+99915550001234",
62
+ to: "+15550000000",
63
+ });
64
+
65
+ expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
66
+ expect(provider.hangupCalls).toHaveLength(1);
67
+ expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
68
+ });
69
+
70
+ it("rejects duplicate inbound events with a single hangup call", async () => {
71
+ const { manager, provider } = await createManagerHarness({
72
+ inboundPolicy: "disabled",
73
+ });
74
+
75
+ manager.processEvent({
76
+ id: "evt-reject-init",
77
+ type: "call.initiated",
78
+ callId: "provider-dup",
79
+ providerCallId: "provider-dup",
80
+ timestamp: Date.now(),
81
+ direction: "inbound",
82
+ from: "+15552222222",
83
+ to: "+15550000000",
84
+ });
85
+
86
+ manager.processEvent({
87
+ id: "evt-reject-ring",
88
+ type: "call.ringing",
89
+ callId: "provider-dup",
90
+ providerCallId: "provider-dup",
91
+ timestamp: Date.now(),
92
+ direction: "inbound",
93
+ from: "+15552222222",
94
+ to: "+15550000000",
95
+ });
96
+
97
+ expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
98
+ expect(provider.hangupCalls).toHaveLength(1);
99
+ expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
100
+ });
101
+
102
+ it("retries rejected inbound hangup after a transient provider failure", async () => {
103
+ class FlakyHangupProvider extends FakeProvider {
104
+ hangupFailuresRemaining = 1;
105
+
106
+ override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
107
+ this.hangupCalls.push(input);
108
+ if (this.hangupFailuresRemaining > 0) {
109
+ this.hangupFailuresRemaining -= 1;
110
+ throw new Error("provider down");
111
+ }
112
+ }
113
+ }
114
+
115
+ const provider = new FlakyHangupProvider();
116
+ const { manager } = await createManagerHarness(
117
+ {
118
+ inboundPolicy: "disabled",
119
+ },
120
+ provider,
121
+ );
122
+
123
+ manager.processEvent({
124
+ id: "evt-reject-fail-init",
125
+ type: "call.initiated",
126
+ callId: "provider-flaky",
127
+ providerCallId: "provider-flaky",
128
+ timestamp: Date.now(),
129
+ direction: "inbound",
130
+ from: "+15553333333",
131
+ to: "+15550000000",
132
+ });
133
+ await Promise.resolve();
134
+
135
+ manager.processEvent({
136
+ id: "evt-reject-fail-ring",
137
+ type: "call.ringing",
138
+ callId: "provider-flaky",
139
+ providerCallId: "provider-flaky",
140
+ timestamp: Date.now(),
141
+ direction: "inbound",
142
+ from: "+15553333333",
143
+ to: "+15550000000",
144
+ });
145
+
146
+ expect(manager.getCallByProviderCallId("provider-flaky")).toBeUndefined();
147
+ expect(provider.hangupCalls).toHaveLength(2);
148
+ expect(provider.hangupCalls.map((call) => call.providerCallId)).toEqual([
149
+ "provider-flaky",
150
+ "provider-flaky",
151
+ ]);
152
+ });
153
+
154
+ it("accepts inbound calls that exactly match the allowlist", async () => {
155
+ const { manager } = await createManagerHarness({
156
+ inboundPolicy: "allowlist",
157
+ allowFrom: ["+15550001234"],
158
+ });
159
+
160
+ manager.processEvent({
161
+ id: "evt-allowlist-exact",
162
+ type: "call.initiated",
163
+ callId: "call-exact",
164
+ providerCallId: "provider-exact",
165
+ timestamp: Date.now(),
166
+ direction: "inbound",
167
+ from: "+15550001234",
168
+ to: "+15550000000",
169
+ });
170
+
171
+ const call = manager.getCallByProviderCallId("provider-exact");
172
+ if (!call) {
173
+ throw new Error("expected exact allowlist match to keep the inbound call");
174
+ }
175
+ expect(call.providerCallId).toBe("provider-exact");
176
+ expect(call.direction).toBe("inbound");
177
+ expect(call.from).toBe("+15550001234");
178
+ expect(call.to).toBe("+15550000000");
179
+ expect(call.callId).toMatch(
180
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
181
+ );
182
+ });
183
+ });
@@ -0,0 +1,390 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createManagerHarness, FakeProvider } from "./manager.test-harness.js";
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.at(0);
73
+ if (!call) {
74
+ throw new Error("expected provider.playTts to be called once");
75
+ }
76
+ return call;
77
+ }
78
+
79
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
80
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
81
+ throw new Error(`expected ${label} to be a record`);
82
+ }
83
+ return value as Record<string, unknown>;
84
+ }
85
+
86
+ function requireSingleStartListeningCall(provider: FakeProvider) {
87
+ expect(provider.startListeningCalls).toHaveLength(1);
88
+ return requireRecord(provider.startListeningCalls.at(0), "start listening call");
89
+ }
90
+
91
+ function requireFirstMockCall(calls: readonly unknown[][], label: string): unknown[] {
92
+ const call = calls.at(0);
93
+ if (!call) {
94
+ throw new Error(`expected ${label} call`);
95
+ }
96
+ return call;
97
+ }
98
+
99
+ type HarnessManager = Awaited<ReturnType<typeof createManagerHarness>>["manager"];
100
+
101
+ async function waitForPlaybackDispatch() {
102
+ await new Promise<void>((resolve) => setImmediate(resolve));
103
+ }
104
+
105
+ async function initiateCallWithMessage(
106
+ manager: HarnessManager,
107
+ to: string,
108
+ message: string,
109
+ mode: "notify" | "conversation",
110
+ ) {
111
+ const { callId, success } = await manager.initiateCall(to, undefined, { message, mode });
112
+ expect(success).toBe(true);
113
+ return callId;
114
+ }
115
+
116
+ async function answerCall(
117
+ manager: HarnessManager,
118
+ callId: string,
119
+ eventId: string,
120
+ providerCallId = "call-uuid",
121
+ ) {
122
+ manager.processEvent({
123
+ id: eventId,
124
+ type: "call.answered",
125
+ callId,
126
+ providerCallId,
127
+ timestamp: Date.now(),
128
+ });
129
+ await waitForPlaybackDispatch();
130
+ }
131
+
132
+ function expectFirstPlayTtsText(provider: FakeProvider, text: string) {
133
+ expect(provider.playTtsCalls).toHaveLength(1);
134
+ expect(requireFirstPlayTtsCall(provider).text).toBe(text);
135
+ }
136
+
137
+ describe("CallManager notify and mapping", () => {
138
+ it("upgrades providerCallId mapping when provider ID changes", async () => {
139
+ const { manager } = await createManagerHarness();
140
+
141
+ const { callId, success, error } = await manager.initiateCall("+15550000001");
142
+ expect(success).toBe(true);
143
+ expect(error).toBeUndefined();
144
+
145
+ expect(requireCall(manager, callId).providerCallId).toBe("request-uuid");
146
+ expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId);
147
+
148
+ manager.processEvent({
149
+ id: "evt-1",
150
+ type: "call.answered",
151
+ callId,
152
+ providerCallId: "call-uuid",
153
+ timestamp: Date.now(),
154
+ });
155
+
156
+ expect(requireCall(manager, callId).providerCallId).toBe("call-uuid");
157
+ expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId);
158
+ expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
159
+ });
160
+
161
+ it.each(["plivo", "twilio"] as const)(
162
+ "speaks initial message on answered for notify mode (%s)",
163
+ async (providerName) => {
164
+ const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
165
+
166
+ const callId = await initiateCallWithMessage(
167
+ manager,
168
+ "+15550000002",
169
+ "Hello there",
170
+ "notify",
171
+ );
172
+ await answerCall(manager, callId, `evt-2-${providerName}`);
173
+
174
+ expectFirstPlayTtsText(provider, "Hello there");
175
+ },
176
+ );
177
+
178
+ it("speaks initial message on answered for conversation mode with non-stream provider", async () => {
179
+ const { manager, provider } = await createManagerHarness({}, new FakeProvider("plivo"));
180
+
181
+ const callId = await initiateCallWithMessage(
182
+ manager,
183
+ "+15550000003",
184
+ "Hello from conversation",
185
+ "conversation",
186
+ );
187
+ await answerCall(manager, callId, "evt-conversation-plivo");
188
+
189
+ expectFirstPlayTtsText(provider, "Hello from conversation");
190
+ });
191
+
192
+ it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => {
193
+ const { manager, provider } = await createManagerHarness(
194
+ { streaming: { enabled: false } },
195
+ new FakeProvider("twilio"),
196
+ );
197
+
198
+ const callId = await initiateCallWithMessage(
199
+ manager,
200
+ "+15550000004",
201
+ "Twilio non-stream",
202
+ "conversation",
203
+ );
204
+ await answerCall(manager, callId, "evt-conversation-twilio-no-stream");
205
+
206
+ expectFirstPlayTtsText(provider, "Twilio non-stream");
207
+ });
208
+
209
+ it("lets realtime conversations own the initial greeting instead of posting legacy TwiML", async () => {
210
+ const { manager, provider } = await createManagerHarness(
211
+ { realtime: { enabled: true, provider: "openai" } },
212
+ new FakeProvider("twilio"),
213
+ );
214
+
215
+ const callId = await initiateCallWithMessage(
216
+ manager,
217
+ "+15550000010",
218
+ "Tell Nana dinner is at 6pm.",
219
+ "conversation",
220
+ );
221
+ await answerCall(manager, callId, "evt-conversation-twilio-realtime");
222
+
223
+ expect(provider.playTtsCalls).toHaveLength(0);
224
+ const metadata = requireRecord(requireCall(manager, callId).metadata, "call metadata");
225
+ expect(metadata.initialMessage).toBe("Tell Nana dinner is at 6pm.");
226
+ });
227
+
228
+ it("still speaks initial message in notify mode when realtime is enabled", async () => {
229
+ const { manager, provider } = await createManagerHarness(
230
+ { realtime: { enabled: true, provider: "openai" } },
231
+ new FakeProvider("twilio"),
232
+ );
233
+
234
+ const callId = await initiateCallWithMessage(manager, "+15550000011", "Notify text", "notify");
235
+ await answerCall(manager, callId, "evt-notify-twilio-realtime");
236
+
237
+ expectFirstPlayTtsText(provider, "Notify text");
238
+ });
239
+
240
+ it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => {
241
+ const { manager, provider } = await createManagerHarness(
242
+ { streaming: { enabled: true } },
243
+ new FakeProvider("twilio"),
244
+ );
245
+
246
+ const callId = await initiateCallWithMessage(
247
+ manager,
248
+ "+15550000005",
249
+ "Twilio stream",
250
+ "conversation",
251
+ );
252
+ await answerCall(manager, callId, "evt-conversation-twilio-stream");
253
+
254
+ expect(provider.playTtsCalls).toHaveLength(0);
255
+ });
256
+
257
+ it("speaks on answered when Twilio streaming is enabled but stream-connect path is unavailable", async () => {
258
+ const twilioProvider = new FakeProvider("twilio");
259
+ twilioProvider.twilioStreamConnectEnabled = false;
260
+ const { manager, provider } = await createManagerHarness(
261
+ { streaming: { enabled: true } },
262
+ twilioProvider,
263
+ );
264
+
265
+ const callId = await initiateCallWithMessage(
266
+ manager,
267
+ "+15550000009",
268
+ "Twilio stream unavailable",
269
+ "conversation",
270
+ );
271
+ await answerCall(manager, callId, "evt-conversation-twilio-stream-unavailable");
272
+
273
+ expectFirstPlayTtsText(provider, "Twilio stream unavailable");
274
+ });
275
+
276
+ it("starts listening after the initial greeting for Telnyx conversation calls", async () => {
277
+ const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx"));
278
+
279
+ const callId = await initiateCallWithMessage(
280
+ manager,
281
+ "+15550000012",
282
+ "Telnyx hello",
283
+ "conversation",
284
+ );
285
+ await answerCall(manager, callId, "evt-conversation-telnyx");
286
+
287
+ expectFirstPlayTtsText(provider, "Telnyx hello");
288
+ const startListeningCall = requireSingleStartListeningCall(provider);
289
+ expect(startListeningCall.callId).toBe(callId);
290
+ expect(startListeningCall.providerCallId).toBe("call-uuid");
291
+ expect(requireCall(manager, callId).state).toBe("listening");
292
+ });
293
+
294
+ it("logs fire-and-forget initial-message failures instead of leaking unhandled rejections", async () => {
295
+ const provider = new FailStartListeningProvider("twilio");
296
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
297
+ try {
298
+ const { manager } = await createManagerHarness({ streaming: { enabled: false } }, provider);
299
+
300
+ const callId = await initiateCallWithMessage(
301
+ manager,
302
+ "+15550000013",
303
+ "Twilio hello",
304
+ "conversation",
305
+ );
306
+ await answerCall(manager, callId, "evt-initial-message-start-listening-fails");
307
+
308
+ expectFirstPlayTtsText(provider, "Twilio hello");
309
+ const startListeningCall = requireSingleStartListeningCall(provider);
310
+ expect(startListeningCall.callId).toBe(callId);
311
+ expect(startListeningCall.providerCallId).toBe("call-uuid");
312
+ expect(warn).toHaveBeenCalledOnce();
313
+ expect(String(requireFirstMockCall(warn.mock.calls, "console warn")[0])).toContain(
314
+ `[voice-call] Failed to speak initial message for call ${callId}: synthetic start listening failure`,
315
+ );
316
+ } finally {
317
+ warn.mockRestore();
318
+ }
319
+ });
320
+
321
+ it("preserves initialMessage after a failed first playback and retries on next trigger", async () => {
322
+ const provider = new FailFirstPlayTtsProvider("plivo");
323
+ const { manager } = await createManagerHarness({}, provider);
324
+
325
+ const callId = await initiateCallWithMessage(manager, "+15550000006", "Retry me", "notify");
326
+ await answerCall(manager, callId, "evt-retry-1");
327
+
328
+ const afterFailure = requireCall(manager, callId);
329
+ expect(provider.playTtsCalls).toHaveLength(1);
330
+ const metadata = requireRecord(afterFailure.metadata, "call metadata after failed playback");
331
+ expect(metadata.initialMessage).toBe("Retry me");
332
+ expect(afterFailure.state).toBe("listening");
333
+
334
+ await answerCall(manager, callId, "evt-retry-2");
335
+
336
+ const afterSuccess = requireCall(manager, callId);
337
+ expect(provider.playTtsCalls).toHaveLength(2);
338
+ expect(afterSuccess.metadata).not.toHaveProperty("initialMessage");
339
+ });
340
+
341
+ it("speaks initial message only once on repeated stream-connect triggers", async () => {
342
+ const { manager, provider } = await createManagerHarness(
343
+ { streaming: { enabled: true } },
344
+ new FakeProvider("twilio"),
345
+ );
346
+
347
+ const callId = await initiateCallWithMessage(
348
+ manager,
349
+ "+15550000007",
350
+ "Stream hello",
351
+ "conversation",
352
+ );
353
+ await answerCall(manager, callId, "evt-stream-answered");
354
+ expect(provider.playTtsCalls).toHaveLength(0);
355
+
356
+ await manager.speakInitialMessage("call-uuid");
357
+ await manager.speakInitialMessage("call-uuid");
358
+
359
+ expectFirstPlayTtsText(provider, "Stream hello");
360
+ });
361
+
362
+ it("prevents concurrent initial-message replays while first playback is in flight", async () => {
363
+ const provider = new DelayedPlayTtsProvider("twilio");
364
+ const { manager } = await createManagerHarness({ streaming: { enabled: true } }, provider);
365
+
366
+ const callId = await initiateCallWithMessage(
367
+ manager,
368
+ "+15550000008",
369
+ "In-flight hello",
370
+ "conversation",
371
+ );
372
+ await answerCall(manager, callId, "evt-stream-answered-concurrent");
373
+ expect(provider.playTtsCalls).toHaveLength(0);
374
+
375
+ const first = manager.speakInitialMessage("call-uuid");
376
+ await provider.playTtsStartedPromise;
377
+ expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
378
+
379
+ const second = manager.speakInitialMessage("call-uuid");
380
+ await waitForPlaybackDispatch();
381
+ expect(provider.playTtsCalls).toHaveLength(1);
382
+
383
+ provider.releaseCurrentPlayback();
384
+ await Promise.all([first, second]);
385
+
386
+ const call = requireCall(manager, callId);
387
+ expect(call.metadata).not.toHaveProperty("initialMessage");
388
+ expectFirstPlayTtsText(provider, "In-flight hello");
389
+ });
390
+ });