@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,51 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { IncomingMessage } from "node:http";
5
+ import { WebSocket } from "ws";
6
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
7
+ import { runWebSocketConnection, type TransportAdapter } from "./transport-host.js";
8
+
9
+ const hostConfig = {
10
+ heartbeatIntervalMs: 30_000,
11
+ startupTimeoutMs: 500,
12
+ maxSessionDurationMs: 60_000,
13
+ maxBufferedAmountBytes: 1_000_000,
14
+ maxInboundMessageBytes: 1_000_000,
15
+ };
16
+
17
+ function closedSocket(): WebSocket {
18
+ return {
19
+ readyState: WebSocket.CLOSED,
20
+ on: () => undefined,
21
+ close: () => undefined,
22
+ } as unknown as WebSocket;
23
+ }
24
+
25
+ describe("runWebSocketConnection startup", () => {
26
+ it("does not acquire a session when the socket is already closed before startup", async () => {
27
+ const acquireSession = vi.fn(async () => ({
28
+ session: new VoiceAgentSession({ plugins: {} }),
29
+ resumed: false,
30
+ }));
31
+ const adapter: TransportAdapter<null> = {
32
+ createState: () => null,
33
+ acquireSession,
34
+ wireSession: () => () => undefined,
35
+ processMessage: () => undefined,
36
+ onDisconnect: () => undefined,
37
+ onStartupTimeout: () => undefined,
38
+ sendError: () => undefined,
39
+ sendStartupError: () => undefined,
40
+ };
41
+
42
+ await runWebSocketConnection(
43
+ closedSocket(),
44
+ { url: "/ws" } as IncomingMessage,
45
+ hostConfig,
46
+ adapter,
47
+ );
48
+
49
+ expect(acquireSession).not.toHaveBeenCalled();
50
+ });
51
+ });
@@ -0,0 +1,503 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
5
+ import {
6
+ buildBrowserMetricsMessage,
7
+ TurnMetricsTracker,
8
+ type BrowserMetricsMessage,
9
+ } from "./turn-metrics.js";
10
+ import { waitForCondition } from "./test-helpers.js";
11
+
12
+ describe("turn metrics", () => {
13
+ it("computes stage latencies from synthetic timestamps", () => {
14
+ const message = buildBrowserMetricsMessage("turn-a", {
15
+ speechEndMs: 1000,
16
+ sttFinalMs: 1200,
17
+ eosMs: 0,
18
+ vadStopHangoverMs: 0,
19
+ textReadyMs: 1500,
20
+ firstAudioByteMs: 1700,
21
+ firstAudioPlayedMs: 1900,
22
+ lastAudioPlayedMs: 2500,
23
+ });
24
+
25
+ expect(message).toEqual({
26
+ type: "metrics",
27
+ turnId: "turn-a",
28
+ correlationId: "turn-a",
29
+ speechEndMs: 1000,
30
+ textReadyMs: 1500,
31
+ firstAudioByteMs: 1700,
32
+ firstAudioPlayedMs: 1900,
33
+ lastAudioPlayedMs: 2500,
34
+ sttMs: 200,
35
+ llmTTFTMs: 300,
36
+ ttsTTFBMs: 200,
37
+ e2eMs: 900,
38
+ eouBudgetMs: {
39
+ sttFinalDelayMs: 200,
40
+ totalMs: 200,
41
+ },
42
+ });
43
+ });
44
+
45
+ it("buildBrowserMetricsMessage eou budget sums hangover, stt-final, and endpoint delays", () => {
46
+ const message = buildBrowserMetricsMessage("turn-eou-unit", {
47
+ speechEndMs: 1000,
48
+ sttFinalMs: 1250,
49
+ eosMs: 1300,
50
+ vadStopHangoverMs: 80,
51
+ textReadyMs: 0,
52
+ firstAudioByteMs: 0,
53
+ firstAudioPlayedMs: 0,
54
+ lastAudioPlayedMs: 0,
55
+ });
56
+
57
+ expect(message.eouBudgetMs).toEqual({
58
+ vadStopHangoverMs: 80,
59
+ sttFinalDelayMs: 250,
60
+ endpointDelayMs: 50,
61
+ totalMs: 380,
62
+ });
63
+ expect(message.sttMs).toBe(250);
64
+ });
65
+
66
+ // The edge/DO transport is client-paced: the browser reports its own playout clock
67
+ // and edge.ts forwards it, but nothing emits `tts.playout_started` there. These two
68
+ // pin down that `finalizeOnTtsEnd` is a floor for silent clients only — it must not
69
+ // pre-empt a client that IS reporting, because tts.end (synthesis done) always
70
+ // precedes playout completion (playback done).
71
+ describe("finalizeOnTtsEnd — the edge floor", () => {
72
+ const driveTurnToTtsEnd = (session: VoiceAgentSession, contextId: string): void => {
73
+ session.bus.push(Route.Main, { kind: "vad.speech_ended", contextId, timestampMs: 500 });
74
+ session.bus.push(Route.Main, {
75
+ kind: "stt.result", contextId, timestampMs: 700, text: "hello", confidence: 0.99,
76
+ });
77
+ session.bus.push(Route.Main, { kind: "llm.delta", contextId, timestampMs: 900, text: "hi" });
78
+ session.bus.push(Route.Main, {
79
+ kind: "tts.audio", contextId, timestampMs: 1100, audio: new Uint8Array(640), sampleRateHz: 16000,
80
+ });
81
+ };
82
+
83
+ it("does not pre-empt a client that reports playout, keeping the played marks", async () => {
84
+ const session = new VoiceAgentSession({ plugins: {} });
85
+ const emitted: BrowserMetricsMessage[] = [];
86
+ const tracker = new TurnMetricsTracker(
87
+ session.bus, (m) => emitted.push(m), undefined, { finalizeOnTtsEnd: true },
88
+ );
89
+ tracker.wire([]);
90
+ void session.start();
91
+
92
+ driveTurnToTtsEnd(session, "reporting");
93
+ // Browser starts playing and says so — no playout_started on this transport.
94
+ session.bus.push(Route.Main, {
95
+ kind: "tts.playout_progress", contextId: "reporting", timestampMs: 1200,
96
+ playedOutMs: 40, complete: false,
97
+ });
98
+ // Synthesis finishes BEFORE playback does. The floor must stay its hand here.
99
+ session.bus.push(Route.Main, { kind: "tts.end", contextId: "reporting", timestampMs: 1300 });
100
+ await new Promise((r) => setTimeout(r, 50));
101
+ expect(emitted, "tts.end must not finalize a reporting client").toHaveLength(0);
102
+
103
+ // Playback actually finishes.
104
+ session.bus.push(Route.Main, {
105
+ kind: "tts.playout_progress", contextId: "reporting", timestampMs: 4000,
106
+ playedOutMs: 2800, complete: true,
107
+ });
108
+ await waitForCondition(() => emitted.length === 1);
109
+ const message = emitted[0];
110
+ // The whole point: these survive, and e2e is measured to audio PLAYED, not to
111
+ // first byte — the same semantics the Node path reports.
112
+ expect(message?.firstAudioPlayedMs).toBe(1200);
113
+ expect(message?.lastAudioPlayedMs).toBe(4000);
114
+ expect(message?.e2eMs).toBe(700);
115
+ });
116
+
117
+ it("floors on tts.end for a client that never reports playout", async () => {
118
+ const session = new VoiceAgentSession({ plugins: {} });
119
+ const emitted: BrowserMetricsMessage[] = [];
120
+ const tracker = new TurnMetricsTracker(
121
+ session.bus, (m) => emitted.push(m), undefined, { finalizeOnTtsEnd: true },
122
+ );
123
+ tracker.wire([]);
124
+ void session.start();
125
+
126
+ driveTurnToTtsEnd(session, "silent");
127
+ session.bus.push(Route.Main, { kind: "tts.end", contextId: "silent", timestampMs: 1300 });
128
+ await waitForCondition(() => emitted.length === 1);
129
+
130
+ const message = emitted[0];
131
+ expect(message?.ttsTTFBMs).toBe(200);
132
+ // Never measured, so omitted rather than zeroed.
133
+ expect(message?.firstAudioPlayedMs).toBeUndefined();
134
+ expect(message?.lastAudioPlayedMs).toBeUndefined();
135
+ });
136
+
137
+ it("leaves the Node path alone — no floor unless opted in", async () => {
138
+ const session = new VoiceAgentSession({ plugins: {} });
139
+ const emitted: BrowserMetricsMessage[] = [];
140
+ const tracker = new TurnMetricsTracker(session.bus, (m) => emitted.push(m));
141
+ tracker.wire([]);
142
+ void session.start();
143
+
144
+ driveTurnToTtsEnd(session, "node");
145
+ session.bus.push(Route.Main, { kind: "tts.end", contextId: "node", timestampMs: 1300 });
146
+ await new Promise((r) => setTimeout(r, 50));
147
+ expect(emitted, "default must not finalize on tts.end").toHaveLength(0);
148
+ });
149
+ });
150
+
151
+ it("keeps correlation id stable for the turn context", async () => {
152
+ const session = new VoiceAgentSession({ plugins: {} });
153
+ const emitted: unknown[] = [];
154
+ const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
155
+ const disposers: Array<() => void> = [];
156
+ tracker.wire(disposers);
157
+ void session.start();
158
+
159
+ session.bus.push(Route.Main, {
160
+ kind: "vad.speech_ended",
161
+ contextId: "turn-correlation",
162
+ timestampMs: 500,
163
+ });
164
+ session.bus.push(Route.Main, {
165
+ kind: "stt.result",
166
+ contextId: "turn-correlation",
167
+ timestampMs: 700,
168
+ text: "hello",
169
+ confidence: 0.99,
170
+ });
171
+ session.bus.push(Route.Main, {
172
+ kind: "llm.delta",
173
+ contextId: "turn-correlation",
174
+ timestampMs: 900,
175
+ text: "hi",
176
+ });
177
+ session.bus.push(Route.Main, {
178
+ kind: "tts.audio",
179
+ contextId: "turn-correlation",
180
+ timestampMs: 1100,
181
+ audio: new Uint8Array(640),
182
+ sampleRateHz: 16000,
183
+ });
184
+ session.bus.push(Route.Main, {
185
+ kind: "tts.playout_started",
186
+ contextId: "turn-correlation",
187
+ timestampMs: 1100,
188
+ });
189
+ session.bus.push(Route.Main, {
190
+ kind: "tts.playout_progress",
191
+ contextId: "turn-correlation",
192
+ timestampMs: 1300,
193
+ playedOutMs: 200,
194
+ complete: false,
195
+ });
196
+ session.bus.push(Route.Main, {
197
+ kind: "tts.playout_progress",
198
+ contextId: "turn-correlation",
199
+ timestampMs: 1800,
200
+ playedOutMs: 120,
201
+ complete: true,
202
+ });
203
+
204
+ await waitForCondition(() => emitted.length === 1);
205
+ expect(emitted[0]).toMatchObject({
206
+ type: "metrics",
207
+ turnId: "turn-correlation",
208
+ correlationId: "turn-correlation",
209
+ sttMs: 200,
210
+ llmTTFTMs: 200,
211
+ ttsTTFBMs: 200,
212
+ e2eMs: 600,
213
+ });
214
+
215
+ for (const dispose of disposers) dispose();
216
+ await session.close();
217
+ });
218
+
219
+ it("records firstAudioPlayedMs from playout_started, not throttled progress", async () => {
220
+ const session = new VoiceAgentSession({ plugins: {} });
221
+ const emitted: unknown[] = [];
222
+ const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
223
+ const disposers: Array<() => void> = [];
224
+ tracker.wire(disposers);
225
+ void session.start();
226
+
227
+ session.bus.push(Route.Main, {
228
+ kind: "vad.speech_ended",
229
+ contextId: "turn-throttle",
230
+ timestampMs: 1000,
231
+ });
232
+ session.bus.push(Route.Main, {
233
+ kind: "tts.playout_started",
234
+ contextId: "turn-throttle",
235
+ timestampMs: 1100,
236
+ });
237
+ session.bus.push(Route.Main, {
238
+ kind: "tts.playout_progress",
239
+ contextId: "turn-throttle",
240
+ timestampMs: 1300,
241
+ playedOutMs: 200,
242
+ complete: true,
243
+ });
244
+
245
+ await waitForCondition(() => emitted.length === 1);
246
+ expect(emitted[0]).toMatchObject({
247
+ firstAudioPlayedMs: 1100,
248
+ e2eMs: 100,
249
+ });
250
+
251
+ for (const dispose of disposers) dispose();
252
+ await session.close();
253
+ });
254
+
255
+ it("emits metrics once when playout completes for a wired browser session", async () => {
256
+ const session = new VoiceAgentSession({ plugins: {} });
257
+ const emitted: unknown[] = [];
258
+ const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
259
+ const disposers: Array<() => void> = [];
260
+ tracker.wire(disposers);
261
+ void session.start();
262
+
263
+ session.bus.push(Route.Main, {
264
+ kind: "vad.speech_ended",
265
+ contextId: "turn-live",
266
+ timestampMs: 10_000,
267
+ });
268
+ session.bus.push(Route.Main, {
269
+ kind: "stt.result",
270
+ contextId: "turn-live",
271
+ timestampMs: 10_200,
272
+ text: "hello",
273
+ confidence: 0.99,
274
+ });
275
+ session.bus.push(Route.Main, {
276
+ kind: "llm.delta",
277
+ contextId: "turn-live",
278
+ timestampMs: 10_450,
279
+ text: "hi",
280
+ });
281
+ session.bus.push(Route.Main, {
282
+ kind: "tts.audio",
283
+ contextId: "turn-live",
284
+ timestampMs: 10_600,
285
+ audio: new Uint8Array(640),
286
+ sampleRateHz: 16000,
287
+ });
288
+ session.bus.push(Route.Main, {
289
+ kind: "tts.playout_started",
290
+ contextId: "turn-live",
291
+ timestampMs: 10_600,
292
+ });
293
+ session.bus.push(Route.Main, {
294
+ kind: "tts.playout_progress",
295
+ contextId: "turn-live",
296
+ timestampMs: 10_800,
297
+ playedOutMs: 200,
298
+ complete: false,
299
+ });
300
+ session.bus.push(Route.Main, {
301
+ kind: "tts.playout_progress",
302
+ contextId: "turn-live",
303
+ timestampMs: 11_000,
304
+ playedOutMs: 120,
305
+ complete: true,
306
+ });
307
+
308
+ await waitForCondition(() => emitted.length === 1);
309
+ expect(emitted[0]).toMatchObject({
310
+ type: "metrics",
311
+ turnId: "turn-live",
312
+ correlationId: "turn-live",
313
+ sttMs: 200,
314
+ llmTTFTMs: 250,
315
+ ttsTTFBMs: 150,
316
+ e2eMs: 600,
317
+ });
318
+
319
+ for (const dispose of disposers) dispose();
320
+ await session.close();
321
+ });
322
+
323
+ it("eou_budget_breakdown: vad hangover, stt-final delay, endpoint delay, and total", async () => {
324
+ const session = new VoiceAgentSession({ plugins: {} });
325
+ const emitted: unknown[] = [];
326
+ const tracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message));
327
+ const disposers: Array<() => void> = [];
328
+ tracker.wire(disposers);
329
+ void session.start();
330
+
331
+ session.bus.push(Route.Main, {
332
+ kind: "vad.speech_ended",
333
+ contextId: "turn-eou",
334
+ timestampMs: 1000,
335
+ });
336
+ session.bus.push(Route.Main, {
337
+ kind: "metric.conversation",
338
+ contextId: "turn-eou",
339
+ timestampMs: 1005,
340
+ name: "vad.stop_hangover_ms",
341
+ value: "80",
342
+ });
343
+ session.bus.push(Route.Main, {
344
+ kind: "stt.result",
345
+ contextId: "turn-eou",
346
+ timestampMs: 1250,
347
+ text: "hello",
348
+ confidence: 0.99,
349
+ });
350
+ session.bus.push(Route.Main, {
351
+ kind: "eos.turn_complete",
352
+ contextId: "turn-eou",
353
+ timestampMs: 1300,
354
+ text: "hello",
355
+ transcripts: [],
356
+ });
357
+ session.bus.push(Route.Main, {
358
+ kind: "tts.playout_progress",
359
+ contextId: "turn-eou",
360
+ timestampMs: 1500,
361
+ playedOutMs: 100,
362
+ complete: true,
363
+ });
364
+
365
+ await waitForCondition(() => emitted.length === 1);
366
+ expect(emitted[0]).toMatchObject({
367
+ type: "metrics",
368
+ turnId: "turn-eou",
369
+ correlationId: "turn-eou",
370
+ sttMs: 250,
371
+ eouBudgetMs: {
372
+ vadStopHangoverMs: 80,
373
+ sttFinalDelayMs: 250,
374
+ endpointDelayMs: 50,
375
+ totalMs: 380,
376
+ },
377
+ });
378
+
379
+ for (const dispose of disposers) dispose();
380
+ await session.close();
381
+ });
382
+
383
+ it("drops partial turn state on interrupt without emitting metrics", async () => {
384
+ const session = new VoiceAgentSession({ plugins: {} });
385
+ const emit = vi.fn();
386
+ const tracker = new TurnMetricsTracker(session.bus, emit);
387
+ tracker.wire([]);
388
+ void session.start();
389
+
390
+ session.bus.push(Route.Main, {
391
+ kind: "vad.speech_ended",
392
+ contextId: "turn-interrupted",
393
+ timestampMs: 1000,
394
+ });
395
+ session.bus.push(Route.Critical, {
396
+ kind: "interrupt.tts",
397
+ contextId: "turn-interrupted",
398
+ timestampMs: 1100,
399
+ });
400
+ session.bus.push(Route.Main, {
401
+ kind: "tts.playout_progress",
402
+ contextId: "turn-interrupted",
403
+ timestampMs: 1200,
404
+ playedOutMs: 20,
405
+ complete: true,
406
+ });
407
+
408
+ await waitForCondition(() => emit.mock.calls.length === 0, 200);
409
+ expect(emit).not.toHaveBeenCalled();
410
+
411
+ await session.close();
412
+ });
413
+ });
414
+
415
+ describe("turn metrics — endpointing decision", () => {
416
+ const emptyMarks = {
417
+ speechEndMs: 0,
418
+ sttFinalMs: 0,
419
+ eosMs: 0,
420
+ vadStopHangoverMs: 0,
421
+ textReadyMs: 0,
422
+ firstAudioByteMs: 0,
423
+ firstAudioPlayedMs: 0,
424
+ lastAudioPlayedMs: 0,
425
+ };
426
+
427
+ it("emits the owner/reason when the turn state carries them", () => {
428
+ const message = buildBrowserMetricsMessage("turn-dec", {
429
+ ...emptyMarks,
430
+ endpointingOwner: "smart_turn",
431
+ endpointingReason: "end_of_speech",
432
+ });
433
+ expect(message.endpointingOwner).toBe("smart_turn");
434
+ expect(message.endpointingReason).toBe("end_of_speech");
435
+ });
436
+
437
+ it("overrides the reason to force_finalized when the watchdog fired, even if the emitter said end_of_speech", () => {
438
+ // The completing eos may come from the STT plugin, which cannot know the
439
+ // force-finalize watchdog fired underneath it. The tracker reads that mark
440
+ // independently, so the truth wins here regardless of emitter.
441
+ const message = buildBrowserMetricsMessage("turn-forced", {
442
+ ...emptyMarks,
443
+ endpointingOwner: "provider_stt",
444
+ endpointingReason: "end_of_speech",
445
+ forceFinalized: true,
446
+ });
447
+ expect(message.endpointingOwner).toBe("provider_stt");
448
+ expect(message.endpointingReason).toBe("force_finalized");
449
+ });
450
+
451
+ it("omits the decision when the state has none (absent means absent)", () => {
452
+ const message = buildBrowserMetricsMessage("turn-none", { ...emptyMarks });
453
+ expect(message.endpointingOwner).toBeUndefined();
454
+ expect(message.endpointingReason).toBeUndefined();
455
+ });
456
+
457
+ it("captures owner from the completing eos and force-finalize from the watchdog metric", async () => {
458
+ const session = new VoiceAgentSession({ plugins: {} });
459
+ const emitted: BrowserMetricsMessage[] = [];
460
+ const tracker = new TurnMetricsTracker(session.bus, (m) => emitted.push(m));
461
+ const disposers: Array<() => void> = [];
462
+ tracker.wire(disposers);
463
+ void session.start();
464
+
465
+ // The completing eos carries owner=provider_stt, reason=end_of_speech ...
466
+ session.bus.push(Route.Main, {
467
+ kind: "eos.turn_complete",
468
+ contextId: "turn-dec",
469
+ timestampMs: 1000,
470
+ text: "hi",
471
+ transcripts: [],
472
+ endpointingOwner: "provider_stt",
473
+ endpointingReason: "end_of_speech",
474
+ });
475
+ // ... but the force-finalize watchdog fired for this turn — its mark arrives
476
+ // on the bus independently and must override the emitter's reason.
477
+ session.bus.push(Route.Background, {
478
+ kind: "metric.conversation",
479
+ contextId: "turn-dec",
480
+ timestampMs: 1050,
481
+ name: "stt.force_finalized",
482
+ value: "1",
483
+ });
484
+ // The watchdog mark rides the lower-priority Background route, which the bus
485
+ // drains only after Main. Let it land before playout completes (as it does in
486
+ // production, where the watchdog fires during STT, long before playback ends).
487
+ await new Promise((resolve) => setTimeout(resolve, 10));
488
+ session.bus.push(Route.Main, {
489
+ kind: "tts.playout_progress",
490
+ contextId: "turn-dec",
491
+ timestampMs: 2000,
492
+ playedOutMs: 0,
493
+ complete: true,
494
+ });
495
+
496
+ await waitForCondition(() => emitted.length === 1);
497
+ expect(emitted[0]!.endpointingOwner).toBe("provider_stt");
498
+ expect(emitted[0]!.endpointingReason).toBe("force_finalized");
499
+
500
+ for (const dispose of disposers) dispose();
501
+ await session.close();
502
+ });
503
+ });
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { validateTwilioSignature } from "./twilio-auth.js";
5
+
6
+ // Vector computed with the canonical HMAC-SHA1 algorithm (verified against
7
+ // node:crypto createHmac("sha1")) for these exact inputs — this pins that our
8
+ // Web Crypto implementation matches Twilio's documented signing scheme.
9
+ describe("validateTwilioSignature", () => {
10
+ const authToken = "12345";
11
+ const url = "https://mycompany.com/myapp.php?foo=1&bar=2";
12
+ const params = {
13
+ CallSid: "CA1234567890ABCDE",
14
+ Caller: "+14158675310",
15
+ Digits: "1234",
16
+ From: "+14158675310",
17
+ To: "+18005551212",
18
+ };
19
+ const validSignature = "GvWf1cFY/Q7PnoempGyD5oXAezc=";
20
+
21
+ it("accepts a correct signature", async () => {
22
+ expect(await validateTwilioSignature({ authToken, signature: validSignature, url, params })).toBe(true);
23
+ });
24
+
25
+ it("rejects a tampered signature", async () => {
26
+ expect(await validateTwilioSignature({ authToken, signature: "AAAA" + validSignature.slice(4), url, params })).toBe(false);
27
+ });
28
+
29
+ it("rejects a wrong auth token", async () => {
30
+ expect(await validateTwilioSignature({ authToken: "wrong", signature: validSignature, url, params })).toBe(false);
31
+ });
32
+
33
+ it("rejects a missing signature", async () => {
34
+ expect(await validateTwilioSignature({ authToken, signature: null, url, params })).toBe(false);
35
+ });
36
+ });