@alexkroman1/aai 0.12.3 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/CHANGELOG.md +174 -0
  3. package/dist/constants-VTFoymJ-.js +47 -0
  4. package/dist/host/_run-code.d.ts +1 -1
  5. package/dist/host/_runtime-conformance.d.ts +4 -5
  6. package/dist/host/builtin-tools.d.ts +11 -9
  7. package/dist/host/runtime-barrel.d.ts +15 -0
  8. package/dist/{direct-executor-DRRrZUp0.js → host/runtime-barrel.js} +453 -348
  9. package/dist/host/runtime-config.d.ts +42 -0
  10. package/dist/host/runtime.d.ts +119 -35
  11. package/dist/host/s2s.d.ts +14 -38
  12. package/dist/host/server.d.ts +16 -8
  13. package/dist/host/session-ctx.d.ts +55 -0
  14. package/dist/host/session.d.ts +20 -70
  15. package/dist/host/tool-executor.d.ts +20 -0
  16. package/dist/host/unstorage-kv.d.ts +1 -1
  17. package/dist/host/ws-handler.d.ts +4 -2
  18. package/dist/index.d.ts +9 -20
  19. package/dist/index.js +63 -2
  20. package/dist/{isolate → sdk}/_internal-types.d.ts +5 -9
  21. package/dist/{isolate → sdk}/constants.d.ts +6 -4
  22. package/dist/sdk/define.d.ts +66 -0
  23. package/dist/{isolate → sdk}/kv.d.ts +1 -49
  24. package/dist/sdk/manifest-barrel.d.ts +8 -0
  25. package/dist/sdk/manifest-barrel.js +52 -0
  26. package/dist/sdk/manifest.d.ts +50 -0
  27. package/dist/{isolate → sdk}/protocol.d.ts +59 -36
  28. package/dist/sdk/protocol.js +163 -0
  29. package/dist/{isolate → sdk}/system-prompt.d.ts +2 -2
  30. package/dist/sdk/types.d.ts +201 -0
  31. package/dist/sdk/ws-upgrade.d.ts +5 -0
  32. package/dist/{system-prompt-DYAYFW99.js → system-prompt-nik_iavo.js} +10 -10
  33. package/dist/types-Cfx_4QDK.js +39 -0
  34. package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
  35. package/exports-no-dev-deps.test.ts +62 -0
  36. package/host/_mock-ws.ts +185 -0
  37. package/host/_run-code.ts +217 -0
  38. package/host/_runtime-conformance.ts +143 -0
  39. package/host/_test-utils.ts +276 -0
  40. package/host/builtin-tools.test.ts +774 -0
  41. package/host/builtin-tools.ts +255 -0
  42. package/host/cleanup.test.ts +422 -0
  43. package/host/fixture-replay.test.ts +463 -0
  44. package/host/fixtures/README.md +40 -0
  45. package/host/fixtures/greeting-session-sequence.json +40 -0
  46. package/host/fixtures/reply-audio-samples.json +42 -0
  47. package/host/fixtures/reply-lifecycle.json +21 -0
  48. package/host/fixtures/session-ready.json +48 -0
  49. package/host/fixtures/session-updated.json +45 -0
  50. package/host/fixtures/simple-question-sequence.json +73 -0
  51. package/host/fixtures/tool-call-sequence.json +114 -0
  52. package/host/fixtures/tool-calls.json +11 -0
  53. package/host/fixtures/tool-config-session-sequence.json +51 -0
  54. package/host/fixtures/user-speech-recognition.json +30 -0
  55. package/host/fixtures/web-search-sequence.json +122 -0
  56. package/host/integration.test.ts +222 -0
  57. package/host/runtime-barrel.ts +25 -0
  58. package/host/runtime-config.test.ts +71 -0
  59. package/host/runtime-config.ts +99 -0
  60. package/host/runtime.test.ts +641 -0
  61. package/host/runtime.ts +308 -0
  62. package/host/s2s-fixtures.test.ts +237 -0
  63. package/host/s2s.test.ts +562 -0
  64. package/host/s2s.ts +310 -0
  65. package/host/server-shutdown.test.ts +76 -0
  66. package/host/server.test.ts +116 -0
  67. package/host/server.ts +223 -0
  68. package/host/session-ctx.ts +107 -0
  69. package/host/session-fixture-replay.test.ts +136 -0
  70. package/host/session-prompt.test.ts +77 -0
  71. package/host/session.test.ts +590 -0
  72. package/host/session.ts +370 -0
  73. package/host/tool-executor.test.ts +124 -0
  74. package/host/tool-executor.ts +80 -0
  75. package/host/unstorage-kv.test.ts +99 -0
  76. package/host/unstorage-kv.ts +69 -0
  77. package/host/ws-handler.test.ts +739 -0
  78. package/host/ws-handler.ts +255 -0
  79. package/index.ts +16 -0
  80. package/package.json +24 -72
  81. package/sdk/_internal-types.test.ts +34 -0
  82. package/sdk/_internal-types.ts +115 -0
  83. package/sdk/compat-fixtures/README.md +26 -0
  84. package/sdk/compat-fixtures/v1.json +68 -0
  85. package/sdk/constants.ts +77 -0
  86. package/sdk/define.test.ts +57 -0
  87. package/sdk/define.ts +88 -0
  88. package/sdk/kv.ts +60 -0
  89. package/sdk/manifest-barrel.ts +12 -0
  90. package/sdk/manifest.test.ts +56 -0
  91. package/sdk/manifest.ts +89 -0
  92. package/sdk/protocol-compat.test.ts +187 -0
  93. package/sdk/protocol-snapshot.test.ts +199 -0
  94. package/sdk/protocol.test.ts +170 -0
  95. package/sdk/protocol.ts +223 -0
  96. package/sdk/schema-alignment.test.ts +191 -0
  97. package/sdk/system-prompt.test.ts +111 -0
  98. package/sdk/system-prompt.ts +74 -0
  99. package/sdk/tsconfig.json +12 -0
  100. package/sdk/types-inference.test.ts +122 -0
  101. package/sdk/types.test.ts +14 -0
  102. package/sdk/types.ts +226 -0
  103. package/sdk/utils.test.ts +52 -0
  104. package/sdk/utils.ts +20 -0
  105. package/sdk/ws-upgrade.test.ts +48 -0
  106. package/sdk/ws-upgrade.ts +13 -0
  107. package/tsconfig.build.json +14 -0
  108. package/tsconfig.json +10 -0
  109. package/tsdown.config.ts +26 -0
  110. package/vitest.config.ts +17 -0
  111. package/dist/host/_test-utils.d.ts +0 -73
  112. package/dist/host/direct-executor.d.ts +0 -130
  113. package/dist/host/index.d.ts +0 -19
  114. package/dist/host/index.js +0 -165
  115. package/dist/host/matchers.d.ts +0 -20
  116. package/dist/host/matchers.js +0 -41
  117. package/dist/host/server.js +0 -164
  118. package/dist/host/testing.d.ts +0 -294
  119. package/dist/host/testing.js +0 -2
  120. package/dist/host/vite-plugin.d.ts +0 -15
  121. package/dist/host/vite-plugin.js +0 -83
  122. package/dist/isolate/_kv-utils.d.ts +0 -10
  123. package/dist/isolate/_utils.js +0 -17
  124. package/dist/isolate/hooks.d.ts +0 -44
  125. package/dist/isolate/hooks.js +0 -58
  126. package/dist/isolate/index.d.ts +0 -18
  127. package/dist/isolate/index.js +0 -6
  128. package/dist/isolate/kv.js +0 -1
  129. package/dist/isolate/protocol.js +0 -2
  130. package/dist/isolate/types.d.ts +0 -418
  131. package/dist/isolate/types.js +0 -175
  132. package/dist/protocol-rcOrz7T3.js +0 -183
  133. package/dist/testing-BreLdpq-.js +0 -513
  134. package/dist/types.test-d.d.ts +0 -7
  135. /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
@@ -0,0 +1,739 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import type { ClientSink } from "../sdk/protocol.ts";
3
+ import { MockWebSocket } from "./_mock-ws.ts";
4
+ import { makeStubSession, silentLogger } from "./_test-utils.ts";
5
+ import type { Session } from "./session.ts";
6
+ import { wireSessionSocket } from "./ws-handler.ts";
7
+
8
+ function makeLogger() {
9
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
10
+ }
11
+
12
+ /** Wait until wireSessionSocket has fully initialized (sessionReady = true). */
13
+ async function waitForSessionReady(logger: { info: ReturnType<typeof vi.fn> }): Promise<void> {
14
+ await vi.waitFor(() => {
15
+ const calls = logger.info.mock.calls.map((c: unknown[]) => c[0]);
16
+ if (!calls.includes("Session ready")) throw new Error("Session not ready yet");
17
+ });
18
+ }
19
+
20
+ const defaultConfig = { audioFormat: "pcm16" as const, sampleRate: 16_000, ttsSampleRate: 24_000 };
21
+
22
+ describe("wireSessionSocket", () => {
23
+ test("'Session ready' is not logged until session.start() resolves", async () => {
24
+ const logs: string[] = [];
25
+ const logger = {
26
+ info: (msg: string) => logs.push(msg),
27
+ warn: (msg: string) => logs.push(msg),
28
+ error: (msg: string) => logs.push(msg),
29
+ debug: (msg: string) => logs.push(msg),
30
+ };
31
+
32
+ let resolveStart!: () => void;
33
+ const session = makeStubSession();
34
+ session.start = vi.fn(
35
+ () =>
36
+ new Promise<void>((r) => {
37
+ resolveStart = r;
38
+ }),
39
+ );
40
+
41
+ const ws = new MockWebSocket("ws://test");
42
+ ws.readyState = MockWebSocket.OPEN;
43
+
44
+ wireSessionSocket(ws, {
45
+ sessions: new Map(),
46
+ createSession: () => session,
47
+ readyConfig: defaultConfig,
48
+ logger,
49
+ });
50
+
51
+ expect(session.start).toHaveBeenCalled();
52
+ expect(logs).toContain("Session connected");
53
+ expect(logs).not.toContain("Session ready");
54
+
55
+ resolveStart();
56
+ await vi.waitFor(() => {
57
+ expect(logs).toContain("Session ready");
58
+ });
59
+ });
60
+
61
+ test("logs 'Session start failed' when start() rejects", async () => {
62
+ const logs: { msg: string; meta: Record<string, unknown> | undefined }[] = [];
63
+ const logger = {
64
+ info: (msg: string, meta?: Record<string, unknown>) => logs.push({ msg, meta }),
65
+ warn: (msg: string, meta?: Record<string, unknown>) => logs.push({ msg, meta }),
66
+ error: (msg: string, meta?: Record<string, unknown>) => logs.push({ msg, meta }),
67
+ debug: (msg: string, meta?: Record<string, unknown>) => logs.push({ msg, meta }),
68
+ };
69
+
70
+ const session = makeStubSession();
71
+ session.start = vi.fn(() => Promise.reject(new Error("boom")));
72
+
73
+ const ws = new MockWebSocket("ws://test");
74
+ ws.readyState = MockWebSocket.OPEN;
75
+
76
+ wireSessionSocket(ws, {
77
+ sessions: new Map(),
78
+ createSession: () => session,
79
+ readyConfig: defaultConfig,
80
+ logger,
81
+ });
82
+
83
+ await vi.waitFor(() => {
84
+ expect(logs).toContainEqual(expect.objectContaining({ msg: "Session start failed" }));
85
+ });
86
+ expect(logs.map((l) => l.msg)).not.toContain("Session ready");
87
+ });
88
+
89
+ test("session is added to sessions map on open", () => {
90
+ const sessions = new Map<string, Session>();
91
+ const session = makeStubSession();
92
+
93
+ const ws = new MockWebSocket("ws://test");
94
+ ws.readyState = MockWebSocket.OPEN;
95
+
96
+ wireSessionSocket(ws, {
97
+ sessions,
98
+ createSession: () => session,
99
+ readyConfig: defaultConfig,
100
+ });
101
+
102
+ expect(sessions.size).toBe(1);
103
+ expect([...sessions.values()][0]).toBe(session);
104
+ });
105
+
106
+ test("session is removed from sessions map on close", async () => {
107
+ const sessions = new Map<string, Session>();
108
+ const session = makeStubSession();
109
+
110
+ const ws = new MockWebSocket("ws://test");
111
+ ws.readyState = MockWebSocket.OPEN;
112
+
113
+ wireSessionSocket(ws, {
114
+ sessions,
115
+ createSession: () => session,
116
+ readyConfig: defaultConfig,
117
+ });
118
+
119
+ expect(sessions.size).toBe(1);
120
+ ws.close();
121
+
122
+ await vi.waitFor(() => {
123
+ expect(sessions.size).toBe(0);
124
+ });
125
+ });
126
+
127
+ test("sends config as first message on open", () => {
128
+ const ws = new MockWebSocket("ws://test");
129
+ ws.readyState = MockWebSocket.OPEN;
130
+
131
+ wireSessionSocket(ws, {
132
+ sessions: new Map(),
133
+ createSession: () => makeStubSession(),
134
+ readyConfig: defaultConfig,
135
+ });
136
+
137
+ const sent = ws.sentJson();
138
+ expect(sent[0]).toMatchObject({ type: "config", ...defaultConfig });
139
+ });
140
+
141
+ // ─── Binary audio handling ──────────────────────────────────────────────
142
+
143
+ test("Uint8Array binary data is forwarded to session.onAudio", async () => {
144
+ const session = makeStubSession();
145
+ const ws = new MockWebSocket("ws://test");
146
+ ws.readyState = MockWebSocket.OPEN;
147
+ const logger = makeLogger();
148
+
149
+ wireSessionSocket(ws, {
150
+ sessions: new Map(),
151
+ createSession: () => session,
152
+ readyConfig: defaultConfig,
153
+ logger,
154
+ });
155
+
156
+ await waitForSessionReady(logger);
157
+
158
+ const audio = new Uint8Array([1, 2, 3, 4]);
159
+ ws.simulateMessage(audio.buffer);
160
+
161
+ expect(session.onAudio).toHaveBeenCalledOnce();
162
+ const passed = (session.onAudio as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
163
+ expect(passed).toBeInstanceOf(Uint8Array);
164
+ });
165
+
166
+ test("ArrayBuffer data is forwarded to session.onAudio", async () => {
167
+ const session = makeStubSession();
168
+ const ws = new MockWebSocket("ws://test");
169
+ ws.readyState = MockWebSocket.OPEN;
170
+ const logger = makeLogger();
171
+
172
+ wireSessionSocket(ws, {
173
+ sessions: new Map(),
174
+ createSession: () => session,
175
+ readyConfig: defaultConfig,
176
+ logger,
177
+ });
178
+
179
+ await waitForSessionReady(logger);
180
+
181
+ const buf = new ArrayBuffer(4);
182
+ ws.simulateMessage(buf);
183
+
184
+ expect(session.onAudio).toHaveBeenCalledOnce();
185
+ });
186
+
187
+ // ─── Text message handling ──────────────────────────────────────────────
188
+
189
+ test("audio_ready message calls session.onAudioReady", async () => {
190
+ const session = makeStubSession();
191
+ const ws = new MockWebSocket("ws://test");
192
+ ws.readyState = MockWebSocket.OPEN;
193
+ const logger = makeLogger();
194
+
195
+ wireSessionSocket(ws, {
196
+ sessions: new Map(),
197
+ createSession: () => session,
198
+ readyConfig: defaultConfig,
199
+ logger,
200
+ });
201
+
202
+ await waitForSessionReady(logger);
203
+
204
+ ws.simulateMessage(JSON.stringify({ type: "audio_ready" }));
205
+ expect(session.onAudioReady).toHaveBeenCalledOnce();
206
+ });
207
+
208
+ test("cancel message calls session.onCancel", async () => {
209
+ const session = makeStubSession();
210
+ const ws = new MockWebSocket("ws://test");
211
+ ws.readyState = MockWebSocket.OPEN;
212
+ const logger = makeLogger();
213
+
214
+ wireSessionSocket(ws, {
215
+ sessions: new Map(),
216
+ createSession: () => session,
217
+ readyConfig: defaultConfig,
218
+ logger,
219
+ });
220
+
221
+ await waitForSessionReady(logger);
222
+
223
+ ws.simulateMessage(JSON.stringify({ type: "cancel" }));
224
+ expect(session.onCancel).toHaveBeenCalledOnce();
225
+ });
226
+
227
+ test("reset message calls session.onReset", async () => {
228
+ const session = makeStubSession();
229
+ const ws = new MockWebSocket("ws://test");
230
+ ws.readyState = MockWebSocket.OPEN;
231
+ const logger = makeLogger();
232
+
233
+ wireSessionSocket(ws, {
234
+ sessions: new Map(),
235
+ createSession: () => session,
236
+ readyConfig: defaultConfig,
237
+ logger,
238
+ });
239
+
240
+ await waitForSessionReady(logger);
241
+
242
+ ws.simulateMessage(JSON.stringify({ type: "reset" }));
243
+ expect(session.onReset).toHaveBeenCalledOnce();
244
+ });
245
+
246
+ test("history message calls session.onHistory", async () => {
247
+ const session = makeStubSession();
248
+ const ws = new MockWebSocket("ws://test");
249
+ ws.readyState = MockWebSocket.OPEN;
250
+ const logger = makeLogger();
251
+
252
+ wireSessionSocket(ws, {
253
+ sessions: new Map(),
254
+ createSession: () => session,
255
+ readyConfig: defaultConfig,
256
+ logger,
257
+ });
258
+
259
+ await waitForSessionReady(logger);
260
+
261
+ const messages = [
262
+ { role: "user" as const, content: "Hello" },
263
+ { role: "assistant" as const, content: "Hi" },
264
+ ];
265
+ ws.simulateMessage(JSON.stringify({ type: "history", messages }));
266
+ expect(session.onHistory).toHaveBeenCalledWith(messages);
267
+ });
268
+
269
+ test("invalid JSON is logged and ignored", async () => {
270
+ const session = makeStubSession();
271
+ const ws = new MockWebSocket("ws://test");
272
+ ws.readyState = MockWebSocket.OPEN;
273
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
274
+
275
+ wireSessionSocket(ws, {
276
+ sessions: new Map(),
277
+ createSession: () => session,
278
+ readyConfig: defaultConfig,
279
+ logger,
280
+ });
281
+
282
+ await waitForSessionReady(logger);
283
+
284
+ ws.simulateMessage("not-json{{{");
285
+ expect(logger.warn).toHaveBeenCalledWith("Invalid JSON from client", expect.any(Object));
286
+ });
287
+
288
+ test("invalid message schema is logged and ignored", async () => {
289
+ const session = makeStubSession();
290
+ const ws = new MockWebSocket("ws://test");
291
+ ws.readyState = MockWebSocket.OPEN;
292
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
293
+
294
+ wireSessionSocket(ws, {
295
+ sessions: new Map(),
296
+ createSession: () => session,
297
+ readyConfig: defaultConfig,
298
+ logger,
299
+ });
300
+
301
+ await waitForSessionReady(logger);
302
+
303
+ // Unknown but well-formed message types are silently ignored (two-phase
304
+ // parsing: additive protocol changes should not produce warnings).
305
+ ws.simulateMessage(JSON.stringify({ type: "unknown_type" }));
306
+ expect(logger.warn).not.toHaveBeenCalled();
307
+ });
308
+
309
+ // ─── ClientSink (indirect testing via createSession capture) ────────────
310
+
311
+ test("ClientSink.event sends JSON text via ws.send", () => {
312
+ let capturedClient!: ClientSink;
313
+ const ws = new MockWebSocket("ws://test");
314
+ ws.readyState = MockWebSocket.OPEN;
315
+
316
+ wireSessionSocket(ws, {
317
+ sessions: new Map(),
318
+ createSession: (_sid, client) => {
319
+ capturedClient = client;
320
+ return makeStubSession();
321
+ },
322
+ readyConfig: defaultConfig,
323
+ logger: silentLogger,
324
+ });
325
+
326
+ capturedClient.event({ type: "speech_started" });
327
+ const sentStrings = ws.sent.filter((d): d is string => typeof d === "string");
328
+ expect(sentStrings).toContainEqual(expect.stringContaining('"speech_started"'));
329
+ });
330
+
331
+ test("ClientSink.playAudioChunk sends binary data", () => {
332
+ let capturedClient!: ClientSink;
333
+ const ws = new MockWebSocket("ws://test");
334
+ ws.readyState = MockWebSocket.OPEN;
335
+
336
+ wireSessionSocket(ws, {
337
+ sessions: new Map(),
338
+ createSession: (_sid, client) => {
339
+ capturedClient = client;
340
+ return makeStubSession();
341
+ },
342
+ readyConfig: defaultConfig,
343
+ logger: silentLogger,
344
+ });
345
+
346
+ const chunk = new Uint8Array([10, 20, 30]);
347
+ capturedClient.playAudioChunk(chunk);
348
+ expect(ws.sent).toContain(chunk);
349
+ });
350
+
351
+ test("ClientSink.playAudioDone sends audio_done JSON", () => {
352
+ let capturedClient!: ClientSink;
353
+ const ws = new MockWebSocket("ws://test");
354
+ ws.readyState = MockWebSocket.OPEN;
355
+
356
+ wireSessionSocket(ws, {
357
+ sessions: new Map(),
358
+ createSession: (_sid, client) => {
359
+ capturedClient = client;
360
+ return makeStubSession();
361
+ },
362
+ readyConfig: defaultConfig,
363
+ logger: silentLogger,
364
+ });
365
+
366
+ capturedClient.playAudioDone();
367
+ const sentStrings = ws.sent.filter((d): d is string => typeof d === "string");
368
+ expect(sentStrings).toContainEqual(expect.stringContaining('"audio_done"'));
369
+ });
370
+
371
+ test("ClientSink.open reflects ws.readyState", () => {
372
+ let capturedClient!: ClientSink;
373
+ const ws = new MockWebSocket("ws://test");
374
+ ws.readyState = MockWebSocket.OPEN;
375
+
376
+ wireSessionSocket(ws, {
377
+ sessions: new Map(),
378
+ createSession: (_sid, client) => {
379
+ capturedClient = client;
380
+ return makeStubSession();
381
+ },
382
+ readyConfig: defaultConfig,
383
+ logger: silentLogger,
384
+ });
385
+
386
+ expect(capturedClient.open).toBe(true);
387
+ ws.readyState = MockWebSocket.CLOSED;
388
+ expect(capturedClient.open).toBe(false);
389
+ });
390
+
391
+ test("ClientSink tolerates ws.send throwing (closed socket)", () => {
392
+ let capturedClient!: ClientSink;
393
+ const ws = new MockWebSocket("ws://test");
394
+ ws.readyState = MockWebSocket.OPEN;
395
+
396
+ wireSessionSocket(ws, {
397
+ sessions: new Map(),
398
+ createSession: (_sid, client) => {
399
+ capturedClient = client;
400
+ return makeStubSession();
401
+ },
402
+ readyConfig: defaultConfig,
403
+ logger: silentLogger,
404
+ });
405
+
406
+ // Override send to throw
407
+ ws.send = () => {
408
+ throw new Error("socket closed");
409
+ };
410
+ // Should not throw
411
+ capturedClient.event({ type: "speech_started" });
412
+ capturedClient.playAudioChunk(new Uint8Array([1]));
413
+ capturedClient.playAudioDone();
414
+ });
415
+
416
+ // ─── Close handler ──────────────────────────────────────────────────────
417
+
418
+ test("close handler calls session.stop", async () => {
419
+ const session = makeStubSession();
420
+ const ws = new MockWebSocket("ws://test");
421
+ ws.readyState = MockWebSocket.OPEN;
422
+
423
+ wireSessionSocket(ws, {
424
+ sessions: new Map(),
425
+ createSession: () => session,
426
+ readyConfig: defaultConfig,
427
+ logger: silentLogger,
428
+ });
429
+
430
+ ws.close();
431
+
432
+ await vi.waitFor(() => {
433
+ expect(session.stop).toHaveBeenCalledOnce();
434
+ });
435
+ });
436
+
437
+ // ─── Error handler ──────────────────────────────────────────────────────
438
+
439
+ test("error event is logged", () => {
440
+ const ws = new MockWebSocket("ws://test");
441
+ ws.readyState = MockWebSocket.OPEN;
442
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
443
+
444
+ wireSessionSocket(ws, {
445
+ sessions: new Map(),
446
+ createSession: () => makeStubSession(),
447
+ readyConfig: defaultConfig,
448
+ logger,
449
+ });
450
+
451
+ const errEvent = new Event("error");
452
+ Object.defineProperty(errEvent, "message", { value: "test error" });
453
+ ws.dispatchEvent(errEvent);
454
+
455
+ expect(logger.error).toHaveBeenCalledWith(
456
+ "WebSocket error",
457
+ expect.objectContaining({ error: "test error" }),
458
+ );
459
+ });
460
+
461
+ test("generic error event logs default message", () => {
462
+ const ws = new MockWebSocket("ws://test");
463
+ ws.readyState = MockWebSocket.OPEN;
464
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
465
+
466
+ wireSessionSocket(ws, {
467
+ sessions: new Map(),
468
+ createSession: () => makeStubSession(),
469
+ readyConfig: defaultConfig,
470
+ logger,
471
+ });
472
+
473
+ ws.dispatchEvent(new Event("error"));
474
+
475
+ expect(logger.error).toHaveBeenCalledWith(
476
+ "WebSocket error",
477
+ expect.objectContaining({ error: "WebSocket error" }),
478
+ );
479
+ });
480
+
481
+ // ─── Callbacks ──────────────────────────────────────────────────────────
482
+
483
+ test("onOpen callback is invoked when socket opens", () => {
484
+ const onOpen = vi.fn();
485
+ const ws = new MockWebSocket("ws://test");
486
+ ws.readyState = MockWebSocket.OPEN;
487
+
488
+ wireSessionSocket(ws, {
489
+ sessions: new Map(),
490
+ createSession: () => makeStubSession(),
491
+ readyConfig: defaultConfig,
492
+ onOpen,
493
+ logger: silentLogger,
494
+ });
495
+
496
+ expect(onOpen).toHaveBeenCalledOnce();
497
+ });
498
+
499
+ test("onClose callback is invoked when socket closes", () => {
500
+ const onClose = vi.fn();
501
+ const ws = new MockWebSocket("ws://test");
502
+ ws.readyState = MockWebSocket.OPEN;
503
+
504
+ wireSessionSocket(ws, {
505
+ sessions: new Map(),
506
+ createSession: () => makeStubSession(),
507
+ readyConfig: defaultConfig,
508
+ onClose,
509
+ logger: silentLogger,
510
+ });
511
+
512
+ ws.close();
513
+ expect(onClose).toHaveBeenCalledOnce();
514
+ });
515
+
516
+ test("onSessionEnd is called with sessionId after session cleanup", async () => {
517
+ const onSessionEnd = vi.fn();
518
+ const ws = new MockWebSocket("ws://test");
519
+ ws.readyState = MockWebSocket.OPEN;
520
+ const sessions = new Map<string, Session>();
521
+
522
+ wireSessionSocket(ws, {
523
+ sessions,
524
+ createSession: () => makeStubSession(),
525
+ readyConfig: defaultConfig,
526
+ onSessionEnd,
527
+ logger: silentLogger,
528
+ });
529
+
530
+ // Session is now in the map
531
+ expect(sessions.size).toBe(1);
532
+ const sessionId = [...sessions.keys()][0] ?? "";
533
+
534
+ ws.close();
535
+
536
+ await vi.waitFor(() => {
537
+ expect(onSessionEnd).toHaveBeenCalledOnce();
538
+ });
539
+ expect(onSessionEnd).toHaveBeenCalledWith(sessionId);
540
+ expect(sessions.size).toBe(0);
541
+ });
542
+
543
+ // ─── Concurrency regression tests ─────────────────────────────────────
544
+
545
+ test("close during start() does not double-stop or throw", async () => {
546
+ let resolveStart!: () => void;
547
+ const session = makeStubSession();
548
+ session.start = vi.fn(
549
+ () =>
550
+ new Promise<void>((r) => {
551
+ resolveStart = r;
552
+ }),
553
+ );
554
+ const ws = new MockWebSocket("ws://test");
555
+ ws.readyState = MockWebSocket.OPEN;
556
+ const sessions = new Map<string, Session>();
557
+
558
+ wireSessionSocket(ws, {
559
+ sessions,
560
+ createSession: () => session,
561
+ readyConfig: defaultConfig,
562
+ logger: silentLogger,
563
+ });
564
+
565
+ // Close while start() is pending
566
+ ws.close();
567
+
568
+ // Now start() resolves
569
+ resolveStart();
570
+ await vi.waitFor(() => {
571
+ expect(session.stop).toHaveBeenCalledOnce();
572
+ });
573
+ });
574
+
575
+ test("start() failure removes session from map before close", async () => {
576
+ const session = makeStubSession();
577
+ session.start = vi.fn(() => Promise.reject(new Error("boom")));
578
+ const ws = new MockWebSocket("ws://test");
579
+ ws.readyState = MockWebSocket.OPEN;
580
+ const sessions = new Map<string, Session>();
581
+
582
+ wireSessionSocket(ws, {
583
+ sessions,
584
+ createSession: () => session,
585
+ readyConfig: defaultConfig,
586
+ logger: silentLogger,
587
+ });
588
+
589
+ // Wait for start() rejection to propagate
590
+ await vi.waitFor(() => {
591
+ expect(sessions.size).toBe(0);
592
+ });
593
+
594
+ // Close should not throw — session is null
595
+ ws.close();
596
+ });
597
+
598
+ // ─── Session start timeout ─────────────────────────────────────────────
599
+
600
+ test("session.start() timeout triggers 'Session start failed'", async () => {
601
+ const session = makeStubSession();
602
+ // start() never resolves — simulates a hanging S2S connection
603
+ session.start = vi.fn(
604
+ () =>
605
+ new Promise<void>(() => {
606
+ /* intentionally never resolves */
607
+ }),
608
+ );
609
+
610
+ const ws = new MockWebSocket("ws://test");
611
+ ws.readyState = MockWebSocket.OPEN;
612
+ const sessions = new Map<string, Session>();
613
+
614
+ wireSessionSocket(ws, {
615
+ sessions,
616
+ createSession: () => session,
617
+ readyConfig: defaultConfig,
618
+ logger: silentLogger,
619
+ sessionStartTimeoutMs: 50,
620
+ });
621
+
622
+ expect(sessions.size).toBe(1);
623
+
624
+ await vi.waitFor(
625
+ () => {
626
+ expect(sessions.size).toBe(0);
627
+ },
628
+ { timeout: 500 },
629
+ );
630
+
631
+ expect(silentLogger.error).toHaveBeenCalledWith(
632
+ "Session start failed",
633
+ expect.objectContaining({ error: expect.stringContaining("timed out") }),
634
+ );
635
+ });
636
+
637
+ // ─── Socket not yet open ───────────────────────────────────────────────
638
+
639
+ test("waits for open event when readyState is not OPEN", async () => {
640
+ const session = makeStubSession();
641
+ const ws = new MockWebSocket("ws://test");
642
+ ws.readyState = MockWebSocket.CONNECTING;
643
+
644
+ wireSessionSocket(ws, {
645
+ sessions: new Map(),
646
+ createSession: () => session,
647
+ readyConfig: defaultConfig,
648
+ logger: silentLogger,
649
+ });
650
+
651
+ // Session not started yet — waiting for open
652
+ expect(session.start).not.toHaveBeenCalled();
653
+
654
+ // Simulate open
655
+ ws.readyState = MockWebSocket.OPEN;
656
+ ws.dispatchEvent(new Event("open"));
657
+
658
+ expect(session.start).toHaveBeenCalledOnce();
659
+ });
660
+
661
+ // ─── No session ignores messages ───────────────────────────────────────
662
+
663
+ test("messages before session is created are ignored", () => {
664
+ const ws = new MockWebSocket("ws://test");
665
+ ws.readyState = MockWebSocket.CONNECTING;
666
+
667
+ wireSessionSocket(ws, {
668
+ sessions: new Map(),
669
+ createSession: () => makeStubSession(),
670
+ readyConfig: defaultConfig,
671
+ logger: silentLogger,
672
+ });
673
+
674
+ // Send message before open — session is null, should be ignored
675
+ ws.simulateMessage(JSON.stringify({ type: "audio_ready" }));
676
+ // No error thrown
677
+ });
678
+
679
+ // ─── Session resume ────────────────────────────────────────────────────
680
+
681
+ test("resumeFrom reuses old session ID instead of generating new UUID", () => {
682
+ const sessions = new Map<string, Session>();
683
+ const ws = new MockWebSocket("ws://test");
684
+ ws.readyState = MockWebSocket.OPEN;
685
+ let capturedId: string | undefined;
686
+
687
+ wireSessionSocket(ws, {
688
+ sessions,
689
+ createSession: (sid) => {
690
+ capturedId = sid;
691
+ return makeStubSession();
692
+ },
693
+ readyConfig: defaultConfig,
694
+ logger: silentLogger,
695
+ resumeFrom: "old-session-abc",
696
+ });
697
+
698
+ expect(capturedId).toBe("old-session-abc");
699
+ expect(sessions.has("old-session-abc")).toBeTruthy();
700
+ });
701
+
702
+ test("config message includes resumed session ID", () => {
703
+ const ws = new MockWebSocket("ws://test");
704
+ ws.readyState = MockWebSocket.OPEN;
705
+
706
+ wireSessionSocket(ws, {
707
+ sessions: new Map(),
708
+ createSession: () => makeStubSession(),
709
+ readyConfig: defaultConfig,
710
+ logger: silentLogger,
711
+ resumeFrom: "resume-id-123",
712
+ });
713
+
714
+ const config = ws.sentJson()[0];
715
+ expect(config).toMatchObject({ type: "config", sessionId: "resume-id-123" });
716
+ });
717
+
718
+ test("without resumeFrom, generates a new UUID session ID", () => {
719
+ const sessions = new Map<string, Session>();
720
+ const ws = new MockWebSocket("ws://test");
721
+ ws.readyState = MockWebSocket.OPEN;
722
+ let capturedId: string | undefined;
723
+
724
+ wireSessionSocket(ws, {
725
+ sessions,
726
+ createSession: (sid) => {
727
+ capturedId = sid;
728
+ return makeStubSession();
729
+ },
730
+ readyConfig: defaultConfig,
731
+ logger: silentLogger,
732
+ });
733
+
734
+ expect(capturedId).toBeDefined();
735
+ expect(capturedId).not.toBe("");
736
+ // UUID format: 8-4-4-4-12
737
+ expect(capturedId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
738
+ });
739
+ });