@kuralle-syrinx/browser-client 4.2.0 → 4.3.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.
package/src/index.test.ts DELETED
@@ -1,818 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
- import { decodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
5
- import { SyrinxBrowserClient, type SyrinxBrowserClientEvent } from "./index.js";
6
-
7
- const originalWebSocket = globalThis.WebSocket;
8
- let sockets: FakeWebSocket[] = [];
9
-
10
- class FakeWebSocket {
11
- static readonly CONNECTING = 0;
12
- static readonly OPEN = 1;
13
- static readonly CLOSING = 2;
14
- static readonly CLOSED = 3;
15
-
16
- binaryType: BinaryType = "blob";
17
- readyState = FakeWebSocket.OPEN;
18
- readonly sent: unknown[] = [];
19
- private readonly listeners = new Map<string, Array<(event: any) => void>>();
20
-
21
- constructor(
22
- readonly url: string | URL,
23
- readonly protocols?: string | string[],
24
- ) {
25
- sockets.push(this);
26
- }
27
-
28
- addEventListener(type: string, listener: (event: any) => void): void {
29
- const listeners = this.listeners.get(type) ?? [];
30
- listeners.push(listener);
31
- this.listeners.set(type, listeners);
32
- }
33
-
34
- removeEventListener(type: string, listener: (event: any) => void): void {
35
- const listeners = this.listeners.get(type);
36
- if (!listeners) return;
37
- const next = listeners.filter((entry) => entry !== listener);
38
- if (next.length === 0) this.listeners.delete(type);
39
- else this.listeners.set(type, next);
40
- }
41
-
42
- send(data: unknown): void {
43
- this.sent.push(data);
44
- }
45
-
46
- close(): void {
47
- this.readyState = FakeWebSocket.CLOSED;
48
- }
49
-
50
- dispatch(type: string, event: any): void {
51
- for (const listener of this.listeners.get(type) ?? []) listener(event);
52
- }
53
- }
54
-
55
- function makeClient(overrides: Partial<ConstructorParameters<typeof SyrinxBrowserClient>[0]> = {}) {
56
- return new SyrinxBrowserClient({ url: "ws://localhost/ws", ...overrides });
57
- }
58
-
59
- const PCM_READY_MESSAGE = JSON.stringify({
60
- type: "ready",
61
- audio: {
62
- inputSampleRateHz: 16000,
63
- outputSampleRateHz: 16000,
64
- encoding: "pcm_s16le",
65
- channels: 1,
66
- },
67
- });
68
-
69
- async function dispatchPcmReady(socket: FakeWebSocket): Promise<void> {
70
- socket.dispatch("message", { data: PCM_READY_MESSAGE });
71
- await new Promise((resolve) => setTimeout(resolve, 0));
72
- }
73
-
74
- function binaryEnvelopes(socket: FakeWebSocket): Uint8Array[] {
75
- return socket.sent.filter((entry): entry is Uint8Array => entry instanceof Uint8Array);
76
- }
77
-
78
- function collectEvents(client: SyrinxBrowserClient): SyrinxBrowserClientEvent[] {
79
- const events: SyrinxBrowserClientEvent[] = [];
80
- client.on((e) => events.push(e));
81
- return events;
82
- }
83
-
84
- describe("SyrinxBrowserClient — audio sequence", () => {
85
- beforeEach(() => {
86
- sockets = [];
87
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
88
- });
89
-
90
- afterEach(() => {
91
- globalThis.WebSocket = originalWebSocket;
92
- });
93
-
94
- it("adds monotonic sequence metadata to every audio send path by default", async () => {
95
- const client = makeClient();
96
- client.connect();
97
- const socket = sockets[0]!;
98
- await dispatchPcmReady(socket);
99
-
100
- client.sendAudioBase64(Buffer.from([1, 0, 2, 0]).toString("base64"), 16000, { contextId: "turn-json" });
101
- client.sendAudioPcm(new Uint8Array([3, 0, 4, 0]), 16000, { contextId: "turn-pcm" });
102
- client.sendFloat32Audio(new Float32Array([0, 0.5, 1]), {
103
- fromSampleRateHz: 48000,
104
- toSampleRateHz: 16000,
105
- contextId: "turn-float",
106
- });
107
-
108
- const jsonAudio = JSON.parse(
109
- socket.sent.find((entry) => {
110
- if (typeof entry !== "string") return false;
111
- return (JSON.parse(entry) as { type?: string }).type === "audio";
112
- }) as string,
113
- ) as { readonly sequence?: number };
114
- const envelopes = binaryEnvelopes(socket);
115
- const pcmEnvelope = decodeSyrinxAudioEnvelope(envelopes[0]!);
116
- const floatEnvelope = decodeSyrinxAudioEnvelope(envelopes[1]!);
117
-
118
- expect(jsonAudio.sequence).toBe(1);
119
- expect(pcmEnvelope.header).toMatchObject({ contextId: "turn-pcm", sequence: 2 });
120
- expect(floatEnvelope.header).toMatchObject({ contextId: "turn-float", sequence: 3 });
121
- });
122
-
123
- it("honors explicit audio sequence overrides and advances later automatic sequences past them", async () => {
124
- const client = makeClient();
125
- client.connect();
126
- const socket = sockets[0]!;
127
- await dispatchPcmReady(socket);
128
-
129
- client.sendAudioPcm(new Uint8Array([1, 0, 2, 0]), 16000, { contextId: "turn-pcm", sequence: 10 });
130
- client.sendFloat32Audio(new Float32Array([0, 0.5, 1]), {
131
- fromSampleRateHz: 48000,
132
- toSampleRateHz: 16000,
133
- contextId: "turn-float",
134
- });
135
-
136
- const envelopes = binaryEnvelopes(socket);
137
- const pcmEnvelope = decodeSyrinxAudioEnvelope(envelopes[0]!);
138
- const floatEnvelope = decodeSyrinxAudioEnvelope(envelopes[1]!);
139
-
140
- expect(pcmEnvelope.header).toMatchObject({ contextId: "turn-pcm", sequence: 10 });
141
- expect(floatEnvelope.header).toMatchObject({ contextId: "turn-float", sequence: 11 });
142
- });
143
-
144
- it("rejects duplicate or regressing explicit audio sequence overrides before sending", async () => {
145
- const client = makeClient();
146
- client.connect();
147
- const socket = sockets[0]!;
148
-
149
- client.sendAudioBase64(Buffer.from([1, 0, 2, 0]).toString("base64"), 16000, {
150
- contextId: "turn-json",
151
- sequence: 3,
152
- });
153
- await dispatchPcmReady(socket);
154
-
155
- expect(() => client.sendAudioPcm(new Uint8Array([3, 0, 4, 0]), 16000, {
156
- contextId: "turn-pcm",
157
- sequence: 3,
158
- })).toThrow("audio sequence must increase monotonically: 3 -> 3");
159
- expect(() => client.sendFloat32Audio(new Float32Array([0, 0.5, 1]), {
160
- fromSampleRateHz: 48000,
161
- toSampleRateHz: 16000,
162
- contextId: "turn-float",
163
- sequence: 2,
164
- })).toThrow("audio sequence must increase monotonically: 3 -> 2");
165
-
166
- const jsonAudio = socket.sent.filter((entry) => {
167
- if (typeof entry !== "string") return false;
168
- return (JSON.parse(entry) as { type?: string }).type === "audio";
169
- });
170
- expect(jsonAudio).toHaveLength(1);
171
- });
172
-
173
- it("emits validated server JSON messages", () => {
174
- const client = makeClient();
175
- const messages: unknown[] = [];
176
- client.on((event) => {
177
- if (event.type === "message") messages.push(event.message);
178
- });
179
- client.connect();
180
-
181
- sockets[0]!.dispatch("message", {
182
- data: JSON.stringify({
183
- type: "tts_chunk",
184
- turnId: "turn-1",
185
- sequence: 1,
186
- sampleRateHz: 16000,
187
- encoding: "pcm_s16le",
188
- channels: 1,
189
- byteLength: 320,
190
- durationMs: 10,
191
- }),
192
- });
193
-
194
- expect(messages).toEqual([
195
- {
196
- type: "tts_chunk",
197
- turnId: "turn-1",
198
- sequence: 1,
199
- sampleRateHz: 16000,
200
- encoding: "pcm_s16le",
201
- channels: 1,
202
- byteLength: 320,
203
- durationMs: 10,
204
- },
205
- ]);
206
- });
207
-
208
- it("surfaces malformed server JSON messages as client errors", () => {
209
- const client = makeClient();
210
- const messages: unknown[] = [];
211
- const errors: string[] = [];
212
- client.on((event) => {
213
- if (event.type === "message") messages.push(event.message);
214
- if (event.type === "error" && event.error instanceof Error) errors.push(event.error.message);
215
- });
216
- client.connect();
217
-
218
- sockets[0]!.dispatch("message", {
219
- data: JSON.stringify({
220
- type: "agent_chunk",
221
- turnId: "turn-1",
222
- text: 42,
223
- }),
224
- });
225
-
226
- expect(messages).toEqual([]);
227
- expect(errors).toEqual(["agent_chunk.text must be a non-empty string"]);
228
- });
229
- });
230
-
231
- describe("SyrinxBrowserClient — sessionId", () => {
232
- beforeEach(() => {
233
- sockets = [];
234
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
235
- });
236
-
237
- afterEach(() => {
238
- globalThis.WebSocket = originalWebSocket;
239
- });
240
-
241
- it("is null before any ready message", () => {
242
- const client = makeClient();
243
- client.connect();
244
- expect(client.sessionId).toBeNull();
245
- });
246
-
247
- it("captures sessionId from the ready message", () => {
248
- const client = makeClient();
249
- client.connect();
250
- sockets[0]!.dispatch("message", {
251
- data: JSON.stringify({ type: "ready", sessionId: "sess-abc" }),
252
- });
253
- expect(client.sessionId).toBe("sess-abc");
254
- });
255
-
256
- it("parses resumed and resumeWindowMs from the ready message", () => {
257
- const client = makeClient();
258
- const messages: SyrinxBrowserClientEvent[] = [];
259
- client.on((e) => messages.push(e));
260
- client.connect();
261
-
262
- sockets[0]!.dispatch("message", {
263
- data: JSON.stringify({
264
- type: "ready",
265
- sessionId: "sess-abc",
266
- resumed: true,
267
- resumeWindowMs: 15000,
268
- }),
269
- });
270
-
271
- const readyMsg = messages.find((e) => e.type === "message" && e.message.type === "ready");
272
- expect(readyMsg).toBeDefined();
273
- if (readyMsg?.type === "message" && readyMsg.message.type === "ready") {
274
- expect(readyMsg.message.resumed).toBe(true);
275
- expect(readyMsg.message.resumeWindowMs).toBe(15000);
276
- }
277
- });
278
- });
279
-
280
- describe("SyrinxBrowserClient — reconnect", () => {
281
- beforeEach(() => {
282
- sockets = [];
283
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
284
- vi.useFakeTimers();
285
- });
286
-
287
- afterEach(() => {
288
- globalThis.WebSocket = originalWebSocket;
289
- vi.useRealTimers();
290
- });
291
-
292
- it("reconnects after unexpected close and re-dials with sessionId in URL", async () => {
293
- const client = makeClient({ reconnect: { baseDelayMs: 100, maxAttempts: 3 }, keepaliveIntervalMs: false });
294
- const events = collectEvents(client);
295
- client.connect();
296
-
297
- sockets[0]!.dispatch("open", {});
298
- sockets[0]!.dispatch("message", { data: JSON.stringify({ type: "ready", sessionId: "sess-xyz" }) });
299
-
300
- // Unexpected close
301
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
302
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
303
-
304
- expect(events.map((e) => e.type)).toContain("reconnecting");
305
- expect(sockets).toHaveLength(1);
306
-
307
- await vi.advanceTimersByTimeAsync(200);
308
-
309
- expect(sockets).toHaveLength(2);
310
- expect(String(sockets[1]!.url)).toContain("sessionId=sess-xyz");
311
- });
312
-
313
- it("emits reconnected (not open) when the new socket opens", async () => {
314
- const client = makeClient({ reconnect: { baseDelayMs: 50, maxAttempts: 3 }, keepaliveIntervalMs: false });
315
- const events = collectEvents(client);
316
- client.connect();
317
-
318
- sockets[0]!.dispatch("open", {});
319
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
320
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
321
-
322
- await vi.advanceTimersByTimeAsync(100);
323
- sockets[1]!.dispatch("open", {});
324
-
325
- const types = events.map((e) => e.type);
326
- expect(types).toContain("open");
327
- expect(types).toContain("reconnecting");
328
- expect(types).toContain("reconnected");
329
- // reconnected comes after open, not instead of it
330
- expect(types.indexOf("open")).toBeLessThan(types.indexOf("reconnected"));
331
- });
332
-
333
- it("emits reconnecting with increasing attempt counter", async () => {
334
- const client = makeClient({ reconnect: { baseDelayMs: 10, maxAttempts: 5 }, keepaliveIntervalMs: false });
335
- const reconnectingEvents: Array<{ attempt: number }> = [];
336
- client.on((e) => {
337
- if (e.type === "reconnecting") reconnectingEvents.push({ attempt: e.attempt });
338
- });
339
- client.connect();
340
-
341
- sockets[0]!.dispatch("open", {});
342
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
343
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
344
-
345
- expect(reconnectingEvents[0]!.attempt).toBe(1);
346
-
347
- await vi.advanceTimersByTimeAsync(30);
348
- // sockets[1] opens and immediately closes without dispatching open
349
- sockets[1]!.readyState = FakeWebSocket.CLOSED;
350
- sockets[1]!.dispatch("close", { code: 1006, reason: "" });
351
-
352
- expect(reconnectingEvents[1]!.attempt).toBe(2);
353
- });
354
-
355
- it("delays grow exponentially between attempts", () => {
356
- const client = makeClient({ reconnect: { baseDelayMs: 1000, maxDelayMs: 30_000, maxAttempts: 5 }, keepaliveIntervalMs: false });
357
- const delays: number[] = [];
358
- client.on((e) => {
359
- if (e.type === "reconnecting") delays.push(e.delayMs);
360
- });
361
- client.connect();
362
-
363
- sockets[0]!.dispatch("open", {});
364
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
365
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
366
-
367
- // First delay: ~1000 ms (base, with up to 20% jitter = max 1200)
368
- expect(delays[0]).toBeGreaterThanOrEqual(1000);
369
- expect(delays[0]).toBeLessThanOrEqual(1200);
370
- });
371
-
372
- it("emits resumed when the server ready includes resumed:true after reconnect", async () => {
373
- const client = makeClient({ reconnect: { baseDelayMs: 50, maxAttempts: 3 }, keepaliveIntervalMs: false });
374
- const events = collectEvents(client);
375
- client.connect();
376
-
377
- sockets[0]!.dispatch("open", {});
378
- sockets[0]!.dispatch("message", { data: JSON.stringify({ type: "ready", sessionId: "sess-xyz" }) });
379
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
380
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
381
-
382
- await vi.advanceTimersByTimeAsync(100);
383
- sockets[1]!.dispatch("open", {});
384
- sockets[1]!.dispatch("message", {
385
- data: JSON.stringify({ type: "ready", sessionId: "sess-xyz", resumed: true, resumeWindowMs: 15000 }),
386
- });
387
-
388
- const types = events.map((e) => e.type);
389
- expect(types).toContain("resumed");
390
- expect(types.indexOf("reconnected")).toBeLessThan(types.indexOf("resumed"));
391
- });
392
-
393
- it("does not emit resumed when server ready has resumed:false", async () => {
394
- const client = makeClient({ reconnect: { baseDelayMs: 50, maxAttempts: 3 }, keepaliveIntervalMs: false });
395
- const events = collectEvents(client);
396
- client.connect();
397
-
398
- sockets[0]!.dispatch("open", {});
399
- sockets[0]!.dispatch("message", { data: JSON.stringify({ type: "ready", sessionId: "sess-xyz" }) });
400
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
401
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
402
-
403
- await vi.advanceTimersByTimeAsync(100);
404
- sockets[1]!.dispatch("open", {});
405
- sockets[1]!.dispatch("message", {
406
- data: JSON.stringify({ type: "ready", sessionId: "sess-new", resumed: false }),
407
- });
408
-
409
- expect(events.map((e) => e.type)).not.toContain("resumed");
410
- });
411
-
412
- it("connect() during CLOSING opens a new socket after the closing socket finishes", () => {
413
- const client = makeClient({ reconnect: false, keepaliveIntervalMs: false });
414
- client.connect();
415
- const first = sockets[0]!;
416
- first.readyState = FakeWebSocket.CLOSING;
417
- client.connect();
418
- expect(sockets).toHaveLength(1);
419
- first.readyState = FakeWebSocket.CLOSED;
420
- first.dispatch("close", { code: 1000, reason: "" });
421
- expect(sockets).toHaveLength(2);
422
- expect(sockets[1]!.readyState).toBe(FakeWebSocket.OPEN);
423
- });
424
-
425
- it("clean app-initiated close does not reconnect and emits close", () => {
426
- const client = makeClient({ keepaliveIntervalMs: false });
427
- const events = collectEvents(client);
428
- client.connect();
429
-
430
- sockets[0]!.dispatch("open", {});
431
- client.close(1000, "done");
432
- sockets[0]!.dispatch("close", { code: 1000, reason: "done" });
433
-
434
- expect(events.map((e) => e.type)).not.toContain("reconnecting");
435
- expect(events.find((e) => e.type === "close")).toMatchObject({ type: "close", code: 1000, reason: "done" });
436
- expect(sockets).toHaveLength(1);
437
- });
438
-
439
- it("storm cap stops reconnecting after maxAttempts and emits close", async () => {
440
- const client = makeClient({ reconnect: { maxAttempts: 2, baseDelayMs: 10 }, keepaliveIntervalMs: false });
441
- const events = collectEvents(client);
442
- client.connect();
443
-
444
- sockets[0]!.dispatch("open", {});
445
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
446
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
447
-
448
- // Attempt 1 scheduled
449
- await vi.advanceTimersByTimeAsync(30);
450
- // sockets[1] created — no open dispatch, close immediately
451
- sockets[1]!.readyState = FakeWebSocket.CLOSED;
452
- sockets[1]!.dispatch("close", { code: 1006, reason: "" });
453
-
454
- // Attempt 2 scheduled
455
- await vi.advanceTimersByTimeAsync(60);
456
- // sockets[2] created — close immediately
457
- sockets[2]!.readyState = FakeWebSocket.CLOSED;
458
- sockets[2]!.dispatch("close", { code: 1006, reason: "" });
459
-
460
- // maxAttempts=2 exhausted — emits close, no sockets[3]
461
- const types = events.map((e) => e.type);
462
- expect(types.filter((t) => t === "reconnecting")).toHaveLength(2);
463
- expect(types.filter((t) => t === "close")).toHaveLength(1);
464
- expect(sockets).toHaveLength(3);
465
- });
466
-
467
- it("reconnect disabled entirely with reconnect:false emits close on unexpected disconnect", () => {
468
- const client = makeClient({ reconnect: false, keepaliveIntervalMs: false });
469
- const events = collectEvents(client);
470
- client.connect();
471
-
472
- sockets[0]!.dispatch("open", {});
473
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
474
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
475
-
476
- const types = events.map((e) => e.type);
477
- expect(types).not.toContain("reconnecting");
478
- expect(types).toContain("close");
479
- expect(sockets).toHaveLength(1);
480
- });
481
-
482
- it("resets reconnect counter after a successful reconnect", async () => {
483
- const client = makeClient({ reconnect: { maxAttempts: 2, baseDelayMs: 10 }, keepaliveIntervalMs: false });
484
- const reconnectingCount = () => collectEvents(client).filter((e) => e.type === "reconnecting").length;
485
- const events = collectEvents(client);
486
-
487
- client.connect();
488
- sockets[0]!.dispatch("open", {});
489
- sockets[0]!.readyState = FakeWebSocket.CLOSED;
490
- sockets[0]!.dispatch("close", { code: 1006, reason: "" });
491
-
492
- await vi.advanceTimersByTimeAsync(30);
493
- // sockets[1] reconnects successfully
494
- sockets[1]!.dispatch("open", {});
495
- // then disconnects again
496
- sockets[1]!.readyState = FakeWebSocket.CLOSED;
497
- sockets[1]!.dispatch("close", { code: 1006, reason: "" });
498
-
499
- // Second storm: attempt counter reset after reconnect succeeded, so we get another 2 attempts
500
- await vi.advanceTimersByTimeAsync(60);
501
-
502
- const reconnectingEvents = events.filter((e) => e.type === "reconnecting") as Array<{ type: "reconnecting"; attempt: number }>;
503
- // After the successful reconnect, attempt resets: second disconnect starts at attempt 1 again
504
- expect(reconnectingEvents[0]!.attempt).toBe(1);
505
- expect(reconnectingEvents[1]!.attempt).toBe(1);
506
- });
507
-
508
- it("gives up after maxQuickFailures open-then-die flaps even when maxAttempts is high", async () => {
509
- // A peer that accepts the socket then drops it immediately (half-broken mid-deploy,
510
- // or a token accepted-then-rejected) must not loop forever: backoff can't fix it.
511
- const client = makeClient({
512
- reconnect: { maxAttempts: 100, baseDelayMs: 10, minStableMs: 5_000, maxQuickFailures: 3 },
513
- keepaliveIntervalMs: false,
514
- });
515
- const events = collectEvents(client);
516
- client.connect();
517
-
518
- for (let i = 0; i < 3; i += 1) {
519
- sockets[i]!.dispatch("open", {}); // opens...
520
- sockets[i]!.readyState = FakeWebSocket.CLOSED;
521
- sockets[i]!.dispatch("close", { code: 1006, reason: "" }); // ...then dies < minStableMs
522
- await vi.advanceTimersByTimeAsync(50);
523
- }
524
-
525
- const types = events.map((e) => e.type);
526
- // 3rd flap hits maxQuickFailures → give up despite maxAttempts=100; no 4th socket.
527
- expect(types.filter((t) => t === "close")).toHaveLength(1);
528
- expect(sockets).toHaveLength(3);
529
- });
530
- });
531
-
532
- describe("SyrinxBrowserClient — keepalive", () => {
533
- beforeEach(() => {
534
- sockets = [];
535
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
536
- vi.useFakeTimers();
537
- });
538
-
539
- afterEach(() => {
540
- globalThis.WebSocket = originalWebSocket;
541
- vi.useRealTimers();
542
- });
543
-
544
- it("sends ping at the configured interval", async () => {
545
- const client = makeClient({ keepaliveIntervalMs: 5000, reconnect: false });
546
- client.connect();
547
- sockets[0]!.dispatch("open", {});
548
-
549
- await vi.advanceTimersByTimeAsync(5000);
550
-
551
- expect(sockets[0]!.sent).toContain(JSON.stringify({ type: "ping" }));
552
- });
553
-
554
- it("sends pings repeatedly at each interval", async () => {
555
- const client = makeClient({ keepaliveIntervalMs: 5000, reconnect: false });
556
- client.connect();
557
- sockets[0]!.dispatch("open", {});
558
-
559
- await vi.advanceTimersByTimeAsync(15000);
560
-
561
- const pings = sockets[0]!.sent.filter((s) => s === JSON.stringify({ type: "ping" }));
562
- expect(pings).toHaveLength(3);
563
- });
564
-
565
- it("does not send pings before the interval has elapsed", async () => {
566
- const client = makeClient({ keepaliveIntervalMs: 5000, reconnect: false });
567
- client.connect();
568
- sockets[0]!.dispatch("open", {});
569
-
570
- await vi.advanceTimersByTimeAsync(4999);
571
-
572
- expect(sockets[0]!.sent.filter((s) => s === JSON.stringify({ type: "ping" }))).toHaveLength(0);
573
- });
574
-
575
- it("stops pings after clean close", async () => {
576
- const client = makeClient({ keepaliveIntervalMs: 5000, reconnect: false });
577
- client.connect();
578
- sockets[0]!.dispatch("open", {});
579
- client.close();
580
- sockets[0]!.dispatch("close", { code: 1000, reason: "" });
581
-
582
- await vi.advanceTimersByTimeAsync(20000);
583
-
584
- const pings = sockets[0]!.sent.filter((s) => s === JSON.stringify({ type: "ping" }));
585
- expect(pings).toHaveLength(0);
586
- });
587
-
588
- it("keepalive:false disables pings entirely", async () => {
589
- const client = makeClient({ keepaliveIntervalMs: false, reconnect: false });
590
- client.connect();
591
- sockets[0]!.dispatch("open", {});
592
-
593
- await vi.advanceTimersByTimeAsync(60000);
594
-
595
- expect(sockets[0]!.sent.filter((s) => s === JSON.stringify({ type: "ping" }))).toHaveLength(0);
596
- });
597
-
598
- it("uses default 10 s interval when keepaliveIntervalMs is not set", async () => {
599
- const client = makeClient({ reconnect: false });
600
- client.connect();
601
- sockets[0]!.dispatch("open", {});
602
-
603
- await vi.advanceTimersByTimeAsync(10000);
604
-
605
- expect(sockets[0]!.sent).toContain(JSON.stringify({ type: "ping" }));
606
- });
607
-
608
- it("stops keepalive and emits error when ping send races a socket close", async () => {
609
- const client = makeClient({ keepaliveIntervalMs: 5000, reconnect: false });
610
- const events = collectEvents(client);
611
- client.connect();
612
- sockets[0]!.dispatch("open", {});
613
-
614
- sockets[0]!.send = () => {
615
- throw new Error("socket closed during ping");
616
- };
617
-
618
- await vi.advanceTimersByTimeAsync(5000);
619
- await vi.advanceTimersByTimeAsync(5000);
620
-
621
- expect(events.some((e) => e.type === "error")).toBe(true);
622
- expect(sockets[0]!.sent.filter((s) => s === JSON.stringify({ type: "ping" }))).toHaveLength(0);
623
- });
624
- });
625
-
626
- describe("SyrinxBrowserClient — metrics", () => {
627
- beforeEach(() => {
628
- sockets = [];
629
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
630
- });
631
-
632
- afterEach(() => {
633
- globalThis.WebSocket = originalWebSocket;
634
- });
635
-
636
- it("surfaces populated server metrics messages", () => {
637
- const client = makeClient();
638
- const events = collectEvents(client);
639
- client.connect();
640
- const socket = sockets[0]!;
641
- socket.dispatch("open", {});
642
-
643
- socket.dispatch("message", {
644
- data: JSON.stringify({
645
- type: "metrics",
646
- turnId: "turn-1",
647
- correlationId: "turn-1",
648
- speechEndMs: 1000,
649
- textReadyMs: 1500,
650
- firstAudioByteMs: 1700,
651
- firstAudioPlayedMs: 1900,
652
- lastAudioPlayedMs: 2500,
653
- sttMs: 200,
654
- llmTTFTMs: 300,
655
- ttsTTFBMs: 200,
656
- e2eMs: 900,
657
- }),
658
- });
659
-
660
- const metricsEvent = events.find((event) =>
661
- event.type === "message" && event.message.type === "metrics",
662
- );
663
- expect(metricsEvent).toMatchObject({
664
- type: "message",
665
- message: {
666
- type: "metrics",
667
- turnId: "turn-1",
668
- correlationId: "turn-1",
669
- e2eMs: 900,
670
- },
671
- });
672
- });
673
- });
674
-
675
- describe("SyrinxBrowserClient — tool-call cues (G3/WBS-3)", () => {
676
- beforeEach(() => {
677
- sockets = [];
678
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
679
- });
680
-
681
- afterEach(() => {
682
- globalThis.WebSocket = originalWebSocket;
683
- });
684
-
685
- it("surfaces the four typed tool_call_* lifecycle messages", () => {
686
- const client = makeClient();
687
- const events = collectEvents(client);
688
- client.connect();
689
- const socket = sockets[0]!;
690
- socket.dispatch("open", {});
691
-
692
- const frames = [
693
- { type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
694
- { type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 2000 },
695
- { type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
696
- { type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" },
697
- ];
698
- for (const frame of frames) socket.dispatch("message", { data: JSON.stringify(frame) });
699
-
700
- const cueMessages = events
701
- .filter((event): event is Extract<SyrinxBrowserClientEvent, { type: "message" }> => event.type === "message")
702
- .map((event) => event.message)
703
- .filter((message) => message.type.startsWith("tool_call_"));
704
- expect(cueMessages).toMatchObject(frames);
705
- });
706
- });
707
-
708
- describe("local barge-in (client_interrupt)", () => {
709
- beforeEach(() => {
710
- sockets = [];
711
- globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
712
- });
713
-
714
- afterEach(() => {
715
- globalThis.WebSocket = originalWebSocket;
716
- });
717
-
718
- class BargeInMockAudioContext {
719
- currentTime = 0;
720
- destination = {};
721
- createBuffer(_channels: number, length: number, sampleRate: number) {
722
- return {
723
- numberOfChannels: 1,
724
- length,
725
- sampleRate,
726
- duration: length / sampleRate,
727
- copyToChannel: () => undefined,
728
- };
729
- }
730
- createBufferSource() {
731
- return {
732
- buffer: null,
733
- onended: null as (() => void) | null,
734
- connect: () => undefined,
735
- start: () => undefined,
736
- };
737
- }
738
- }
739
-
740
- function pcm16Chunk(amplitude: number, samples = 320): Uint8Array {
741
- const data = new Int16Array(samples).fill(amplitude);
742
- return new Uint8Array(data.buffer);
743
- }
744
-
745
- function clientInterrupts(socket: FakeWebSocket): Array<Record<string, unknown>> {
746
- return socket.sent
747
- .filter((entry): entry is string => typeof entry === "string")
748
- .map((entry) => JSON.parse(entry) as Record<string, unknown>)
749
- .filter((msg) => msg.type === "client_interrupt");
750
- }
751
-
752
- async function playingClient() {
753
- const audioContext = new BargeInMockAudioContext() as unknown as AudioContext;
754
- const client = makeClient({ audioContext });
755
- client.connect();
756
- const socket = sockets.at(-1)!;
757
- socket.dispatch("open", {});
758
- await dispatchPcmReady(socket);
759
- const jitterBuffer = (client as unknown as { jitterBuffer: { enqueue(b: ArrayBuffer, c?: string): void } })
760
- .jitterBuffer;
761
- jitterBuffer.enqueue(new Int16Array(1600).fill(1000).buffer, "assistant-turn");
762
- return { client, socket };
763
- }
764
-
765
- it("sends client_interrupt once after sustained loud speech during assistant playout", async () => {
766
- const { client, socket } = await playingClient();
767
-
768
- // 15 × 20ms loud chunks = 300ms sustained ≥ 280ms gate.
769
- for (let i = 0; i < 15; i += 1) {
770
- client.sendAudioPcm(pcm16Chunk(8000), 16000);
771
- }
772
- const interrupts = clientInterrupts(socket);
773
- expect(interrupts).toHaveLength(1);
774
- expect(interrupts[0]!.assistantContextId).toBe("assistant-turn");
775
-
776
- // Further loud audio in the same playout must not re-send.
777
- for (let i = 0; i < 15; i += 1) {
778
- client.sendAudioPcm(pcm16Chunk(8000), 16000);
779
- }
780
- expect(clientInterrupts(socket)).toHaveLength(1);
781
- });
782
-
783
- it("does not interrupt for quiet audio during playout", async () => {
784
- const { client, socket } = await playingClient();
785
- for (let i = 0; i < 30; i += 1) {
786
- client.sendAudioPcm(pcm16Chunk(100), 16000);
787
- }
788
- expect(clientInterrupts(socket)).toHaveLength(0);
789
- });
790
-
791
- it("does not interrupt when no assistant audio is playing out", async () => {
792
- const client = makeClient();
793
- client.connect();
794
- const socket = sockets.at(-1)!;
795
- socket.dispatch("open", {});
796
- await dispatchPcmReady(socket);
797
- for (let i = 0; i < 30; i += 1) {
798
- client.sendAudioPcm(pcm16Chunk(8000), 16000);
799
- }
800
- expect(clientInterrupts(socket)).toHaveLength(0);
801
- });
802
-
803
- it("can be disabled via bargeIn: false", async () => {
804
- const audioContext = new BargeInMockAudioContext() as unknown as AudioContext;
805
- const client = makeClient({ audioContext, bargeIn: false });
806
- client.connect();
807
- const socket = sockets.at(-1)!;
808
- socket.dispatch("open", {});
809
- await dispatchPcmReady(socket);
810
- const jitterBuffer = (client as unknown as { jitterBuffer: { enqueue(b: ArrayBuffer, c?: string): void } })
811
- .jitterBuffer;
812
- jitterBuffer.enqueue(new Int16Array(1600).fill(1000).buffer, "assistant-turn");
813
- for (let i = 0; i < 30; i += 1) {
814
- client.sendAudioPcm(pcm16Chunk(8000), 16000);
815
- }
816
- expect(clientInterrupts(socket)).toHaveLength(0);
817
- });
818
- });