@kuralle-syrinx/server-websocket 4.4.1 → 4.5.0

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 (132) hide show
  1. package/dist/backchannel-cue-fixtures.d.ts +4 -0
  2. package/dist/backchannel-cue-fixtures.d.ts.map +1 -0
  3. package/dist/backchannel-cue-fixtures.js +25 -0
  4. package/dist/backchannel-cue-fixtures.js.map +1 -0
  5. package/dist/background-audio.d.ts +59 -0
  6. package/dist/background-audio.d.ts.map +1 -0
  7. package/dist/background-audio.js +248 -0
  8. package/dist/background-audio.js.map +1 -0
  9. package/dist/browser-opus.d.ts +12 -0
  10. package/dist/browser-opus.d.ts.map +1 -0
  11. package/dist/browser-opus.js +48 -0
  12. package/dist/browser-opus.js.map +1 -0
  13. package/dist/carrier-commands.d.ts +124 -0
  14. package/dist/carrier-commands.d.ts.map +1 -0
  15. package/dist/carrier-commands.js +193 -0
  16. package/dist/carrier-commands.js.map +1 -0
  17. package/dist/edge-telnyx.d.ts +37 -0
  18. package/dist/edge-telnyx.d.ts.map +1 -0
  19. package/dist/edge-telnyx.js +320 -0
  20. package/dist/edge-telnyx.js.map +1 -0
  21. package/dist/edge-twilio.d.ts +31 -0
  22. package/dist/edge-twilio.d.ts.map +1 -0
  23. package/dist/edge-twilio.js +286 -0
  24. package/dist/edge-twilio.js.map +1 -0
  25. package/dist/edge.d.ts +71 -0
  26. package/dist/edge.d.ts.map +1 -0
  27. package/dist/edge.js +560 -0
  28. package/dist/edge.js.map +1 -0
  29. package/dist/inbound-audio.d.ts +13 -0
  30. package/dist/inbound-audio.d.ts.map +1 -0
  31. package/dist/inbound-audio.js +66 -0
  32. package/dist/inbound-audio.js.map +1 -0
  33. package/dist/index.d.ts +81 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +718 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/json-message.d.ts +7 -0
  38. package/dist/json-message.d.ts.map +1 -0
  39. package/dist/json-message.js +41 -0
  40. package/dist/json-message.js.map +1 -0
  41. package/dist/outbound-playout-pipeline.d.ts +53 -0
  42. package/dist/outbound-playout-pipeline.d.ts.map +1 -0
  43. package/dist/outbound-playout-pipeline.js +148 -0
  44. package/dist/outbound-playout-pipeline.js.map +1 -0
  45. package/dist/paced-playout.d.ts +42 -0
  46. package/dist/paced-playout.d.ts.map +1 -0
  47. package/dist/paced-playout.js +151 -0
  48. package/dist/paced-playout.js.map +1 -0
  49. package/dist/playout-progress.d.ts +26 -0
  50. package/dist/playout-progress.d.ts.map +1 -0
  51. package/dist/playout-progress.js +64 -0
  52. package/dist/playout-progress.js.map +1 -0
  53. package/dist/session-store.d.ts +39 -0
  54. package/dist/session-store.d.ts.map +1 -0
  55. package/dist/session-store.js +80 -0
  56. package/dist/session-store.js.map +1 -0
  57. package/dist/smartpbx.d.ts +47 -0
  58. package/dist/smartpbx.d.ts.map +1 -0
  59. package/dist/smartpbx.js +437 -0
  60. package/dist/smartpbx.js.map +1 -0
  61. package/dist/telnyx-codec.d.ts +41 -0
  62. package/dist/telnyx-codec.d.ts.map +1 -0
  63. package/dist/telnyx-codec.js +114 -0
  64. package/dist/telnyx-codec.js.map +1 -0
  65. package/dist/telnyx.d.ts +52 -0
  66. package/dist/telnyx.d.ts.map +1 -0
  67. package/dist/telnyx.js +550 -0
  68. package/dist/telnyx.js.map +1 -0
  69. package/dist/test-helpers.d.ts +20 -0
  70. package/dist/test-helpers.d.ts.map +1 -0
  71. package/dist/test-helpers.js +242 -0
  72. package/dist/test-helpers.js.map +1 -0
  73. package/dist/transport-helpers.d.ts +12 -0
  74. package/dist/transport-helpers.d.ts.map +1 -0
  75. package/dist/transport-helpers.js +90 -0
  76. package/dist/transport-helpers.js.map +1 -0
  77. package/dist/transport-host.d.ts +56 -0
  78. package/dist/transport-host.d.ts.map +1 -0
  79. package/dist/transport-host.js +110 -0
  80. package/dist/transport-host.js.map +1 -0
  81. package/dist/turn-metrics.d.ts +79 -0
  82. package/dist/turn-metrics.d.ts.map +1 -0
  83. package/dist/turn-metrics.js +208 -0
  84. package/dist/turn-metrics.js.map +1 -0
  85. package/dist/twilio-auth.d.ts +12 -0
  86. package/dist/twilio-auth.d.ts.map +1 -0
  87. package/dist/twilio-auth.js +44 -0
  88. package/dist/twilio-auth.js.map +1 -0
  89. package/dist/twilio.d.ts +56 -0
  90. package/dist/twilio.d.ts.map +1 -0
  91. package/dist/twilio.js +511 -0
  92. package/dist/twilio.js.map +1 -0
  93. package/dist/websocket-close.d.ts +12 -0
  94. package/dist/websocket-close.d.ts.map +1 -0
  95. package/dist/websocket-close.js +58 -0
  96. package/dist/websocket-close.js.map +1 -0
  97. package/dist/websocket-lifecycle.d.ts +29 -0
  98. package/dist/websocket-lifecycle.d.ts.map +1 -0
  99. package/dist/websocket-lifecycle.js +87 -0
  100. package/dist/websocket-lifecycle.js.map +1 -0
  101. package/dist/websocket-upgrade.d.ts +9 -0
  102. package/dist/websocket-upgrade.d.ts.map +1 -0
  103. package/dist/websocket-upgrade.js +91 -0
  104. package/dist/websocket-upgrade.js.map +1 -0
  105. package/dist/wire-carrier-control.d.ts +27 -0
  106. package/dist/wire-carrier-control.d.ts.map +1 -0
  107. package/dist/wire-carrier-control.js +116 -0
  108. package/dist/wire-carrier-control.js.map +1 -0
  109. package/package.json +32 -11
  110. package/src/admission-control.test.ts +270 -0
  111. package/src/background-audio.test.ts +224 -0
  112. package/src/browser-opus.test.ts +41 -0
  113. package/src/browser-pacing.test.ts +440 -0
  114. package/src/carrier-commands.test.ts +198 -0
  115. package/src/edge-telnyx.test.ts +333 -0
  116. package/src/edge-twilio.test.ts +293 -0
  117. package/src/edge.test.ts +727 -0
  118. package/src/graceful-drain.test.ts +306 -0
  119. package/src/inbound-audio.test.ts +58 -0
  120. package/src/index.test.ts +2078 -0
  121. package/src/outbound-playout-pipeline.test.ts +314 -0
  122. package/src/paced-playout.test.ts +247 -0
  123. package/src/playout-progress.test.ts +56 -0
  124. package/src/session-store.test.ts +274 -0
  125. package/src/smartpbx.test.ts +986 -0
  126. package/src/telnyx.test.ts +1633 -0
  127. package/src/transport-host.test.ts +51 -0
  128. package/src/turn-metrics.test.ts +503 -0
  129. package/src/twilio-auth.test.ts +36 -0
  130. package/src/twilio.test.ts +1274 -0
  131. package/src/websocket-close.test.ts +63 -0
  132. package/src/websocket-lifecycle.test.ts +49 -0
@@ -0,0 +1,440 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // WT-03: Browser outbound pacing + playout clock + client jitter buffer
3
+
4
+ import { createServer } from "node:http";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import WebSocket from "ws";
7
+ import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
8
+ import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
9
+ import { createVoiceWebSocketServer, type VoiceWebSocketServer } from "./index.js";
10
+
11
+ function browserUrl(port: number): string {
12
+ return `ws://127.0.0.1:${port}/ws`;
13
+ }
14
+
15
+ async function openBrowserSocketReady(url: string): Promise<WebSocket> {
16
+ const socket = new WebSocket(url);
17
+ await new Promise<void>((resolve, reject) => {
18
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
19
+ if (isBinary) return;
20
+ try {
21
+ const parsed = JSON.parse(data.toString()) as { type?: string };
22
+ if (parsed.type === "ready") {
23
+ socket.off("message", onMessage);
24
+ resolve();
25
+ }
26
+ } catch {
27
+ // Ignore parsing errors
28
+ }
29
+ };
30
+ socket.on("message", onMessage);
31
+ socket.once("error", reject);
32
+ });
33
+ return socket;
34
+ }
35
+
36
+ function waitMs(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
41
+ const startedAt = Date.now();
42
+ while (Date.now() - startedAt < timeoutMs) {
43
+ if (predicate()) return;
44
+ await waitMs(10);
45
+ }
46
+ throw new Error("Timed out waiting for browser pacing condition");
47
+ }
48
+
49
+ async function readJsonMatching(socket: WebSocket, predicate: (m: unknown) => boolean): Promise<unknown> {
50
+ return new Promise((resolve) => {
51
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
52
+ if (isBinary) return;
53
+ const message = JSON.parse(data.toString()) as unknown;
54
+ if (!predicate(message)) return;
55
+ socket.off("message", onMessage);
56
+ resolve(message);
57
+ };
58
+ socket.on("message", onMessage);
59
+ });
60
+ }
61
+
62
+ function collectMessagesMatching(
63
+ socket: WebSocket,
64
+ predicate: (m: unknown) => boolean,
65
+ timeoutMs: number
66
+ ): Promise<unknown[]> {
67
+ return new Promise((resolve) => {
68
+ const messages: unknown[] = [];
69
+ const timeout = setTimeout(() => {
70
+ socket.off("message", onMessage);
71
+ resolve(messages);
72
+ }, timeoutMs);
73
+
74
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
75
+ if (isBinary) return;
76
+ try {
77
+ const message = JSON.parse(data.toString()) as unknown;
78
+ if (predicate(message)) {
79
+ messages.push(message);
80
+ }
81
+ } catch {
82
+ // Ignore parsing errors
83
+ }
84
+ };
85
+ socket.on("message", onMessage);
86
+ });
87
+ }
88
+
89
+ // Track servers so we can force-close them in afterEach even if test fails
90
+ let activeServers: VoiceWebSocketServer[] = [];
91
+ let activeHttpServers: ReturnType<typeof createServer>[] = [];
92
+
93
+ beforeEach(() => {
94
+ activeServers = [];
95
+ activeHttpServers = [];
96
+ });
97
+
98
+ afterEach(async () => {
99
+ for (const server of activeServers) {
100
+ await server.close().catch(() => undefined);
101
+ }
102
+ for (const httpServer of activeHttpServers) {
103
+ httpServer.close();
104
+ }
105
+ activeServers = [];
106
+ activeHttpServers = [];
107
+ });
108
+
109
+ describe("WT-03 Browser outbound pacing", () => {
110
+ it("browser adapter enqueues paced frames with consistent timing", async () => {
111
+ const httpServer = createServer();
112
+ activeHttpServers.push(httpServer);
113
+
114
+ let session: VoiceAgentSession | null = null;
115
+ const server = await createVoiceWebSocketServer({
116
+ server: httpServer,
117
+ port: 0, // Let the system assign a port
118
+ createSession: () => {
119
+ session = new VoiceAgentSession({ plugins: {} });
120
+ return session;
121
+ },
122
+ outboundFrameDurationMs: 20,
123
+ });
124
+ activeServers.push(server);
125
+
126
+ const port = (server.address() as any).port;
127
+ const socket = await openBrowserSocketReady(browserUrl(port));
128
+
129
+ // Session is already started by the websocket server
130
+
131
+ // Send TTS audio - 160ms of 16kHz PCM16 (should be split into ~8 frames of 20ms each)
132
+ const sampleCount = Math.floor(0.16 * 16000); // 160ms at 16kHz
133
+ const audioSamples = new Int16Array(sampleCount).fill(1000); // Simple sine-like values
134
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
135
+
136
+ // Collect tts_chunk messages over 200ms to ensure all frames arrive
137
+ const ttsChunksPromise = collectMessagesMatching(
138
+ socket,
139
+ (m: any) => m.type === "tts_chunk",
140
+ 300
141
+ );
142
+
143
+ // Send TTS audio
144
+ session!.bus.push(Route.Main, {
145
+ kind: "tts.audio",
146
+ contextId: "test-turn",
147
+ timestampMs: Date.now(),
148
+ audio: audioBytes,
149
+ sampleRateHz: 16000,
150
+ });
151
+
152
+ // End TTS
153
+ session!.bus.push(Route.Main, {
154
+ kind: "tts.end",
155
+ contextId: "test-turn",
156
+ timestampMs: Date.now(),
157
+ });
158
+
159
+ const ttsChunks = await ttsChunksPromise;
160
+ socket.close();
161
+
162
+ // Verify we got multiple frames (pacing is working)
163
+ expect(ttsChunks.length).toBeGreaterThan(1);
164
+
165
+ // Verify each frame has expected structure
166
+ for (const chunk of ttsChunks) {
167
+ const c = chunk as any;
168
+ expect(c.type).toBe("tts_chunk");
169
+ expect(c.turnId).toBe("test-turn");
170
+ expect(c.sequence).toBeGreaterThan(0);
171
+ expect(c.sampleRateHz).toBe(48000); // opus frames carry the 48 kHz codec rate
172
+ expect(c.encoding).toBe("opus");
173
+ expect(c.channels).toBe(1);
174
+ expect(c.byteLength).toBeGreaterThan(0);
175
+ expect(c.durationMs).toBeGreaterThan(0);
176
+ }
177
+
178
+ // Verify sequence numbers increment
179
+ const sequences = ttsChunks.map((c: any) => c.sequence);
180
+ for (let i = 1; i < sequences.length; i++) {
181
+ expect(sequences[i]).toBe(sequences[i - 1] + 1);
182
+ }
183
+ });
184
+
185
+ it("emits tts.playout_progress events during paced playback", async () => {
186
+ const httpServer = createServer();
187
+ activeHttpServers.push(httpServer);
188
+
189
+ let session: VoiceAgentSession | null = null;
190
+ const server = await createVoiceWebSocketServer({
191
+ server: httpServer,
192
+ port: 0,
193
+ createSession: () => {
194
+ session = new VoiceAgentSession({ plugins: {} });
195
+ return session;
196
+ },
197
+ outboundFrameDurationMs: 20,
198
+ });
199
+ activeServers.push(server);
200
+
201
+ const port = (server.address() as any).port;
202
+ const socket = await openBrowserSocketReady(browserUrl(port));
203
+
204
+ // Session is already started by the websocket server
205
+
206
+ // Track playout progress events
207
+ const progressEvents: any[] = [];
208
+ const removeProgressHandler = session!.bus.on("tts.playout_progress", (pkt: any) => {
209
+ if (pkt.contextId === "test-turn") {
210
+ progressEvents.push(pkt);
211
+ }
212
+ });
213
+
214
+ // Send TTS audio - 80ms worth
215
+ const sampleCount = Math.floor(0.08 * 16000);
216
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
217
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
218
+
219
+ session!.bus.push(Route.Main, {
220
+ kind: "tts.audio",
221
+ contextId: "test-turn",
222
+ timestampMs: Date.now(),
223
+ audio: audioBytes,
224
+ sampleRateHz: 16000,
225
+ });
226
+
227
+ session!.bus.push(Route.Main, {
228
+ kind: "tts.end",
229
+ contextId: "test-turn",
230
+ timestampMs: Date.now(),
231
+ });
232
+
233
+ // Wait for playout to complete
234
+ await waitMs(150);
235
+
236
+ socket.close();
237
+ removeProgressHandler();
238
+
239
+ // Should have received playout progress events
240
+ expect(progressEvents.length).toBeGreaterThan(0);
241
+
242
+ // Last event should be completion
243
+ const lastEvent = progressEvents[progressEvents.length - 1];
244
+ expect(lastEvent.complete).toBe(true);
245
+ });
246
+
247
+ it("handles interrupt.tts by clearing playout queue and sending audio_clear", async () => {
248
+ const httpServer = createServer();
249
+ activeHttpServers.push(httpServer);
250
+
251
+ let session: VoiceAgentSession | null = null;
252
+ const server = await createVoiceWebSocketServer({
253
+ server: httpServer,
254
+ port: 0,
255
+ createSession: () => {
256
+ session = new VoiceAgentSession({ plugins: {} });
257
+ return session;
258
+ },
259
+ outboundFrameDurationMs: 20,
260
+ });
261
+ activeServers.push(server);
262
+
263
+ const port = (server.address() as any).port;
264
+ const socket = await openBrowserSocketReady(browserUrl(port));
265
+
266
+ // Session is already started by the websocket server
267
+
268
+ // Send a longer TTS audio burst
269
+ const sampleCount = Math.floor(0.2 * 16000); // 200ms
270
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
271
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
272
+
273
+ session!.bus.push(Route.Main, {
274
+ kind: "tts.audio",
275
+ contextId: "test-turn",
276
+ timestampMs: Date.now(),
277
+ audio: audioBytes,
278
+ sampleRateHz: 16000,
279
+ });
280
+
281
+ // Wait a bit for some frames to be queued
282
+ await waitMs(10);
283
+
284
+ // Listen for audio_clear message
285
+ const audioClearPromise = readJsonMatching(socket, (m: any) => m.type === "audio_clear");
286
+
287
+ // Send interrupt
288
+ session!.bus.push(Route.Main, {
289
+ kind: "interrupt.tts",
290
+ contextId: "test-turn",
291
+ timestampMs: Date.now(),
292
+ });
293
+
294
+ const audioClear = await audioClearPromise;
295
+ socket.close();
296
+
297
+ expect((audioClear as any).type).toBe("audio_clear");
298
+ expect((audioClear as any).turnId).toBe("test-turn");
299
+ expect((audioClear as any).reason).toBe("barge_in");
300
+ });
301
+
302
+ it("respects custom outbound frame duration and queue limits", async () => {
303
+ const httpServer = createServer();
304
+ activeHttpServers.push(httpServer);
305
+
306
+ let session: VoiceAgentSession | null = null;
307
+ const server = await createVoiceWebSocketServer({
308
+ server: httpServer,
309
+ port: 0,
310
+ createSession: () => {
311
+ session = new VoiceAgentSession({ plugins: {} });
312
+ return session;
313
+ },
314
+ outboundFrameDurationMs: 40, // Larger frames
315
+ maxQueuedOutputAudioMs: 100, // Small queue to trigger overflow
316
+ });
317
+ activeServers.push(server);
318
+
319
+ const port = (server.address() as any).port;
320
+ const socket = await openBrowserSocketReady(browserUrl(port));
321
+
322
+ // Session is already started by the websocket server
323
+
324
+ // Send large audio burst to trigger overflow
325
+ const sampleCount = Math.floor(1.0 * 16000); // 1 second
326
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
327
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
328
+
329
+ const ttsChunksPromise = collectMessagesMatching(
330
+ socket,
331
+ (m: any) => m.type === "tts_chunk",
332
+ 200
333
+ );
334
+
335
+ session!.bus.push(Route.Main, {
336
+ kind: "tts.audio",
337
+ contextId: "test-turn",
338
+ timestampMs: Date.now(),
339
+ audio: audioBytes,
340
+ sampleRateHz: 16000,
341
+ });
342
+
343
+ const ttsChunks = await ttsChunksPromise;
344
+ socket.close();
345
+
346
+ // Tail-drop overflow: the head of the burst (up to the 100ms cap) still
347
+ // plays out — a tiny cap must never silence the stream entirely.
348
+ expect(ttsChunks.length).toBeGreaterThan(0);
349
+ expect(ttsChunks.length).toBeLessThan(20);
350
+ const firstChunk = ttsChunks[0] as any;
351
+ expect(firstChunk.durationMs).toBeGreaterThan(0);
352
+ });
353
+
354
+ it("delivers a long reply in full under the default playout bound (no overflow)", async () => {
355
+ const httpServer = createServer();
356
+ activeHttpServers.push(httpServer);
357
+
358
+ let session: VoiceAgentSession | null = null;
359
+ const metrics: Array<{ name: string; value: string }> = [];
360
+ const server = await createVoiceWebSocketServer({
361
+ server: httpServer,
362
+ port: 0,
363
+ createSession: () => {
364
+ session = new VoiceAgentSession({ plugins: {} });
365
+ return session;
366
+ },
367
+ });
368
+ activeServers.push(server);
369
+
370
+ const port = (server.address() as any).port;
371
+ const socket = await openBrowserSocketReady(browserUrl(port));
372
+ session!.bus.on("metric.conversation", (pkt) => {
373
+ const metric = pkt as unknown as { name: string; value: string };
374
+ metrics.push(metric);
375
+ });
376
+
377
+ const ttsChunksPromise = collectMessagesMatching(
378
+ socket,
379
+ (m: any) => m.type === "tts_chunk",
380
+ 1_200, // collect for the full paced second
381
+ );
382
+ // A 1s burst — far beyond the old 200ms cap that used to clear-and-stop.
383
+ session!.bus.push(Route.Main, {
384
+ kind: "tts.audio",
385
+ contextId: "default-bound-turn",
386
+ timestampMs: Date.now(),
387
+ audio: pcm16SamplesToBytes(new Int16Array(16000)),
388
+ sampleRateHz: 16000,
389
+ });
390
+
391
+ const ttsChunks = await ttsChunksPromise;
392
+ socket.close();
393
+
394
+ expect(ttsChunks.length).toBeGreaterThanOrEqual(40); // ≥800ms of the reply paced out
395
+ expect(metrics.some((m) => m.name.includes("overflow"))).toBe(false);
396
+ });
397
+
398
+ it("allows long playout queues only through an explicit override", async () => {
399
+ const httpServer = createServer();
400
+ activeHttpServers.push(httpServer);
401
+
402
+ let session: VoiceAgentSession | null = null;
403
+ const server = await createVoiceWebSocketServer({
404
+ server: httpServer,
405
+ port: 0,
406
+ createSession: () => {
407
+ session = new VoiceAgentSession({ plugins: {} });
408
+ return session;
409
+ },
410
+ maxQueuedOutputAudioMs: 1000,
411
+ });
412
+ activeServers.push(server);
413
+
414
+ const port = (server.address() as any).port;
415
+ const socket = await openBrowserSocketReady(browserUrl(port));
416
+ let closed = false;
417
+ socket.once("close", () => {
418
+ closed = true;
419
+ });
420
+
421
+ const ttsChunksPromise = collectMessagesMatching(
422
+ socket,
423
+ (m: any) => m.type === "tts_chunk",
424
+ 150,
425
+ );
426
+ session!.bus.push(Route.Main, {
427
+ kind: "tts.audio",
428
+ contextId: "explicit-long-queue-turn",
429
+ timestampMs: Date.now(),
430
+ audio: pcm16SamplesToBytes(new Int16Array(6400)),
431
+ sampleRateHz: 16000,
432
+ });
433
+
434
+ const chunks = await ttsChunksPromise;
435
+ socket.close();
436
+
437
+ expect(chunks.length).toBeGreaterThan(0);
438
+ expect(closed).toBe(false);
439
+ });
440
+ });
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Unit tests for pure carrier command constructors + mockable dispatch.
4
+ // Live carrier HTTP is NOT exercised (no credentials).
5
+
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import {
8
+ buildTelnyxSendDtmf,
9
+ buildTelnyxTransfer,
10
+ buildTwilioSendDigits,
11
+ buildTwilioTransfer,
12
+ dispatchTelnyxCommand,
13
+ dispatchTwilioCommand,
14
+ resolveTransferSummary,
15
+ type FetchLike,
16
+ } from "./carrier-commands.js";
17
+
18
+ describe("buildTwilioSendDigits", () => {
19
+ it("maps digits + pause syntax into SendDigits and TwiML Play", () => {
20
+ const cmd = buildTwilioSendDigits("CAxxx", { digits: "1w2W9*#" });
21
+ expect(cmd.carrier).toBe("twilio");
22
+ expect(cmd.path).toBe("/Calls/CAxxx.json");
23
+ expect(cmd.form.SendDigits).toBe("1w2W9*#");
24
+ expect(cmd.twiml).toBe('<Response><Play digits="1w2W9*#"/></Response>');
25
+ });
26
+
27
+ it("URL-encodes call SID in the path", () => {
28
+ const cmd = buildTwilioSendDigits("CA/special", { digits: "0" });
29
+ expect(cmd.path).toBe("/Calls/CA%2Fspecial.json");
30
+ });
31
+ });
32
+
33
+ describe("buildTelnyxSendDtmf", () => {
34
+ it("builds Call Control send_dtmf payload with pause letters retained", () => {
35
+ const cmd = buildTelnyxSendDtmf("v3:abc", { digits: "9w0W#" }, { durationMillis: 250 });
36
+ expect(cmd.carrier).toBe("telnyx");
37
+ expect(cmd.path).toBe("/v2/calls/v3%3Aabc/actions/send_dtmf");
38
+ expect(cmd.json).toEqual({ digits: "9w0W#", duration_millis: 250 });
39
+ });
40
+ });
41
+
42
+ describe("buildTwilioTransfer", () => {
43
+ it("cold transfer emits Dial TwiML for E.164", () => {
44
+ const cmd = buildTwilioTransfer("CAcall", {
45
+ mode: "cold",
46
+ target: "+15551234567",
47
+ });
48
+ expect(cmd.twiml).toBe("<Response><Dial>+15551234567</Dial></Response>");
49
+ expect(cmd.form.Twiml).toContain("<Dial>");
50
+ });
51
+
52
+ it("warm transfer carries summary in a TwiML comment and command field", () => {
53
+ const cmd = buildTwilioTransfer("CAcall", {
54
+ mode: "warm",
55
+ target: "+15557654321",
56
+ summary: "Caller asked about billing",
57
+ });
58
+ expect(cmd.summary).toBe("Caller asked about billing");
59
+ expect(cmd.twiml).toContain("<!-- warm-handoff: Caller asked about billing -->");
60
+ expect(cmd.twiml).toContain("<Dial>+15557654321</Dial>");
61
+ });
62
+
63
+ it("sip_refer still uses Dial/Sip (not raw REFER) for answer-rate", () => {
64
+ const cmd = buildTwilioTransfer("CAcall", {
65
+ mode: "sip_refer",
66
+ target: "sip:agent@example.com",
67
+ });
68
+ expect(cmd.twiml).toBe("<Response><Dial><Sip>sip:agent@example.com</Sip></Dial></Response>");
69
+ });
70
+
71
+ it("redirectUrl mode posts Url instead of inline Twiml", () => {
72
+ const cmd = buildTwilioTransfer(
73
+ "CAcall",
74
+ { mode: "cold", target: "+15550001111" },
75
+ { redirectUrl: "https://example.com/twiml" },
76
+ );
77
+ expect(cmd.form).toEqual({ Url: "https://example.com/twiml", Method: "POST" });
78
+ });
79
+ });
80
+
81
+ describe("buildTelnyxTransfer", () => {
82
+ it("cold transfer posts to Call Control transfer action", () => {
83
+ const cmd = buildTelnyxTransfer("v3:id", { mode: "cold", target: "+15551234567" });
84
+ expect(cmd.path).toBe("/v2/calls/v3%3Aid/actions/transfer");
85
+ expect(cmd.json.to).toBe("+15551234567");
86
+ expect(cmd.json.client_state).toBeUndefined();
87
+ });
88
+
89
+ it("warm transfer encodes summary into client_state", () => {
90
+ const cmd = buildTelnyxTransfer("v3:id", {
91
+ mode: "warm",
92
+ target: "+15550001111",
93
+ summary: "Needs refund help",
94
+ });
95
+ expect(cmd.summary).toBe("Needs refund help");
96
+ expect(cmd.json.client_state).toBeTypeOf("string");
97
+ const raw = cmd.json.client_state!;
98
+ // base64url → standard base64 with correct padding
99
+ let b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
100
+ while (b64.length % 4 !== 0) b64 += "=";
101
+ const json = JSON.parse(atob(b64)) as { summary: string; mode: string };
102
+ expect(json).toEqual({ summary: "Needs refund help", mode: "warm" });
103
+ });
104
+
105
+ it("sip_refer mode still uses transfer action with SIP URI as to", () => {
106
+ const cmd = buildTelnyxTransfer("v3:id", {
107
+ mode: "sip_refer",
108
+ target: "sip:desk@pbx.example",
109
+ });
110
+ expect(cmd.path).toContain("/actions/transfer");
111
+ expect(cmd.json.to).toBe("sip:desk@pbx.example");
112
+ });
113
+ });
114
+
115
+ describe("resolveTransferSummary (warm seam)", () => {
116
+ it("uses packet.summary when provided", async () => {
117
+ const summary = await resolveTransferSummary({
118
+ kind: "call.transfer",
119
+ contextId: "c1",
120
+ timestampMs: 1,
121
+ mode: "warm",
122
+ target: "+1",
123
+ summary: "from packet",
124
+ });
125
+ expect(summary).toBe("from packet");
126
+ });
127
+
128
+ it("calls injectable summarizer when warm and summary missing", async () => {
129
+ const summarizer = vi.fn(async () => "from seam");
130
+ const summary = await resolveTransferSummary(
131
+ {
132
+ kind: "call.transfer",
133
+ contextId: "c1",
134
+ timestampMs: 1,
135
+ mode: "warm",
136
+ target: "+1555",
137
+ },
138
+ summarizer,
139
+ );
140
+ expect(summarizer).toHaveBeenCalledWith({ contextId: "c1", target: "+1555" });
141
+ expect(summary).toBe("from seam");
142
+ });
143
+
144
+ it("does not call summarizer for cold transfer", async () => {
145
+ const summarizer = vi.fn(async () => "nope");
146
+ const summary = await resolveTransferSummary(
147
+ {
148
+ kind: "call.transfer",
149
+ contextId: "c1",
150
+ timestampMs: 1,
151
+ mode: "cold",
152
+ target: "+1555",
153
+ },
154
+ summarizer,
155
+ );
156
+ expect(summarizer).not.toHaveBeenCalled();
157
+ expect(summary).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("dispatch via fetch (mockable, no live carrier)", () => {
162
+ it("dispatchTwilioCommand posts form body with Basic auth", async () => {
163
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
164
+ const fetchImpl: FetchLike = async (url, init) => {
165
+ calls.push({ url, init });
166
+ return new Response("{}", { status: 200 });
167
+ };
168
+ const cmd = buildTwilioSendDigits("CAsid", { digits: "12" });
169
+ await dispatchTwilioCommand(
170
+ { accountSid: "ACacct", authToken: "secret" },
171
+ cmd,
172
+ fetchImpl,
173
+ );
174
+ expect(calls).toHaveLength(1);
175
+ expect(calls[0]!.url).toContain("/2010-04-01/Accounts/ACacct/Calls/CAsid.json");
176
+ expect(calls[0]!.init?.method).toBe("POST");
177
+ const headers = calls[0]!.init?.headers as Record<string, string>;
178
+ expect(headers.Authorization).toMatch(/^Basic /);
179
+ expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
180
+ expect(calls[0]!.init?.body).toBe("SendDigits=12");
181
+ });
182
+
183
+ it("dispatchTelnyxCommand posts JSON with Bearer auth", async () => {
184
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
185
+ const fetchImpl: FetchLike = async (url, init) => {
186
+ calls.push({ url, init });
187
+ return new Response("{}", { status: 200 });
188
+ };
189
+ const cmd = buildTelnyxTransfer("v3:x", { mode: "cold", target: "+1555" });
190
+ await dispatchTelnyxCommand({ apiKey: "KEY" }, cmd, fetchImpl);
191
+ expect(calls[0]!.url).toBe("https://api.telnyx.com/v2/calls/v3%3Ax/actions/transfer");
192
+ expect(calls[0]!.init?.headers).toMatchObject({
193
+ Authorization: "Bearer KEY",
194
+ "Content-Type": "application/json",
195
+ });
196
+ expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ to: "+1555" });
197
+ });
198
+ });