@kuralle-syrinx/deepgram 4.4.0 → 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.
@@ -0,0 +1,1784 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { WebSocketServer, type WebSocket } from "ws";
5
+ import {
6
+ PipelineBusImpl,
7
+ Route,
8
+ type ConversationMetricPacket,
9
+ type SttErrorPacket,
10
+ type SttInterimPacket,
11
+ type SttPartialPacket,
12
+ type SttResultPacket,
13
+ type UsageRecordedPacket,
14
+ } from "@kuralle-syrinx/core";
15
+
16
+ import { DeepgramSTTPlugin } from "./stt.js";
17
+
18
+ let servers: WebSocketServer[] = [];
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(
22
+ servers.splice(0).map(
23
+ (server) =>
24
+ new Promise<void>((resolve) => {
25
+ for (const client of server.clients) client.terminate();
26
+ server.close(() => resolve());
27
+ }),
28
+ ),
29
+ );
30
+ });
31
+
32
+ async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
33
+ const server = await new Promise<WebSocketServer>((resolve) => {
34
+ let nextServer: WebSocketServer;
35
+ nextServer = new WebSocketServer({ port: 0 }, () => {
36
+ resolve(nextServer);
37
+ });
38
+ });
39
+ servers.push(server);
40
+ server.on("connection", onConnection);
41
+ const address = server.address();
42
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
43
+ return `ws://127.0.0.1:${address.port}/listen`;
44
+ }
45
+
46
+ function startBus(bus: PipelineBusImpl): Promise<void> {
47
+ return bus.start();
48
+ }
49
+
50
+ async function waitFor<T>(items: T[], count = 1): Promise<void> {
51
+ for (let i = 0; i < 50; i += 1) {
52
+ if (items.length >= count) return;
53
+ await new Promise((resolve) => setTimeout(resolve, 10));
54
+ }
55
+ }
56
+
57
+ async function waitForValue<T>(items: T[], value: T): Promise<void> {
58
+ for (let i = 0; i < 50; i += 1) {
59
+ if (items.includes(value)) return;
60
+ await new Promise((resolve) => setTimeout(resolve, 10));
61
+ }
62
+ }
63
+
64
+ describe("DeepgramSTTPlugin", () => {
65
+ it("emits stt.partial with word timings alongside unchanged stt.interim", async () => {
66
+ const endpointUrl = await createLocalServer((socket) => {
67
+ socket.on("message", (data, isBinary) => {
68
+ if (!isBinary) return;
69
+ socket.send(JSON.stringify({
70
+ is_final: false,
71
+ channel: {
72
+ alternatives: [{
73
+ transcript: "hello there",
74
+ confidence: 0.91,
75
+ words: [
76
+ { word: "hello", start: 0.12, end: 0.48, confidence: 0.95 },
77
+ { word: "there", start: 0.5, end: 0.82, confidence: 0.88 },
78
+ ],
79
+ }],
80
+ },
81
+ }));
82
+ });
83
+ });
84
+ const bus = new PipelineBusImpl();
85
+ const started = startBus(bus);
86
+ const plugin = new DeepgramSTTPlugin();
87
+ const interims: SttInterimPacket[] = [];
88
+ const partials: SttPartialPacket[] = [];
89
+ bus.on("stt.interim", (pkt) => {
90
+ interims.push(pkt as SttInterimPacket);
91
+ });
92
+ bus.on("stt.partial", (pkt) => {
93
+ partials.push(pkt as SttPartialPacket);
94
+ });
95
+
96
+ await plugin.initialize(bus, {
97
+ api_key: "test",
98
+ endpoint_url: endpointUrl,
99
+ sample_rate: 16000,
100
+ });
101
+ bus.push(Route.Main, {
102
+ kind: "stt.audio",
103
+ contextId: "turn-1",
104
+ timestampMs: Date.now(),
105
+ audio: new Uint8Array(640),
106
+ });
107
+ await waitFor(partials);
108
+
109
+ expect(interims).toEqual([
110
+ expect.objectContaining({ kind: "stt.interim", contextId: "turn-1", text: "hello there" }),
111
+ ]);
112
+ expect(partials).toEqual([
113
+ expect.objectContaining({
114
+ kind: "stt.partial",
115
+ contextId: "turn-1",
116
+ text: "hello there",
117
+ wordTimings: [
118
+ { word: "hello", startMs: 120, endMs: 480, confidence: 0.95 },
119
+ { word: "there", startMs: 500, endMs: 820, confidence: 0.88 },
120
+ ],
121
+ }),
122
+ ]);
123
+
124
+ await plugin.close();
125
+ bus.stop();
126
+ await started;
127
+ });
128
+
129
+ it("uses provider KeepAlive while idle and CloseStream on shutdown", async () => {
130
+ const controlMessages: string[] = [];
131
+ const endpointUrl = await createLocalServer((socket) => {
132
+ socket.on("message", (data, isBinary) => {
133
+ if (isBinary) return;
134
+ const msg = JSON.parse(data.toString()) as { type?: string };
135
+ if (msg.type) controlMessages.push(msg.type);
136
+ });
137
+ });
138
+ const bus = new PipelineBusImpl();
139
+ const started = startBus(bus);
140
+ const plugin = new DeepgramSTTPlugin();
141
+
142
+ await plugin.initialize(bus, {
143
+ api_key: "test",
144
+ endpoint_url: endpointUrl,
145
+ sample_rate: 16000,
146
+ keep_alive_interval_ms: 10,
147
+ });
148
+ await waitFor(controlMessages);
149
+ await plugin.close();
150
+ await waitForValue(controlMessages, "CloseStream");
151
+ bus.stop();
152
+ await started;
153
+
154
+ expect(controlMessages).toContain("KeepAlive");
155
+ expect(controlMessages.at(-1)).toBe("CloseStream");
156
+ });
157
+
158
+ it("emits provider-final segments immediately while still allowing explicit finalization", async () => {
159
+ let finalizeMessages = 0;
160
+ const endpointUrl = await createLocalServer((socket) => {
161
+ socket.on("message", (data, isBinary) => {
162
+ if (isBinary) {
163
+ socket.send(JSON.stringify({
164
+ is_final: true,
165
+ speech_final: true,
166
+ channel: { alternatives: [{ transcript: "premature", confidence: 0.8 }] },
167
+ }));
168
+ return;
169
+ }
170
+ const msg = JSON.parse(data.toString()) as { type?: string };
171
+ if (msg.type !== "Finalize") return;
172
+ finalizeMessages += 1;
173
+ socket.send(JSON.stringify({
174
+ is_final: true,
175
+ speech_final: false,
176
+ channel: { alternatives: [{ transcript: "partial", confidence: 0.7 }] },
177
+ }));
178
+ setTimeout(() => {
179
+ socket.send(JSON.stringify({
180
+ is_final: true,
181
+ speech_final: false,
182
+ from_finalize: true,
183
+ channel: { alternatives: [{ transcript: "confirmed", confidence: 0.95 }] },
184
+ }));
185
+ }, 5);
186
+ });
187
+ });
188
+ const bus = new PipelineBusImpl();
189
+ const started = startBus(bus);
190
+ const plugin = new DeepgramSTTPlugin();
191
+ const finals: SttResultPacket[] = [];
192
+ const metrics: ConversationMetricPacket[] = [];
193
+ bus.on("stt.result", (pkt) => {
194
+ finals.push(pkt as SttResultPacket);
195
+ });
196
+ bus.on("metric.conversation", (pkt) => {
197
+ metrics.push(pkt as ConversationMetricPacket);
198
+ });
199
+
200
+ await plugin.initialize(bus, {
201
+ api_key: "test",
202
+ endpoint_url: endpointUrl,
203
+ sample_rate: 16000,
204
+ finalize_on_speech_final: false,
205
+ provider_finalize_timeout_ms: 0,
206
+ emit_eos_on_final: false,
207
+ });
208
+ bus.push(Route.Main, {
209
+ kind: "stt.audio",
210
+ contextId: "turn-1",
211
+ timestampMs: Date.now(),
212
+ audio: new Uint8Array(640),
213
+ });
214
+ await new Promise((resolve) => setTimeout(resolve, 30));
215
+ expect(finals).toEqual([
216
+ expect.objectContaining({
217
+ kind: "stt.result",
218
+ contextId: "turn-1",
219
+ text: "premature",
220
+ confidence: 0.8,
221
+ }),
222
+ ]);
223
+
224
+ plugin.forceFinalize("turn-1");
225
+ await new Promise((resolve) => setTimeout(resolve, 30));
226
+ expect(finals).toEqual([
227
+ expect.objectContaining({ contextId: "turn-1", text: "premature", confidence: 0.8 }),
228
+ expect.objectContaining({ contextId: "turn-1", text: "partial", confidence: 0.7 }),
229
+ expect.objectContaining({ contextId: "turn-1", text: "confirmed", confidence: 0.95 }),
230
+ ]);
231
+ expect(finalizeMessages).toBe(1);
232
+ expect(metrics).toEqual(expect.arrayContaining([
233
+ expect.objectContaining({
234
+ name: "stt_provider_finalize_requested",
235
+ contextId: "turn-1",
236
+ value: expect.stringContaining("\"bytes\":640"),
237
+ }),
238
+ ]));
239
+
240
+ await plugin.close();
241
+ bus.stop();
242
+ await started;
243
+ });
244
+
245
+ it("completes a wedged turn via UtteranceEnd when speech_final never fires (noisy-line backstop)", async () => {
246
+ // A noisy/continuous line: Deepgram sends an is_final segment but never
247
+ // speech_final. UtteranceEnd (gap-based) must complete the turn so it can't wedge.
248
+ const endpointUrl = await createLocalServer((socket) => {
249
+ socket.on("message", (data, isBinary) => {
250
+ if (isBinary) {
251
+ socket.send(JSON.stringify({
252
+ is_final: true,
253
+ speech_final: false, // never speech_final on this noisy line
254
+ channel: { alternatives: [{ transcript: "book a room", confidence: 0.9 }] },
255
+ }));
256
+ setTimeout(() => socket.send(JSON.stringify({ type: "UtteranceEnd", last_word_end: 1.2 })), 5);
257
+ return;
258
+ }
259
+ });
260
+ });
261
+ const bus = new PipelineBusImpl();
262
+ const started = startBus(bus);
263
+ const plugin = new DeepgramSTTPlugin();
264
+ const turnCompletes: Array<{ text: string; contextId: string }> = [];
265
+ bus.on("eos.turn_complete", (pkt) => {
266
+ const p = pkt as unknown as { text: string; contextId: string };
267
+ turnCompletes.push({ text: p.text, contextId: p.contextId });
268
+ });
269
+
270
+ await plugin.initialize(bus, {
271
+ api_key: "test",
272
+ endpoint_url: endpointUrl,
273
+ sample_rate: 16000,
274
+ utterance_end_ms: 1000,
275
+ });
276
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
277
+ await new Promise((resolve) => setTimeout(resolve, 40));
278
+
279
+ expect(turnCompletes).toEqual([{ text: "book a room", contextId: "turn-1" }]);
280
+
281
+ await plugin.close();
282
+ bus.stop();
283
+ await started;
284
+ });
285
+
286
+ it("emits usage.recorded with stage stt and audioSeconds when a turn completes", async () => {
287
+ const endpointUrl = await createLocalServer((socket) => {
288
+ socket.on("message", (data, isBinary) => {
289
+ if (!isBinary) return;
290
+ socket.send(JSON.stringify({
291
+ is_final: true,
292
+ speech_final: true,
293
+ channel: { alternatives: [{ transcript: "hello", confidence: 0.95 }] },
294
+ }));
295
+ });
296
+ });
297
+ const bus = new PipelineBusImpl();
298
+ const started = startBus(bus);
299
+ const plugin = new DeepgramSTTPlugin();
300
+ const usage: UsageRecordedPacket[] = [];
301
+ bus.on("usage.recorded", (pkt) => {
302
+ usage.push(pkt as UsageRecordedPacket);
303
+ });
304
+
305
+ // 640 bytes pcm_s16le @ 16kHz = 640 / 2 / 16000 = 0.02 s
306
+ const audio = new Uint8Array(640);
307
+ await plugin.initialize(bus, {
308
+ api_key: "test",
309
+ endpoint_url: endpointUrl,
310
+ sample_rate: 16000,
311
+ model: "nova-3",
312
+ });
313
+ bus.push(Route.Main, {
314
+ kind: "stt.audio",
315
+ contextId: "turn-usage",
316
+ timestampMs: Date.now(),
317
+ audio,
318
+ });
319
+ await waitFor(usage);
320
+
321
+ expect(usage).toEqual([
322
+ expect.objectContaining({
323
+ kind: "usage.recorded",
324
+ contextId: "turn-usage",
325
+ stage: "stt",
326
+ provider: "deepgram",
327
+ model: "nova-3",
328
+ audioSeconds: 0.02,
329
+ }),
330
+ ]);
331
+
332
+ await plugin.close();
333
+ bus.stop();
334
+ await started;
335
+ });
336
+
337
+ it("emits usage.recorded even under smart-turn endpointing (emit_eos_on_final=false, no speech_final)", async () => {
338
+ // Regression: the flagship cascade sets endpointingOwner=smart_turn / emit_eos_on_final=false,
339
+ // so Deepgram never calls pushTurnComplete/pushFinal — the final goes out via pushResult alone.
340
+ // Billing hooked only on the turn-complete methods silently no-ops in this (production) mode.
341
+ const endpointUrl = await createLocalServer((socket) => {
342
+ socket.on("message", (data, isBinary) => {
343
+ if (!isBinary) return;
344
+ socket.send(JSON.stringify({
345
+ is_final: true,
346
+ speech_final: false,
347
+ channel: { alternatives: [{ transcript: "account number please", confidence: 0.95 }] },
348
+ }));
349
+ });
350
+ });
351
+ const bus = new PipelineBusImpl();
352
+ const started = startBus(bus);
353
+ const plugin = new DeepgramSTTPlugin();
354
+ const usage: UsageRecordedPacket[] = [];
355
+ bus.on("usage.recorded", (pkt) => {
356
+ usage.push(pkt as UsageRecordedPacket);
357
+ });
358
+
359
+ const audio = new Uint8Array(640); // 0.02s @ 16kHz pcm_s16le
360
+ await plugin.initialize(bus, {
361
+ api_key: "test",
362
+ endpoint_url: endpointUrl,
363
+ sample_rate: 16000,
364
+ model: "nova-3",
365
+ emit_eos_on_final: false,
366
+ finalize_on_speech_final: false,
367
+ });
368
+ bus.push(Route.Main, {
369
+ kind: "stt.audio",
370
+ contextId: "turn-smartturn",
371
+ timestampMs: Date.now(),
372
+ audio,
373
+ });
374
+ await waitFor(usage);
375
+
376
+ expect(usage).toEqual([
377
+ expect.objectContaining({
378
+ kind: "usage.recorded",
379
+ contextId: "turn-smartturn",
380
+ stage: "stt",
381
+ provider: "deepgram",
382
+ model: "nova-3",
383
+ audioSeconds: 0.02,
384
+ }),
385
+ ]);
386
+
387
+ await plugin.close();
388
+ bus.stop();
389
+ await started;
390
+ });
391
+
392
+ it("bills each is_final segment's audio delta once (multi-segment turn sums, no double-count)", async () => {
393
+ // Two is_final segments in one turn: usage must sum the per-segment audio deltas, not
394
+ // re-bill the running total each time.
395
+ let sent = 0;
396
+ const endpointUrl = await createLocalServer((socket) => {
397
+ socket.on("message", (data, isBinary) => {
398
+ if (!isBinary) return;
399
+ sent += 1;
400
+ socket.send(JSON.stringify({
401
+ is_final: true,
402
+ speech_final: false,
403
+ channel: { alternatives: [{ transcript: sent === 1 ? "one" : "one two", confidence: 0.9 }] },
404
+ }));
405
+ });
406
+ });
407
+ const bus = new PipelineBusImpl();
408
+ const started = startBus(bus);
409
+ const plugin = new DeepgramSTTPlugin();
410
+ const usage: UsageRecordedPacket[] = [];
411
+ bus.on("usage.recorded", (pkt) => {
412
+ usage.push(pkt as UsageRecordedPacket);
413
+ });
414
+
415
+ await plugin.initialize(bus, {
416
+ api_key: "test",
417
+ endpoint_url: endpointUrl,
418
+ sample_rate: 16000,
419
+ model: "nova-3",
420
+ emit_eos_on_final: false,
421
+ });
422
+ // 320 bytes then another 320 bytes = 0.01s + 0.01s = 0.02s total.
423
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-multi", timestampMs: Date.now(), audio: new Uint8Array(320) });
424
+ await waitFor(usage);
425
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-multi", timestampMs: Date.now(), audio: new Uint8Array(320) });
426
+ await waitFor(usage, 2);
427
+
428
+ const total = usage.reduce((sum, u) => sum + (u.audioSeconds ?? 0), 0);
429
+ expect(total).toBeCloseTo(0.02, 6);
430
+
431
+ await plugin.close();
432
+ bus.stop();
433
+ await started;
434
+ });
435
+
436
+ it("emits trailing provider-final segments after Pipecat requests finalize", async () => {
437
+ const endpointUrl = await createLocalServer((socket) => {
438
+ socket.on("message", (data, isBinary) => {
439
+ if (isBinary) {
440
+ socket.send(JSON.stringify({
441
+ is_final: true,
442
+ speech_final: false,
443
+ channel: { alternatives: [{ transcript: "first phrase", confidence: 0.8 }] },
444
+ }));
445
+ return;
446
+ }
447
+ const msg = JSON.parse(data.toString()) as { type?: string };
448
+ if (msg.type !== "Finalize") return;
449
+ setTimeout(() => {
450
+ socket.send(JSON.stringify({
451
+ is_final: true,
452
+ speech_final: true,
453
+ channel: { alternatives: [{ transcript: "trailing phrase", confidence: 0.9 }] },
454
+ }));
455
+ }, 10);
456
+ });
457
+ });
458
+ const bus = new PipelineBusImpl();
459
+ const started = startBus(bus);
460
+ const plugin = new DeepgramSTTPlugin();
461
+ const finals: SttResultPacket[] = [];
462
+ bus.on("stt.result", (pkt) => {
463
+ finals.push(pkt as SttResultPacket);
464
+ });
465
+
466
+ await plugin.initialize(bus, {
467
+ api_key: "test",
468
+ endpoint_url: endpointUrl,
469
+ sample_rate: 16000,
470
+ finalize_on_speech_final: false,
471
+ provider_finalize_timeout_ms: 100,
472
+ emit_eos_on_final: false,
473
+ });
474
+ bus.push(Route.Main, {
475
+ kind: "stt.audio",
476
+ contextId: "turn-2",
477
+ timestampMs: Date.now(),
478
+ audio: new Uint8Array(640),
479
+ });
480
+ await waitFor(finals, 1);
481
+ expect(finals).toEqual([
482
+ expect.objectContaining({
483
+ contextId: "turn-2",
484
+ text: "first phrase",
485
+ confidence: 0.8,
486
+ }),
487
+ ]);
488
+
489
+ plugin.forceFinalize("turn-2");
490
+ await waitFor(finals, 2);
491
+ expect(finals).toEqual([
492
+ expect.objectContaining({
493
+ contextId: "turn-2",
494
+ text: "first phrase",
495
+ confidence: 0.8,
496
+ }),
497
+ expect.objectContaining({
498
+ contextId: "turn-2",
499
+ text: "trailing phrase",
500
+ confidence: 0.9,
501
+ }),
502
+ ]);
503
+
504
+ await plugin.close();
505
+ bus.stop();
506
+ await started;
507
+ });
508
+
509
+ it("turns malformed provider messages into STT errors instead of dropping them", async () => {
510
+ const endpointUrl = await createLocalServer((socket) => {
511
+ socket.on("message", (_data, isBinary) => {
512
+ if (isBinary) socket.send("{not-json");
513
+ });
514
+ });
515
+ const bus = new PipelineBusImpl();
516
+ const started = startBus(bus);
517
+ const plugin = new DeepgramSTTPlugin();
518
+ const errors: SttErrorPacket[] = [];
519
+ bus.on("stt.error", (pkt) => {
520
+ errors.push(pkt as SttErrorPacket);
521
+ });
522
+
523
+ await plugin.initialize(bus, {
524
+ api_key: "test",
525
+ endpoint_url: endpointUrl,
526
+ sample_rate: 16000,
527
+ });
528
+ bus.push(Route.Main, {
529
+ kind: "stt.audio",
530
+ contextId: "turn-malformed",
531
+ timestampMs: Date.now(),
532
+ audio: new Uint8Array(640),
533
+ });
534
+
535
+ await waitFor(errors);
536
+ expect(errors).toEqual([
537
+ expect.objectContaining({
538
+ kind: "stt.error",
539
+ contextId: "turn-malformed",
540
+ component: "stt",
541
+ isRecoverable: false,
542
+ cause: expect.objectContaining({
543
+ message: expect.stringContaining("Deepgram STT provider sent malformed JSON"),
544
+ }),
545
+ }),
546
+ ]);
547
+
548
+ await plugin.close();
549
+ bus.stop();
550
+ await started;
551
+ });
552
+
553
+ it("surfaces Deepgram provider error frames as STT errors", async () => {
554
+ const endpointUrl = await createLocalServer((socket) => {
555
+ socket.on("message", (_data, isBinary) => {
556
+ if (!isBinary) return;
557
+ socket.send(JSON.stringify({
558
+ type: "Error",
559
+ code: "FAILED_TO_START_LISTENING",
560
+ description: "Failed to open the listen connection.",
561
+ request_id: "req-1",
562
+ }));
563
+ });
564
+ });
565
+ const bus = new PipelineBusImpl();
566
+ const started = startBus(bus);
567
+ const plugin = new DeepgramSTTPlugin();
568
+ const errors: SttErrorPacket[] = [];
569
+ bus.on("stt.error", (pkt) => {
570
+ errors.push(pkt as SttErrorPacket);
571
+ });
572
+
573
+ await plugin.initialize(bus, {
574
+ api_key: "test",
575
+ endpoint_url: endpointUrl,
576
+ sample_rate: 16000,
577
+ });
578
+ bus.push(Route.Main, {
579
+ kind: "stt.audio",
580
+ contextId: "turn-provider-error",
581
+ timestampMs: Date.now(),
582
+ audio: new Uint8Array(640),
583
+ });
584
+
585
+ await waitFor(errors);
586
+ expect(errors).toEqual([
587
+ expect.objectContaining({
588
+ kind: "stt.error",
589
+ contextId: "turn-provider-error",
590
+ component: "stt",
591
+ cause: expect.objectContaining({
592
+ message: expect.stringContaining("FAILED_TO_START_LISTENING"),
593
+ }),
594
+ }),
595
+ ]);
596
+
597
+ await plugin.close();
598
+ bus.stop();
599
+ await started;
600
+ });
601
+
602
+ it("surfaces unexpected Deepgram websocket close frames as STT errors", async () => {
603
+ const endpointUrl = await createLocalServer((socket) => {
604
+ socket.on("message", (_data, isBinary) => {
605
+ if (isBinary) socket.close(1011, "NET-0000");
606
+ });
607
+ });
608
+ const bus = new PipelineBusImpl();
609
+ const started = startBus(bus);
610
+ const plugin = new DeepgramSTTPlugin();
611
+ const errors: SttErrorPacket[] = [];
612
+ bus.on("stt.error", (pkt) => {
613
+ errors.push(pkt as SttErrorPacket);
614
+ });
615
+
616
+ await plugin.initialize(bus, {
617
+ api_key: "test",
618
+ endpoint_url: endpointUrl,
619
+ sample_rate: 16000,
620
+ });
621
+ bus.push(Route.Main, {
622
+ kind: "stt.audio",
623
+ contextId: "turn-close",
624
+ timestampMs: Date.now(),
625
+ audio: new Uint8Array(640),
626
+ });
627
+
628
+ await waitFor(errors);
629
+ expect(errors).toEqual(expect.arrayContaining([
630
+ expect.objectContaining({
631
+ kind: "stt.error",
632
+ contextId: "turn-close",
633
+ component: "stt",
634
+ isRecoverable: true,
635
+ cause: expect.objectContaining({
636
+ message: expect.stringContaining("code=1011 reason=NET-0000"),
637
+ }),
638
+ }),
639
+ ]));
640
+
641
+ await plugin.close();
642
+ bus.stop();
643
+ await started;
644
+ });
645
+
646
+ it("discards unconfirmed provider transcript state across recoverable websocket reconnect", async () => {
647
+ const connections: WebSocket[] = [];
648
+ const endpointUrl = await createLocalServer((socket) => {
649
+ connections.push(socket);
650
+ if (connections.length === 1) {
651
+ socket.on("message", (_data, isBinary) => {
652
+ if (!isBinary) return;
653
+ socket.send(JSON.stringify({
654
+ is_final: true,
655
+ speech_final: false,
656
+ channel: { alternatives: [{ transcript: "stale pre reconnect", confidence: 0.8 }] },
657
+ }));
658
+ socket.close(1011, "NET-0000");
659
+ });
660
+ return;
661
+ }
662
+ socket.on("message", (_data, isBinary) => {
663
+ if (!isBinary) return;
664
+ socket.send(JSON.stringify({
665
+ is_final: true,
666
+ speech_final: true,
667
+ channel: { alternatives: [{ transcript: "fresh after reconnect", confidence: 0.9 }] },
668
+ }));
669
+ });
670
+ });
671
+ const bus = new PipelineBusImpl();
672
+ const started = startBus(bus);
673
+ const plugin = new DeepgramSTTPlugin();
674
+ const finals: SttResultPacket[] = [];
675
+ const metrics: ConversationMetricPacket[] = [];
676
+ bus.on("stt.result", (pkt) => {
677
+ finals.push(pkt as SttResultPacket);
678
+ });
679
+ bus.on("metric.conversation", (pkt) => {
680
+ metrics.push(pkt as ConversationMetricPacket);
681
+ });
682
+
683
+ await plugin.initialize(bus, {
684
+ api_key: "test",
685
+ endpoint_url: endpointUrl,
686
+ sample_rate: 16000,
687
+ });
688
+ bus.push(Route.Main, {
689
+ kind: "stt.audio",
690
+ contextId: "turn-before-reconnect",
691
+ timestampMs: Date.now(),
692
+ audio: new Uint8Array(640),
693
+ });
694
+ await waitFor(connections, 2);
695
+
696
+ bus.push(Route.Main, {
697
+ kind: "stt.audio",
698
+ contextId: "turn-after-reconnect",
699
+ timestampMs: Date.now(),
700
+ audio: new Uint8Array(640),
701
+ });
702
+ await waitFor(finals, 2);
703
+
704
+ expect(finals).toEqual([
705
+ expect.objectContaining({
706
+ contextId: "turn-before-reconnect",
707
+ text: "stale pre reconnect",
708
+ }),
709
+ expect.objectContaining({
710
+ contextId: "turn-after-reconnect",
711
+ text: "fresh after reconnect",
712
+ }),
713
+ ]);
714
+ expect(metrics).toEqual(expect.arrayContaining([
715
+ expect.objectContaining({
716
+ name: "stt_provider_reconnect_discarded_state",
717
+ contextId: "turn-before-reconnect",
718
+ }),
719
+ ]));
720
+
721
+ await plugin.close();
722
+ bus.stop();
723
+ await started;
724
+ });
725
+
726
+ it("does not bill usage when no audio was successfully sent before finalize", async () => {
727
+ // No audio frames are delivered; finalize should still request provider Finalize with
728
+ // zero recorded audio-stats bytes (metrics), and no usage.recorded.
729
+ const endpointUrl = await createLocalServer((socket) => {
730
+ socket.on("message", () => {
731
+ // swallow Finalize — no provider confirmation needed for this metric assertion
732
+ });
733
+ });
734
+ const bus = new PipelineBusImpl();
735
+ const started = startBus(bus);
736
+ const plugin = new DeepgramSTTPlugin();
737
+ const usage: UsageRecordedPacket[] = [];
738
+ const metrics: ConversationMetricPacket[] = [];
739
+ bus.on("usage.recorded", (pkt) => {
740
+ usage.push(pkt as UsageRecordedPacket);
741
+ });
742
+ bus.on("metric.conversation", (pkt) => {
743
+ metrics.push(pkt as ConversationMetricPacket);
744
+ });
745
+
746
+ await plugin.initialize(bus, {
747
+ api_key: "test",
748
+ endpoint_url: endpointUrl,
749
+ sample_rate: 16000,
750
+ provider_finalize_timeout_ms: 0,
751
+ emit_eos_on_final: false,
752
+ });
753
+ // Establish context without audio (turn.change only).
754
+ bus.push(Route.Main, {
755
+ kind: "turn.change",
756
+ contextId: "turn-unsent",
757
+ timestampMs: Date.now(),
758
+ });
759
+ plugin.forceFinalize("turn-unsent");
760
+ await waitFor(metrics);
761
+
762
+ expect(usage).toHaveLength(0);
763
+ expect(metrics).toEqual(expect.arrayContaining([
764
+ expect.objectContaining({
765
+ name: "stt_provider_finalize_requested",
766
+ contextId: "turn-unsent",
767
+ value: expect.stringContaining("\"bytes\":0"),
768
+ }),
769
+ ]));
770
+
771
+ await plugin.close();
772
+ bus.stop();
773
+ await started;
774
+ });
775
+
776
+ it("times out interim-only provider text when Finalize is not confirmed", async () => {
777
+ const endpointUrl = await createLocalServer((socket) => {
778
+ socket.on("message", (data, isBinary) => {
779
+ if (isBinary) {
780
+ socket.send(JSON.stringify({
781
+ is_final: false,
782
+ speech_final: false,
783
+ channel: { alternatives: [{ transcript: "unconfirmed segment", confidence: 0.8 }] },
784
+ }));
785
+ return;
786
+ }
787
+ const msg = JSON.parse(data.toString()) as { type?: string };
788
+ if (msg.type !== "Finalize") return;
789
+ });
790
+ });
791
+ const bus = new PipelineBusImpl();
792
+ const started = startBus(bus);
793
+ const plugin = new DeepgramSTTPlugin();
794
+ const finals: SttResultPacket[] = [];
795
+ const errors: SttErrorPacket[] = [];
796
+ const metrics: ConversationMetricPacket[] = [];
797
+ bus.on("stt.result", (pkt) => {
798
+ finals.push(pkt as SttResultPacket);
799
+ });
800
+ bus.on("stt.error", (pkt) => {
801
+ errors.push(pkt as SttErrorPacket);
802
+ });
803
+ bus.on("metric.conversation", (pkt) => {
804
+ metrics.push(pkt as ConversationMetricPacket);
805
+ });
806
+
807
+ await plugin.initialize(bus, {
808
+ api_key: "test",
809
+ endpoint_url: endpointUrl,
810
+ sample_rate: 16000,
811
+ finalize_on_speech_final: false,
812
+ provider_finalize_timeout_ms: 10,
813
+ emit_eos_on_final: false,
814
+ });
815
+ bus.push(Route.Main, {
816
+ kind: "stt.audio",
817
+ contextId: "turn-unconfirmed",
818
+ timestampMs: Date.now(),
819
+ audio: new Uint8Array(640),
820
+ });
821
+ await new Promise((resolve) => setTimeout(resolve, 20));
822
+
823
+ plugin.forceFinalize("turn-unconfirmed");
824
+ await waitFor(errors);
825
+
826
+ expect(finals).toHaveLength(0);
827
+ expect(errors).toEqual([
828
+ expect.objectContaining({
829
+ kind: "stt.error",
830
+ contextId: "turn-unconfirmed",
831
+ isRecoverable: true,
832
+ cause: expect.objectContaining({
833
+ message: "Deepgram STT Finalize timed out before speech_final/from_finalize confirmation",
834
+ }),
835
+ }),
836
+ ]);
837
+ expect(metrics).toEqual(expect.arrayContaining([
838
+ expect.objectContaining({
839
+ name: "stt_provider_finalize_timeout",
840
+ contextId: "turn-unconfirmed",
841
+ value: expect.stringContaining("\"bytes\":640"),
842
+ }),
843
+ ]));
844
+
845
+ await plugin.close();
846
+ bus.stop();
847
+ await started;
848
+ });
849
+
850
+ it("emits provider is_final text without waiting for speech_final or explicit Finalize confirmation", async () => {
851
+ const endpointUrl = await createLocalServer((socket) => {
852
+ socket.on("message", (_data, isBinary) => {
853
+ if (!isBinary) return;
854
+ socket.send(JSON.stringify({
855
+ is_final: true,
856
+ speech_final: false,
857
+ channel: { alternatives: [{ transcript: "released provider text", confidence: 0.87 }] },
858
+ }));
859
+ });
860
+ });
861
+ const bus = new PipelineBusImpl();
862
+ const started = startBus(bus);
863
+ const plugin = new DeepgramSTTPlugin();
864
+ const finals: SttResultPacket[] = [];
865
+ const errors: SttErrorPacket[] = [];
866
+ const metrics: ConversationMetricPacket[] = [];
867
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
868
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
869
+ bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
870
+
871
+ await plugin.initialize(bus, {
872
+ api_key: "test",
873
+ endpoint_url: endpointUrl,
874
+ sample_rate: 16000,
875
+ emit_eos_on_final: false,
876
+ finalize_on_speech_final: false,
877
+ provider_finalize_timeout_ms: 10,
878
+ });
879
+
880
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-is-final", timestampMs: Date.now(), audio: new Uint8Array(640) });
881
+ await waitFor(finals);
882
+ plugin.forceFinalize("turn-is-final");
883
+ await new Promise((resolve) => setTimeout(resolve, 30));
884
+
885
+ expect(finals).toEqual([
886
+ expect.objectContaining({
887
+ kind: "stt.result",
888
+ contextId: "turn-is-final",
889
+ text: "released provider text",
890
+ confidence: 0.87,
891
+ provider: expect.objectContaining({
892
+ name: "deepgram",
893
+ speechFinal: false,
894
+ fromFinalize: false,
895
+ finalizeRequested: false,
896
+ }),
897
+ }),
898
+ ]);
899
+ expect(errors).toHaveLength(0);
900
+ expect(metrics).not.toEqual(expect.arrayContaining([
901
+ expect.objectContaining({ name: "stt_provider_finalize_timeout" }),
902
+ ]));
903
+
904
+ await plugin.close();
905
+ bus.stop();
906
+ await started;
907
+ });
908
+
909
+ it("correlates a pending Finalize response to the requested context across turn.change", async () => {
910
+ const endpointUrl = await createLocalServer((socket) => {
911
+ socket.on("message", (data, isBinary) => {
912
+ if (isBinary) return;
913
+ const msg = JSON.parse(data.toString()) as { type?: string };
914
+ if (msg.type !== "Finalize") return;
915
+ setTimeout(() => {
916
+ socket.send(JSON.stringify({
917
+ is_final: true,
918
+ speech_final: false,
919
+ from_finalize: true,
920
+ channel: { alternatives: [{ transcript: "old turn flushed", confidence: 0.91 }] },
921
+ }));
922
+ }, 5);
923
+ });
924
+ });
925
+ const bus = new PipelineBusImpl();
926
+ const started = startBus(bus);
927
+ const plugin = new DeepgramSTTPlugin();
928
+ const finals: SttResultPacket[] = [];
929
+ const errors: SttErrorPacket[] = [];
930
+ const metrics: ConversationMetricPacket[] = [];
931
+ const usage: UsageRecordedPacket[] = [];
932
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
933
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
934
+ bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
935
+ bus.on("usage.recorded", (pkt) => { usage.push(pkt as UsageRecordedPacket); });
936
+
937
+ await plugin.initialize(bus, {
938
+ api_key: "test",
939
+ endpoint_url: endpointUrl,
940
+ sample_rate: 16000,
941
+ emit_eos_on_final: false,
942
+ finalize_on_speech_final: false,
943
+ provider_finalize_timeout_ms: 25,
944
+ });
945
+
946
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-old", timestampMs: Date.now(), audio: new Uint8Array(640) });
947
+ await new Promise((resolve) => setTimeout(resolve, 5));
948
+ plugin.forceFinalize("turn-old");
949
+ bus.push(Route.Main, {
950
+ kind: "turn.change",
951
+ previousContextId: "turn-old",
952
+ contextId: "turn-new",
953
+ reason: "websocket_audio_turn",
954
+ timestampMs: Date.now(),
955
+ });
956
+
957
+ await waitFor(finals);
958
+ await new Promise((resolve) => setTimeout(resolve, 35));
959
+
960
+ expect(finals).toEqual([
961
+ expect.objectContaining({
962
+ kind: "stt.result",
963
+ contextId: "turn-old",
964
+ text: "old turn flushed",
965
+ provider: expect.objectContaining({
966
+ fromFinalize: true,
967
+ finalizeRequested: true,
968
+ }),
969
+ }),
970
+ ]);
971
+ expect(errors).toHaveLength(0);
972
+ expect(metrics).not.toEqual(expect.arrayContaining([
973
+ expect.objectContaining({ name: "stt_provider_finalize_timeout", contextId: "turn-old" }),
974
+ ]));
975
+ // The trailing audio of turn-old (640B = 0.02s @ 16kHz) must be billed even though the
976
+ // from_finalize final arrives AFTER turn.change rotated the context. (Regression guard:
977
+ // retiring billing on turn.change would bill this as 0s.)
978
+ expect(usage).toEqual([
979
+ expect.objectContaining({ stage: "stt", contextId: "turn-old", audioSeconds: 0.02 }),
980
+ ]);
981
+
982
+ await plugin.close();
983
+ bus.stop();
984
+ await started;
985
+ });
986
+
987
+ it("keeps Finalize response correlation after already released final text crosses turn.change", async () => {
988
+ const endpointUrl = await createLocalServer((socket) => {
989
+ socket.on("message", (data, isBinary) => {
990
+ if (isBinary) {
991
+ socket.send(JSON.stringify({
992
+ is_final: true,
993
+ speech_final: false,
994
+ channel: { alternatives: [{ transcript: "old released text", confidence: 0.88 }] },
995
+ }));
996
+ return;
997
+ }
998
+ const msg = JSON.parse(data.toString()) as { type?: string };
999
+ if (msg.type !== "Finalize") return;
1000
+ setTimeout(() => {
1001
+ socket.send(JSON.stringify({
1002
+ is_final: true,
1003
+ speech_final: false,
1004
+ from_finalize: true,
1005
+ channel: { alternatives: [{ transcript: "old finalize tail", confidence: 0.93 }] },
1006
+ }));
1007
+ }, 5);
1008
+ });
1009
+ });
1010
+ const bus = new PipelineBusImpl();
1011
+ const started = startBus(bus);
1012
+ const plugin = new DeepgramSTTPlugin();
1013
+ const finals: SttResultPacket[] = [];
1014
+ const errors: SttErrorPacket[] = [];
1015
+ const metrics: ConversationMetricPacket[] = [];
1016
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1017
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1018
+ bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
1019
+
1020
+ await plugin.initialize(bus, {
1021
+ api_key: "test",
1022
+ endpoint_url: endpointUrl,
1023
+ sample_rate: 16000,
1024
+ emit_eos_on_final: false,
1025
+ finalize_on_speech_final: false,
1026
+ provider_finalize_timeout_ms: 25,
1027
+ });
1028
+
1029
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-old-released", timestampMs: Date.now(), audio: new Uint8Array(640) });
1030
+ await waitFor(finals);
1031
+ plugin.forceFinalize("turn-old-released");
1032
+ bus.push(Route.Main, {
1033
+ kind: "turn.change",
1034
+ previousContextId: "turn-old-released",
1035
+ contextId: "turn-new-after-release",
1036
+ reason: "websocket_audio_turn",
1037
+ timestampMs: Date.now(),
1038
+ });
1039
+
1040
+ await waitFor(finals, 2);
1041
+ await new Promise((resolve) => setTimeout(resolve, 35));
1042
+
1043
+ expect(finals).toEqual([
1044
+ expect.objectContaining({ contextId: "turn-old-released", text: "old released text" }),
1045
+ expect.objectContaining({
1046
+ contextId: "turn-old-released",
1047
+ text: "old finalize tail",
1048
+ provider: expect.objectContaining({
1049
+ fromFinalize: true,
1050
+ finalizeRequested: true,
1051
+ }),
1052
+ }),
1053
+ ]);
1054
+ expect(errors).toHaveLength(0);
1055
+ expect(metrics).not.toEqual(expect.arrayContaining([
1056
+ expect.objectContaining({ name: "stt_provider_finalize_timeout" }),
1057
+ ]));
1058
+
1059
+ await plugin.close();
1060
+ bus.stop();
1061
+ await started;
1062
+ });
1063
+
1064
+ it("keeps the live connection after a single unconfirmed Finalize timeout", async () => {
1065
+ // A lone slow finalize must NOT tear down the socket — the next turn has to stream
1066
+ // on the same connection, otherwise a reconnect stall cascades into more timeouts.
1067
+ const connections: WebSocket[] = [];
1068
+ let lastAudioContext = "";
1069
+ const endpointUrl = await createLocalServer((socket) => {
1070
+ connections.push(socket);
1071
+ socket.on("message", (data, isBinary) => {
1072
+ if (isBinary) {
1073
+ // Only the fresh turn gets final text; the stale turn's interim-only
1074
+ // text is intentionally never confirmed so its Finalize times out.
1075
+ const forFreshTurn = lastAudioContext === "turn-fresh";
1076
+ socket.send(JSON.stringify({
1077
+ is_final: forFreshTurn,
1078
+ speech_final: forFreshTurn,
1079
+ channel: { alternatives: [{ transcript: forFreshTurn ? "fresh confirmed text" : "stale interim", confidence: 0.9 }] },
1080
+ }));
1081
+ return;
1082
+ }
1083
+ // Swallow Finalize for the stale turn (no from_finalize) → provider timeout.
1084
+ });
1085
+ });
1086
+ const bus = new PipelineBusImpl();
1087
+ const started = startBus(bus);
1088
+ const plugin = new DeepgramSTTPlugin();
1089
+ const finals: SttResultPacket[] = [];
1090
+ const errors: SttErrorPacket[] = [];
1091
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1092
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1093
+
1094
+ await plugin.initialize(bus, {
1095
+ api_key: "test",
1096
+ endpoint_url: endpointUrl,
1097
+ sample_rate: 16000,
1098
+ provider_finalize_timeout_ms: 10,
1099
+ });
1100
+
1101
+ lastAudioContext = "turn-stale";
1102
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-stale", timestampMs: Date.now(), audio: new Uint8Array(640) });
1103
+ await new Promise((resolve) => setTimeout(resolve, 20));
1104
+ plugin.forceFinalize("turn-stale");
1105
+ await waitFor(errors);
1106
+
1107
+ // The single timeout surfaced an error but the socket stayed up (no reconnect).
1108
+ expect(errors).toHaveLength(1);
1109
+ expect(connections).toHaveLength(1);
1110
+ expect(connections[0]?.readyState).toBe(connections[0]?.OPEN);
1111
+
1112
+ // Next turn streams on the SAME connection and completes normally.
1113
+ lastAudioContext = "turn-fresh";
1114
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-fresh", timestampMs: Date.now(), audio: new Uint8Array(640) });
1115
+ await waitFor(finals);
1116
+
1117
+ expect(connections).toHaveLength(1);
1118
+ expect(finals).toEqual([
1119
+ expect.objectContaining({ contextId: "turn-fresh", text: "fresh confirmed text" }),
1120
+ ]);
1121
+
1122
+ await plugin.close();
1123
+ bus.stop();
1124
+ await started;
1125
+ });
1126
+
1127
+ it("reconnects only after consecutive unconfirmed Finalize timeouts", async () => {
1128
+ // Two finalize timeouts in a row with no confirmed final between them looks like a
1129
+ // genuinely wedged stream → reconnect; the fresh socket then serves the next turn.
1130
+ const connections: WebSocket[] = [];
1131
+ const endpointUrl = await createLocalServer((socket) => {
1132
+ connections.push(socket);
1133
+ const connIndex = connections.length;
1134
+ socket.on("message", (data, isBinary) => {
1135
+ if (isBinary) {
1136
+ if (connIndex >= 2) {
1137
+ socket.send(JSON.stringify({
1138
+ is_final: true,
1139
+ speech_final: true,
1140
+ channel: { alternatives: [{ transcript: "fresh confirmed text", confidence: 0.9 }] },
1141
+ }));
1142
+ } else {
1143
+ socket.send(JSON.stringify({
1144
+ is_final: false,
1145
+ speech_final: false,
1146
+ channel: { alternatives: [{ transcript: "wedged interim", confidence: 0.8 }] },
1147
+ }));
1148
+ }
1149
+ return;
1150
+ }
1151
+ // First connection never confirms any Finalize → repeated timeouts.
1152
+ });
1153
+ });
1154
+ const bus = new PipelineBusImpl();
1155
+ const started = startBus(bus);
1156
+ const plugin = new DeepgramSTTPlugin();
1157
+ const finals: SttResultPacket[] = [];
1158
+ const errors: SttErrorPacket[] = [];
1159
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1160
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1161
+
1162
+ await plugin.initialize(bus, {
1163
+ api_key: "test",
1164
+ endpoint_url: endpointUrl,
1165
+ sample_rate: 16000,
1166
+ provider_finalize_timeout_ms: 10,
1167
+ finalize_reset_threshold: 2,
1168
+ });
1169
+
1170
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
1171
+ await new Promise((resolve) => setTimeout(resolve, 20));
1172
+ plugin.forceFinalize("turn-1");
1173
+ await waitFor(errors, 1);
1174
+ // First timeout: still one connection (no reconnect yet).
1175
+ expect(connections).toHaveLength(1);
1176
+
1177
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-2", timestampMs: Date.now(), audio: new Uint8Array(640) });
1178
+ await new Promise((resolve) => setTimeout(resolve, 20));
1179
+ plugin.forceFinalize("turn-2");
1180
+ await waitFor(errors, 2);
1181
+ await waitFor(connections, 2);
1182
+
1183
+ // Second consecutive timeout: now it reconnects and abandons the wedged socket.
1184
+ expect(connections[0]?.readyState).toBe(connections[0]?.CLOSED);
1185
+
1186
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-fresh", timestampMs: Date.now(), audio: new Uint8Array(640) });
1187
+ await waitFor(finals);
1188
+ expect(finals).toEqual([
1189
+ expect.objectContaining({ contextId: "turn-fresh", text: "fresh confirmed text" }),
1190
+ ]);
1191
+
1192
+ await plugin.close();
1193
+ bus.stop();
1194
+ await started;
1195
+ });
1196
+
1197
+ it("clears the consecutive-timeout counter across a real socket reconnect", async () => {
1198
+ // A timeout (counter->1) followed by an unrelated socket-close reconnect must not leave
1199
+ // the count stale: a single timeout after reconnecting should NOT force another reset.
1200
+ const connections: WebSocket[] = [];
1201
+ const endpointUrl = await createLocalServer((socket) => {
1202
+ connections.push(socket);
1203
+ socket.on("message", (_data, isBinary) => {
1204
+ if (!isBinary) return;
1205
+ socket.send(JSON.stringify({
1206
+ is_final: true,
1207
+ speech_final: false,
1208
+ channel: { alternatives: [{ transcript: "unconfirmed", confidence: 0.8 }] },
1209
+ }));
1210
+ });
1211
+ });
1212
+ const bus = new PipelineBusImpl();
1213
+ const started = startBus(bus);
1214
+ const plugin = new DeepgramSTTPlugin();
1215
+ const timeoutErrors: SttErrorPacket[] = [];
1216
+ bus.on("stt.error", (pkt) => {
1217
+ const p = pkt as SttErrorPacket;
1218
+ if (String((p.cause as Error | undefined)?.message ?? "").includes("Finalize timed out")) {
1219
+ timeoutErrors.push(p);
1220
+ }
1221
+ });
1222
+
1223
+ await plugin.initialize(bus, {
1224
+ api_key: "test",
1225
+ endpoint_url: endpointUrl,
1226
+ sample_rate: 16000,
1227
+ provider_finalize_timeout_ms: 10,
1228
+ finalize_reset_threshold: 2,
1229
+ });
1230
+
1231
+ // Turn 1: one finalize timeout → counter 1 (below threshold, no reset yet).
1232
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-before-reconnect", timestampMs: Date.now(), audio: new Uint8Array(640) });
1233
+ await new Promise((resolve) => setTimeout(resolve, 20));
1234
+ plugin.forceFinalize("turn-before-reconnect");
1235
+ await waitFor(timeoutErrors, 1);
1236
+ expect(connections).toHaveLength(1);
1237
+
1238
+ // An unrelated socket-close reconnect happens.
1239
+ connections[0]!.close(1011, "NET-0000");
1240
+ await waitFor(connections, 2);
1241
+ await new Promise((resolve) => setTimeout(resolve, 50));
1242
+
1243
+ // Turn 2: a single post-reconnect timeout must NOT reconnect again — the counter was
1244
+ // cleared on the reconnect, so it is 1 (< threshold 2), not a stale 2.
1245
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-after-reconnect", timestampMs: Date.now(), audio: new Uint8Array(640) });
1246
+ await new Promise((resolve) => setTimeout(resolve, 20));
1247
+ plugin.forceFinalize("turn-after-reconnect");
1248
+ await waitFor(timeoutErrors, 2);
1249
+ await new Promise((resolve) => setTimeout(resolve, 60));
1250
+
1251
+ // Would be 3 without clearing the counter on reconnect.
1252
+ expect(connections).toHaveLength(2);
1253
+ expect(connections[1]?.readyState).toBe(connections[1]?.OPEN);
1254
+
1255
+ await plugin.close();
1256
+ bus.stop();
1257
+ await started;
1258
+ });
1259
+
1260
+ it("completes a timed-out turn from buffered text when finalize_timeout_fallback is on", async () => {
1261
+ // Provider sends a confirmed is_final segment but never speech_final/from_finalize, so the
1262
+ // turn would normally time out and be dropped. With the fallback on it must still emit an
1263
+ // stt.result (which drives the turn plugin → LLM) instead of a "Finalize timed out" error.
1264
+ const endpointUrl = await createLocalServer((socket) => {
1265
+ socket.on("message", (_data, isBinary) => {
1266
+ if (!isBinary) return; // swallow Finalize → provider never confirms
1267
+ socket.send(JSON.stringify({
1268
+ is_final: true,
1269
+ speech_final: false,
1270
+ channel: { alternatives: [{ transcript: "buffered text", confidence: 0.82 }] },
1271
+ }));
1272
+ });
1273
+ });
1274
+ const bus = new PipelineBusImpl();
1275
+ const started = startBus(bus);
1276
+ const plugin = new DeepgramSTTPlugin();
1277
+ const finals: SttResultPacket[] = [];
1278
+ const errors: SttErrorPacket[] = [];
1279
+ bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1280
+ bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1281
+
1282
+ await plugin.initialize(bus, {
1283
+ api_key: "test",
1284
+ endpoint_url: endpointUrl,
1285
+ sample_rate: 16000,
1286
+ emit_eos_on_final: false,
1287
+ finalize_on_speech_final: false,
1288
+ provider_finalize_timeout_ms: 10,
1289
+ finalize_timeout_fallback: true,
1290
+ });
1291
+
1292
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-timeout-fallback", timestampMs: Date.now(), audio: new Uint8Array(640) });
1293
+ await new Promise((resolve) => setTimeout(resolve, 20));
1294
+ plugin.forceFinalize("turn-timeout-fallback");
1295
+ await waitFor(finals);
1296
+
1297
+ expect(finals).toEqual([
1298
+ expect.objectContaining({ kind: "stt.result", contextId: "turn-timeout-fallback", text: "buffered text" }),
1299
+ ]);
1300
+ expect(
1301
+ errors.filter((e) => String((e.cause as Error | undefined)?.message ?? "").includes("Finalize timed out")),
1302
+ ).toHaveLength(0);
1303
+
1304
+ await plugin.close();
1305
+ bus.stop();
1306
+ await started;
1307
+ });
1308
+
1309
+ it("ignores the next late provider final after timeout-fallback promotion (ignoreNext dedupe)", async () => {
1310
+ // Interim-only buffer so the finalize timeout path runs (not correlation-expiry).
1311
+ // After fallback promotes, a late is_final must not double-emit (ignoreNext).
1312
+ let finalizeSeen = false;
1313
+ const endpointUrl = await createLocalServer((socket) => {
1314
+ socket.on("message", (data, isBinary) => {
1315
+ if (isBinary) {
1316
+ socket.send(JSON.stringify({
1317
+ is_final: false,
1318
+ speech_final: false,
1319
+ channel: { alternatives: [{ transcript: "interim only buffer", confidence: 0.8 }] },
1320
+ }));
1321
+ return;
1322
+ }
1323
+ const msg = JSON.parse(data.toString()) as { type?: string };
1324
+ if (msg.type !== "Finalize") return;
1325
+ finalizeSeen = true;
1326
+ setTimeout(() => {
1327
+ socket.send(JSON.stringify({
1328
+ is_final: true,
1329
+ speech_final: false,
1330
+ from_finalize: true,
1331
+ channel: { alternatives: [{ transcript: "late provider final", confidence: 0.99 }] },
1332
+ }));
1333
+ }, 50);
1334
+ });
1335
+ });
1336
+ const bus = new PipelineBusImpl();
1337
+ const started = startBus(bus);
1338
+ const plugin = new DeepgramSTTPlugin();
1339
+ const finals: SttResultPacket[] = [];
1340
+ bus.on("stt.result", (pkt) => {
1341
+ finals.push(pkt as SttResultPacket);
1342
+ });
1343
+
1344
+ await plugin.initialize(bus, {
1345
+ api_key: "test",
1346
+ endpoint_url: endpointUrl,
1347
+ sample_rate: 16000,
1348
+ // true → timeout timer (not correlation-expiry-only when a final segment exists)
1349
+ emit_eos_on_final: true,
1350
+ finalize_on_speech_final: false,
1351
+ provider_finalize_timeout_ms: 15,
1352
+ finalize_timeout_fallback: true,
1353
+ });
1354
+
1355
+ bus.push(Route.Main, {
1356
+ kind: "stt.audio",
1357
+ contextId: "turn-ignore-next",
1358
+ timestampMs: Date.now(),
1359
+ audio: new Uint8Array(640),
1360
+ });
1361
+ await new Promise((resolve) => setTimeout(resolve, 20));
1362
+ plugin.forceFinalize("turn-ignore-next");
1363
+ await waitFor(finals);
1364
+ await new Promise((resolve) => setTimeout(resolve, 80));
1365
+
1366
+ expect(finalizeSeen).toBe(true);
1367
+ expect(finals).toEqual([
1368
+ expect.objectContaining({
1369
+ kind: "stt.result",
1370
+ contextId: "turn-ignore-next",
1371
+ text: "interim only buffer",
1372
+ }),
1373
+ ]);
1374
+ expect(finals.some((f) => f.text === "late provider final")).toBe(false);
1375
+
1376
+ await plugin.close();
1377
+ bus.stop();
1378
+ await started;
1379
+ });
1380
+
1381
+ it("discards a silence-only timed-out turn when finalize_timeout_fallback has no buffered text", async () => {
1382
+ const endpointUrl = await createLocalServer((socket) => {
1383
+ socket.on("message", () => {
1384
+ // No transcripts at all — empty discard path.
1385
+ });
1386
+ });
1387
+ const bus = new PipelineBusImpl();
1388
+ const started = startBus(bus);
1389
+ const plugin = new DeepgramSTTPlugin();
1390
+ const finals: SttResultPacket[] = [];
1391
+ const errors: SttErrorPacket[] = [];
1392
+ const metrics: ConversationMetricPacket[] = [];
1393
+ bus.on("stt.result", (pkt) => {
1394
+ finals.push(pkt as SttResultPacket);
1395
+ });
1396
+ bus.on("stt.error", (pkt) => {
1397
+ errors.push(pkt as SttErrorPacket);
1398
+ });
1399
+ bus.on("metric.conversation", (pkt) => {
1400
+ metrics.push(pkt as ConversationMetricPacket);
1401
+ });
1402
+
1403
+ await plugin.initialize(bus, {
1404
+ api_key: "test",
1405
+ endpoint_url: endpointUrl,
1406
+ sample_rate: 16000,
1407
+ emit_eos_on_final: false,
1408
+ finalize_on_speech_final: false,
1409
+ provider_finalize_timeout_ms: 10,
1410
+ finalize_timeout_fallback: true,
1411
+ });
1412
+
1413
+ bus.push(Route.Main, {
1414
+ kind: "stt.audio",
1415
+ contextId: "turn-empty-discard",
1416
+ timestampMs: Date.now(),
1417
+ audio: new Uint8Array(640),
1418
+ });
1419
+ await new Promise((resolve) => setTimeout(resolve, 15));
1420
+ plugin.forceFinalize("turn-empty-discard");
1421
+ await waitFor(metrics, 2);
1422
+
1423
+ expect(finals).toHaveLength(0);
1424
+ expect(
1425
+ errors.filter((e) => String((e.cause as Error | undefined)?.message ?? "").includes("Finalize timed out")),
1426
+ ).toHaveLength(0);
1427
+ expect(metrics).toEqual(expect.arrayContaining([
1428
+ expect.objectContaining({
1429
+ name: "stt_provider_finalize_timeout_empty_discard",
1430
+ contextId: "turn-empty-discard",
1431
+ }),
1432
+ ]));
1433
+
1434
+ await plugin.close();
1435
+ bus.stop();
1436
+ await started;
1437
+ });
1438
+
1439
+ it("emits stt.error for odd-length PCM16 without throwing into the bus pump", async () => {
1440
+ const receivedAudio: Buffer[] = [];
1441
+ const endpointUrl = await createLocalServer((socket) => {
1442
+ socket.on("message", (data, isBinary) => {
1443
+ if (isBinary) receivedAudio.push(data as Buffer);
1444
+ });
1445
+ });
1446
+ const bus = new PipelineBusImpl();
1447
+ const started = startBus(bus);
1448
+ const plugin = new DeepgramSTTPlugin();
1449
+ const errors: SttErrorPacket[] = [];
1450
+ bus.on("stt.error", (pkt) => {
1451
+ errors.push(pkt as SttErrorPacket);
1452
+ });
1453
+
1454
+ await plugin.initialize(bus, {
1455
+ api_key: "test",
1456
+ endpoint_url: endpointUrl,
1457
+ sample_rate: 16000,
1458
+ });
1459
+ bus.push(Route.Main, {
1460
+ kind: "stt.audio",
1461
+ contextId: "turn-bad-pcm",
1462
+ timestampMs: Date.now(),
1463
+ audio: new Uint8Array([1, 2, 3]),
1464
+ });
1465
+ await waitFor(errors);
1466
+
1467
+ expect(errors).toEqual([
1468
+ expect.objectContaining({
1469
+ kind: "stt.error",
1470
+ contextId: "turn-bad-pcm",
1471
+ component: "stt",
1472
+ cause: expect.objectContaining({
1473
+ message: expect.stringMatching(/even number of bytes/i),
1474
+ }),
1475
+ }),
1476
+ ]);
1477
+ expect(receivedAudio).toEqual([]);
1478
+
1479
+ await plugin.close();
1480
+ bus.stop();
1481
+ await started;
1482
+ });
1483
+ });
1484
+
1485
+ describe("DeepgramSTTPlugin provider speech-start (vad_events)", () => {
1486
+ it("emits vad.speech_started for the current context when the provider sends SpeechStarted", async () => {
1487
+ const endpointUrl = await createLocalServer((socket) => {
1488
+ socket.send(JSON.stringify({ type: "SpeechStarted", channel: [0, 1], timestamp: 0.42 }));
1489
+ });
1490
+ const bus = new PipelineBusImpl();
1491
+ const started = startBus(bus);
1492
+ const speechStarts: Array<{ contextId: string; confidence: number }> = [];
1493
+ bus.on("vad.speech_started", (pkt) => {
1494
+ speechStarts.push(pkt as unknown as { contextId: string; confidence: number });
1495
+ });
1496
+
1497
+ const plugin = new DeepgramSTTPlugin();
1498
+ await plugin.initialize(bus, {
1499
+ api_key: "test",
1500
+ endpoint_url: endpointUrl,
1501
+ sample_rate: 16000,
1502
+ vad_events: true,
1503
+ });
1504
+ bus.push(Route.Main, { kind: "turn.change", contextId: "turn-1", timestampMs: Date.now() });
1505
+ await waitFor(speechStarts);
1506
+ await plugin.close();
1507
+ bus.stop();
1508
+ await started;
1509
+
1510
+ expect(speechStarts.length).toBeGreaterThanOrEqual(1);
1511
+ expect(speechStarts[0]!.confidence).toBe(1);
1512
+ });
1513
+
1514
+ it("does not emit vad.speech_started by default (opt-in for VAD-less deployments)", async () => {
1515
+ const endpointUrl = await createLocalServer((socket) => {
1516
+ socket.send(JSON.stringify({ type: "SpeechStarted", channel: [0, 1], timestamp: 0.42 }));
1517
+ });
1518
+ const bus = new PipelineBusImpl();
1519
+ const started = startBus(bus);
1520
+ const speechStarts: unknown[] = [];
1521
+ bus.on("vad.speech_started", (pkt) => {
1522
+ speechStarts.push(pkt);
1523
+ });
1524
+
1525
+ const plugin = new DeepgramSTTPlugin();
1526
+ await plugin.initialize(bus, {
1527
+ api_key: "test",
1528
+ endpoint_url: endpointUrl,
1529
+ sample_rate: 16000,
1530
+ });
1531
+ await new Promise((resolve) => setTimeout(resolve, 150));
1532
+ await plugin.close();
1533
+ bus.stop();
1534
+ await started;
1535
+
1536
+ expect(speechStarts).toHaveLength(0);
1537
+ });
1538
+
1539
+ it("passes keyterm config as repeatable query params (nova-3 keyterm prompting)", async () => {
1540
+ const connectionUrls: string[] = [];
1541
+ const server = await new Promise<WebSocketServer>((resolve) => {
1542
+ let nextServer: WebSocketServer;
1543
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1544
+ });
1545
+ servers.push(server);
1546
+ server.on("connection", (_socket, req) => {
1547
+ connectionUrls.push(req.url ?? "");
1548
+ });
1549
+ const address = server.address();
1550
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1551
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1552
+
1553
+ const bus = new PipelineBusImpl();
1554
+ const started = startBus(bus);
1555
+ const plugin = new DeepgramSTTPlugin();
1556
+ await plugin.initialize(bus, {
1557
+ api_key: "test",
1558
+ endpoint_url: endpointUrl,
1559
+ sample_rate: 16000,
1560
+ keyterm: ["Syrinx", "Kuralle Suite"],
1561
+ });
1562
+ await waitFor(connectionUrls);
1563
+ await plugin.close();
1564
+ bus.stop();
1565
+ await started;
1566
+
1567
+ const url = connectionUrls[0]!;
1568
+ expect(url).toContain("keyterm=Syrinx");
1569
+ expect(url).toContain("keyterm=Kuralle+Suite");
1570
+ });
1571
+
1572
+ it("omits keyterm from the URL when not configured", async () => {
1573
+ const connectionUrls: string[] = [];
1574
+ const server = await new Promise<WebSocketServer>((resolve) => {
1575
+ let nextServer: WebSocketServer;
1576
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1577
+ });
1578
+ servers.push(server);
1579
+ server.on("connection", (_socket, req) => {
1580
+ connectionUrls.push(req.url ?? "");
1581
+ });
1582
+ const address = server.address();
1583
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1584
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1585
+
1586
+ const bus = new PipelineBusImpl();
1587
+ const started = startBus(bus);
1588
+ const plugin = new DeepgramSTTPlugin();
1589
+ await plugin.initialize(bus, {
1590
+ api_key: "test",
1591
+ endpoint_url: endpointUrl,
1592
+ sample_rate: 16000,
1593
+ });
1594
+ await waitFor(connectionUrls);
1595
+ await plugin.close();
1596
+ bus.stop();
1597
+ await started;
1598
+
1599
+ expect(connectionUrls[0]!).not.toContain("keyterm=");
1600
+ });
1601
+
1602
+ it("reconfigure replaces keyterms + endpointing + hard language and reconnects with the new URL", async () => {
1603
+ const connectionUrls: string[] = [];
1604
+ const server = await new Promise<WebSocketServer>((resolve) => {
1605
+ let nextServer: WebSocketServer;
1606
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1607
+ });
1608
+ servers.push(server);
1609
+ server.on("connection", (_socket, req) => {
1610
+ connectionUrls.push(req.url ?? "");
1611
+ });
1612
+ const address = server.address();
1613
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1614
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1615
+
1616
+ const bus = new PipelineBusImpl();
1617
+ const started = startBus(bus);
1618
+ const plugin = new DeepgramSTTPlugin();
1619
+ await plugin.initialize(bus, {
1620
+ api_key: "test",
1621
+ endpoint_url: endpointUrl,
1622
+ sample_rate: 16000,
1623
+ language: "en-US",
1624
+ keyterm: ["Syrinx"],
1625
+ endpointing: 300,
1626
+ });
1627
+ await waitFor(connectionUrls, 1);
1628
+ expect(connectionUrls[0]!).toContain("keyterm=Syrinx");
1629
+ expect(connectionUrls[0]!).toContain("endpointing=300");
1630
+ expect(connectionUrls[0]!).toContain("language=en-US");
1631
+
1632
+ expect(plugin.sttReconfigure).toBe(plugin);
1633
+ // Hard language switch (e.g. code-switch to Spanish, or Nova-3 "multi") + keyterms + endpointing.
1634
+ plugin.reconfigure({ keyterms: ["account number"], endpointingMs: 120, language: "es-ES" });
1635
+ await waitFor(connectionUrls, 2);
1636
+
1637
+ await plugin.close();
1638
+ bus.stop();
1639
+ await started;
1640
+
1641
+ const reconnected = connectionUrls[1]!;
1642
+ expect(reconnected).toContain("keyterm=account+number");
1643
+ expect(reconnected).toContain("endpointing=120");
1644
+ expect(reconnected).toContain("language=es-ES");
1645
+ expect(reconnected).not.toContain("keyterm=Syrinx");
1646
+ expect(reconnected).not.toContain("language=en-US");
1647
+ });
1648
+
1649
+ it("reconfigure ignores Flux-only fields and does not reconnect or corrupt the Nova URL", async () => {
1650
+ const connectionUrls: string[] = [];
1651
+ const server = await new Promise<WebSocketServer>((resolve) => {
1652
+ let nextServer: WebSocketServer;
1653
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1654
+ });
1655
+ servers.push(server);
1656
+ server.on("connection", (_socket, req) => {
1657
+ connectionUrls.push(req.url ?? "");
1658
+ });
1659
+ const address = server.address();
1660
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1661
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1662
+
1663
+ const bus = new PipelineBusImpl();
1664
+ const started = startBus(bus);
1665
+ const plugin = new DeepgramSTTPlugin();
1666
+ await plugin.initialize(bus, {
1667
+ api_key: "test",
1668
+ endpoint_url: endpointUrl,
1669
+ sample_rate: 16000,
1670
+ keyterm: ["Syrinx"],
1671
+ endpointing: 300,
1672
+ });
1673
+ await waitFor(connectionUrls, 1);
1674
+
1675
+ plugin.reconfigure({ eotThreshold: 0.95, eagerEotThreshold: 0.4, eotTimeoutMs: 2000, contextText: "noop" });
1676
+ // Give reconnect a chance if it incorrectly fired.
1677
+ await new Promise((resolve) => setTimeout(resolve, 80));
1678
+
1679
+ await plugin.close();
1680
+ bus.stop();
1681
+ await started;
1682
+
1683
+ expect(connectionUrls).toHaveLength(1);
1684
+ const url = connectionUrls[0]!;
1685
+ expect(url).toContain("keyterm=Syrinx");
1686
+ expect(url).toContain("endpointing=300");
1687
+ expect(url).not.toContain("eot_threshold");
1688
+ expect(url).not.toContain("eager_eot");
1689
+ });
1690
+
1691
+ it("defaults encoding/channels/no_delay and lets them be overridden on the listen URL", async () => {
1692
+ const connectionUrls: string[] = [];
1693
+ const server = await new Promise<WebSocketServer>((resolve) => {
1694
+ let nextServer: WebSocketServer;
1695
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1696
+ });
1697
+ servers.push(server);
1698
+ server.on("connection", (_socket, req) => {
1699
+ connectionUrls.push(req.url ?? "");
1700
+ });
1701
+ const address = server.address();
1702
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1703
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1704
+
1705
+ const busDefault = new PipelineBusImpl();
1706
+ const startedDefault = startBus(busDefault);
1707
+ const pluginDefault = new DeepgramSTTPlugin();
1708
+ await pluginDefault.initialize(busDefault, {
1709
+ api_key: "test",
1710
+ endpoint_url: endpointUrl,
1711
+ sample_rate: 16000,
1712
+ });
1713
+ await waitFor(connectionUrls, 1);
1714
+ await pluginDefault.close();
1715
+ busDefault.stop();
1716
+ await startedDefault;
1717
+
1718
+ expect(connectionUrls[0]!).toContain("encoding=linear16");
1719
+ expect(connectionUrls[0]!).toContain("channels=1");
1720
+ expect(connectionUrls[0]!).toContain("no_delay=true");
1721
+
1722
+ const busOverride = new PipelineBusImpl();
1723
+ const startedOverride = startBus(busOverride);
1724
+ const pluginOverride = new DeepgramSTTPlugin();
1725
+ await pluginOverride.initialize(busOverride, {
1726
+ api_key: "test",
1727
+ endpoint_url: endpointUrl,
1728
+ sample_rate: 8000,
1729
+ encoding: "mulaw",
1730
+ channels: 2,
1731
+ no_delay: false,
1732
+ });
1733
+ await waitFor(connectionUrls, 2);
1734
+ await pluginOverride.close();
1735
+ busOverride.stop();
1736
+ await startedOverride;
1737
+
1738
+ const overridden = connectionUrls[1]!;
1739
+ expect(overridden).toContain("encoding=mulaw");
1740
+ expect(overridden).toContain("channels=2");
1741
+ expect(overridden).toContain("no_delay=false");
1742
+ });
1743
+
1744
+ it("merges query_params into the Deepgram listen URL", async () => {
1745
+ const connectionUrls: string[] = [];
1746
+ const server = await new Promise<WebSocketServer>((resolve) => {
1747
+ let nextServer: WebSocketServer;
1748
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1749
+ });
1750
+ servers.push(server);
1751
+ server.on("connection", (_socket, req) => {
1752
+ connectionUrls.push(req.url ?? "");
1753
+ });
1754
+ const address = server.address();
1755
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1756
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1757
+
1758
+ const bus = new PipelineBusImpl();
1759
+ const started = startBus(bus);
1760
+ const plugin = new DeepgramSTTPlugin();
1761
+ await plugin.initialize(bus, {
1762
+ api_key: "test",
1763
+ endpoint_url: endpointUrl,
1764
+ sample_rate: 16000,
1765
+ query_params: {
1766
+ punctuate: true,
1767
+ diarize: "true",
1768
+ numerals: false,
1769
+ tag: ["voice", "prod"],
1770
+ },
1771
+ });
1772
+ await waitFor(connectionUrls);
1773
+ await plugin.close();
1774
+ bus.stop();
1775
+ await started;
1776
+
1777
+ const url = connectionUrls[0]!;
1778
+ expect(url).toContain("punctuate=true");
1779
+ expect(url).toContain("diarize=true");
1780
+ expect(url).toContain("numerals=false");
1781
+ expect(url).toContain("tag=voice");
1782
+ expect(url).toContain("tag=prod");
1783
+ });
1784
+ });