@alexkroman1/aai 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +13 -0
  3. package/dist/assemblyai-C969QGi4.js +35 -0
  4. package/dist/cartesia-BfQPOQ7Y.js +37 -0
  5. package/dist/host/_pipeline-test-fakes.d.ts +3 -1
  6. package/dist/host/providers/stt/deepgram.d.ts +28 -0
  7. package/dist/host/providers/tts/cartesia.d.ts +1 -1
  8. package/dist/host/providers/tts/rime.d.ts +44 -0
  9. package/dist/host/runtime-barrel.d.ts +4 -2
  10. package/dist/host/runtime-barrel.js +1432 -1208
  11. package/dist/host/runtime.d.ts +2 -2
  12. package/dist/host/s2s.d.ts +16 -16
  13. package/dist/host/session-core.d.ts +37 -0
  14. package/dist/host/transports/pipeline-transport.d.ts +48 -0
  15. package/dist/host/transports/s2s-transport.d.ts +19 -0
  16. package/dist/host/transports/types.d.ts +45 -0
  17. package/dist/host/ws-handler.d.ts +14 -10
  18. package/dist/sdk/protocol.d.ts +6 -5
  19. package/dist/sdk/providers/llm-barrel.js +1 -1
  20. package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
  21. package/dist/sdk/providers/stt-barrel.d.ts +1 -0
  22. package/dist/sdk/providers/stt-barrel.js +2 -2
  23. package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
  24. package/dist/sdk/providers/tts/rime.d.ts +42 -0
  25. package/dist/sdk/providers/tts-barrel.d.ts +1 -0
  26. package/dist/sdk/providers/tts-barrel.js +2 -2
  27. package/host/_pipeline-test-fakes.ts +6 -3
  28. package/host/_test-utils.ts +209 -128
  29. package/host/cleanup.test.ts +25 -298
  30. package/host/integration/pipeline-reference.integration.test.ts +30 -35
  31. package/host/providers/resolve.ts +10 -2
  32. package/host/providers/stt/deepgram.test.ts +229 -0
  33. package/host/providers/stt/deepgram.ts +172 -0
  34. package/host/providers/tts/cartesia.ts +7 -3
  35. package/host/providers/tts/rime.test.ts +251 -0
  36. package/host/providers/tts/rime.ts +322 -0
  37. package/host/runtime-barrel.ts +4 -2
  38. package/host/runtime.test.ts +13 -46
  39. package/host/runtime.ts +131 -23
  40. package/host/s2s.test.ts +122 -131
  41. package/host/s2s.ts +44 -52
  42. package/host/session-core.test.ts +257 -0
  43. package/host/session-core.ts +262 -0
  44. package/host/transports/pipeline-transport.test.ts +651 -0
  45. package/host/transports/pipeline-transport.ts +532 -0
  46. package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
  47. package/host/transports/s2s-transport.test.ts +56 -0
  48. package/host/transports/s2s-transport.ts +116 -0
  49. package/host/transports/types.test.ts +22 -0
  50. package/host/transports/types.ts +51 -0
  51. package/host/ws-handler.test.ts +324 -242
  52. package/host/ws-handler.ts +56 -59
  53. package/package.json +2 -1
  54. package/sdk/__snapshots__/exports.test.ts.snap +3 -3
  55. package/sdk/protocol-compat.test.ts +8 -0
  56. package/sdk/protocol.ts +6 -5
  57. package/sdk/providers/stt/deepgram.ts +43 -0
  58. package/sdk/providers/stt-barrel.ts +2 -0
  59. package/sdk/providers/tts/cartesia.ts +15 -5
  60. package/sdk/providers/tts/rime.ts +52 -0
  61. package/sdk/providers/tts-barrel.ts +2 -0
  62. package/dist/assemblyai-Cxg9eobY.js +0 -18
  63. package/dist/cartesia-DwDk2tEu.js +0 -10
  64. package/dist/host/pipeline-session-ctx.d.ts +0 -24
  65. package/dist/host/pipeline-session.d.ts +0 -52
  66. package/dist/host/session-ctx.d.ts +0 -73
  67. package/dist/host/session.d.ts +0 -62
  68. package/host/pipeline-session-ctx.test.ts +0 -31
  69. package/host/pipeline-session-ctx.ts +0 -36
  70. package/host/pipeline-session.test.ts +0 -672
  71. package/host/pipeline-session.ts +0 -533
  72. package/host/s2s-fixtures.test.ts +0 -237
  73. package/host/session-ctx.test.ts +0 -387
  74. package/host/session-ctx.ts +0 -134
  75. package/host/session-fixture-replay.test.ts +0 -128
  76. package/host/session.test.ts +0 -634
  77. package/host/session.ts +0 -412
  78. /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
@@ -1,12 +1,22 @@
1
1
  import { describe, expect, test, vi } from "vitest";
2
2
  import type { ClientSink } from "../sdk/protocol.ts";
3
3
  import { MockWebSocket } from "./_mock-ws.ts";
4
- import { makeStubSession, silentLogger } from "./_test-utils.ts";
5
- import type { Session } from "./session.ts";
4
+ import { makeLogger, makeMockCore, silentLogger } from "./_test-utils.ts";
5
+ import type { SessionCore } from "./session-core.ts";
6
6
  import { wireSessionSocket } from "./ws-handler.ts";
7
7
 
8
- function makeLogger() {
9
- return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
8
+ // ─── Test helpers ────────────────────────────────────────────────────────────
9
+
10
+ const defaultConfig = { audioFormat: "pcm16" as const, sampleRate: 16_000, ttsSampleRate: 24_000 };
11
+
12
+ /** Simulate a binary frame arriving on the WebSocket. */
13
+ function simulateBinaryFrame(ws: MockWebSocket, frame: Uint8Array): void {
14
+ ws.dispatchEvent(new MessageEvent("message", { data: frame }));
15
+ }
16
+
17
+ /** Simulate a string (text) frame arriving on the WebSocket. */
18
+ function simulateTextFrame(ws: MockWebSocket, text: string): void {
19
+ ws.dispatchEvent(new MessageEvent("message", { data: text }));
10
20
  }
11
21
 
12
22
  /** Wait until wireSessionSocket has fully initialized (sessionReady = true). */
@@ -17,9 +27,11 @@ async function waitForSessionReady(logger: { info: ReturnType<typeof vi.fn> }):
17
27
  });
18
28
  }
19
29
 
20
- const defaultConfig = { audioFormat: "pcm16" as const, sampleRate: 16_000, ttsSampleRate: 24_000 };
30
+ // ─── Tests ───────────────────────────────────────────────────────────────────
21
31
 
22
32
  describe("wireSessionSocket", () => {
33
+ // ─── Lifecycle: startup ──────────────────────────────────────────────────
34
+
23
35
  test("'Session ready' is not logged until session.start() resolves", async () => {
24
36
  const logs: string[] = [];
25
37
  const logger = {
@@ -30,25 +42,26 @@ describe("wireSessionSocket", () => {
30
42
  };
31
43
 
32
44
  let resolveStart!: () => void;
33
- const session = makeStubSession();
34
- session.start = vi.fn(
35
- () =>
36
- new Promise<void>((r) => {
37
- resolveStart = r;
38
- }),
39
- );
45
+ const core = makeMockCore({
46
+ start: vi.fn(
47
+ () =>
48
+ new Promise<void>((r) => {
49
+ resolveStart = r;
50
+ }),
51
+ ),
52
+ });
40
53
 
41
54
  const ws = new MockWebSocket("ws://test");
42
55
  ws.readyState = MockWebSocket.OPEN;
43
56
 
44
57
  wireSessionSocket(ws, {
45
58
  sessions: new Map(),
46
- createSession: () => session,
59
+ createSession: () => core,
47
60
  readyConfig: defaultConfig,
48
61
  logger,
49
62
  });
50
63
 
51
- expect(session.start).toHaveBeenCalled();
64
+ expect(core.start).toHaveBeenCalled();
52
65
  expect(logs).toContain("Session connected");
53
66
  expect(logs).not.toContain("Session ready");
54
67
 
@@ -67,15 +80,14 @@ describe("wireSessionSocket", () => {
67
80
  debug: (msg: string, meta?: Record<string, unknown>) => logs.push({ msg, meta }),
68
81
  };
69
82
 
70
- const session = makeStubSession();
71
- session.start = vi.fn(() => Promise.reject(new Error("boom")));
83
+ const core = makeMockCore({ start: vi.fn(() => Promise.reject(new Error("boom"))) });
72
84
 
73
85
  const ws = new MockWebSocket("ws://test");
74
86
  ws.readyState = MockWebSocket.OPEN;
75
87
 
76
88
  wireSessionSocket(ws, {
77
89
  sessions: new Map(),
78
- createSession: () => session,
90
+ createSession: () => core,
79
91
  readyConfig: defaultConfig,
80
92
  logger,
81
93
  });
@@ -87,32 +99,31 @@ describe("wireSessionSocket", () => {
87
99
  });
88
100
 
89
101
  test("session is added to sessions map on open", () => {
90
- const sessions = new Map<string, Session>();
91
- const session = makeStubSession();
102
+ const sessions = new Map<string, SessionCore>();
103
+ const core = makeMockCore();
92
104
 
93
105
  const ws = new MockWebSocket("ws://test");
94
106
  ws.readyState = MockWebSocket.OPEN;
95
107
 
96
108
  wireSessionSocket(ws, {
97
109
  sessions,
98
- createSession: () => session,
110
+ createSession: () => core,
99
111
  readyConfig: defaultConfig,
100
112
  });
101
113
 
102
114
  expect(sessions.size).toBe(1);
103
- expect([...sessions.values()][0]).toBe(session);
115
+ expect([...sessions.values()][0]).toBe(core);
104
116
  });
105
117
 
106
118
  test("session is removed from sessions map on close", async () => {
107
- const sessions = new Map<string, Session>();
108
- const session = makeStubSession();
119
+ const sessions = new Map<string, SessionCore>();
109
120
 
110
121
  const ws = new MockWebSocket("ws://test");
111
122
  ws.readyState = MockWebSocket.OPEN;
112
123
 
113
124
  wireSessionSocket(ws, {
114
125
  sessions,
115
- createSession: () => session,
126
+ createSession: () => makeMockCore(),
116
127
  readyConfig: defaultConfig,
117
128
  });
118
129
 
@@ -124,134 +135,156 @@ describe("wireSessionSocket", () => {
124
135
  });
125
136
  });
126
137
 
127
- test("sends config as first message on open", () => {
138
+ // ─── CONFIG frame on open ────────────────────────────────────────────────
139
+
140
+ test("sends CONFIG JSON frame as first message on open", () => {
128
141
  const ws = new MockWebSocket("ws://test");
129
142
  ws.readyState = MockWebSocket.OPEN;
130
143
 
131
144
  wireSessionSocket(ws, {
132
145
  sessions: new Map(),
133
- createSession: () => makeStubSession(),
146
+ createSession: () => makeMockCore(),
134
147
  readyConfig: defaultConfig,
148
+ logger: silentLogger,
135
149
  });
136
150
 
137
- const sent = ws.sentJson();
138
- expect(sent[0]).toMatchObject({ type: "config", ...defaultConfig });
151
+ expect(ws.sent.length).toBeGreaterThanOrEqual(1);
152
+ const firstFrame = ws.sent[0];
153
+ expect(typeof firstFrame).toBe("string");
154
+ const msg = JSON.parse(firstFrame as string);
155
+ expect(msg.type).toBe("config");
139
156
  });
140
157
 
141
- // ─── Binary audio handling ──────────────────────────────────────────────
142
-
143
- test("Uint8Array binary data is forwarded to session.onAudio", async () => {
144
- const session = makeStubSession();
158
+ test("CONFIG frame contains correct sampleRate and ttsSampleRate", () => {
145
159
  const ws = new MockWebSocket("ws://test");
146
160
  ws.readyState = MockWebSocket.OPEN;
147
- const logger = makeLogger();
148
161
 
149
162
  wireSessionSocket(ws, {
150
163
  sessions: new Map(),
151
- createSession: () => session,
164
+ createSession: () => makeMockCore(),
152
165
  readyConfig: defaultConfig,
153
- logger,
166
+ logger: silentLogger,
154
167
  });
155
168
 
156
- await waitForSessionReady(logger);
169
+ const firstFrame = ws.sent[0];
170
+ const msg = JSON.parse(firstFrame as string);
171
+ expect(msg.type).toBe("config");
172
+ expect(msg.audioFormat).toBe("pcm16");
173
+ expect(msg.sampleRate).toBe(16_000);
174
+ expect(msg.ttsSampleRate).toBe(24_000);
175
+ });
157
176
 
158
- const audio = new Uint8Array([1, 2, 3, 4]);
159
- ws.simulateMessage(audio.buffer);
177
+ test("CONFIG frame includes the session ID as sessionId", () => {
178
+ const ws = new MockWebSocket("ws://test");
179
+ ws.readyState = MockWebSocket.OPEN;
180
+ const sessions = new Map<string, SessionCore>();
181
+ let capturedId: string | undefined;
160
182
 
161
- expect(session.onAudio).toHaveBeenCalledOnce();
162
- const passed = (session.onAudio as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
163
- expect(passed).toBeInstanceOf(Uint8Array);
183
+ wireSessionSocket(ws, {
184
+ sessions,
185
+ createSession: (sid) => {
186
+ capturedId = sid;
187
+ return makeMockCore();
188
+ },
189
+ readyConfig: defaultConfig,
190
+ logger: silentLogger,
191
+ });
192
+
193
+ const firstFrame = ws.sent[0];
194
+ const msg = JSON.parse(firstFrame as string);
195
+ expect(msg.type).toBe("config");
196
+ expect(msg.sessionId).toBeTruthy();
197
+ expect(msg.sessionId).toBe(capturedId);
164
198
  });
165
199
 
166
- test("ArrayBuffer data is forwarded to session.onAudio", async () => {
167
- const session = makeStubSession();
200
+ // ─── Inbound C2S frame routing ───────────────────────────────────────────
201
+
202
+ test("raw binary Uint8Array routes to session.onAudio", async () => {
203
+ const core = makeMockCore();
168
204
  const ws = new MockWebSocket("ws://test");
169
205
  ws.readyState = MockWebSocket.OPEN;
170
206
  const logger = makeLogger();
171
207
 
172
208
  wireSessionSocket(ws, {
173
209
  sessions: new Map(),
174
- createSession: () => session,
210
+ createSession: () => core,
175
211
  readyConfig: defaultConfig,
176
212
  logger,
177
213
  });
178
214
 
179
215
  await waitForSessionReady(logger);
180
216
 
181
- const buf = new ArrayBuffer(4);
182
- ws.simulateMessage(buf);
217
+ const pcm = new Uint8Array([1, 2, 3, 4]);
218
+ simulateBinaryFrame(ws, pcm);
183
219
 
184
- expect(session.onAudio).toHaveBeenCalledOnce();
220
+ expect(core.onAudio).toHaveBeenCalledOnce();
221
+ const passed = (core.onAudio as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
222
+ expect(passed).toBeInstanceOf(Uint8Array);
185
223
  });
186
224
 
187
- // ─── Text message handling ──────────────────────────────────────────────
188
-
189
- test("audio_ready message calls session.onAudioReady", async () => {
190
- const session = makeStubSession();
225
+ test("audio_ready JSON text frame routes to session.onAudioReady", async () => {
226
+ const core = makeMockCore();
191
227
  const ws = new MockWebSocket("ws://test");
192
228
  ws.readyState = MockWebSocket.OPEN;
193
229
  const logger = makeLogger();
194
230
 
195
231
  wireSessionSocket(ws, {
196
232
  sessions: new Map(),
197
- createSession: () => session,
233
+ createSession: () => core,
198
234
  readyConfig: defaultConfig,
199
235
  logger,
200
236
  });
201
237
 
202
238
  await waitForSessionReady(logger);
203
-
204
- ws.simulateMessage(JSON.stringify({ type: "audio_ready" }));
205
- expect(session.onAudioReady).toHaveBeenCalledOnce();
239
+ simulateTextFrame(ws, JSON.stringify({ type: "audio_ready" }));
240
+ expect(core.onAudioReady).toHaveBeenCalledOnce();
206
241
  });
207
242
 
208
- test("cancel message calls session.onCancel", async () => {
209
- const session = makeStubSession();
243
+ test("cancel JSON text frame routes to session.onCancel", async () => {
244
+ const core = makeMockCore();
210
245
  const ws = new MockWebSocket("ws://test");
211
246
  ws.readyState = MockWebSocket.OPEN;
212
247
  const logger = makeLogger();
213
248
 
214
249
  wireSessionSocket(ws, {
215
250
  sessions: new Map(),
216
- createSession: () => session,
251
+ createSession: () => core,
217
252
  readyConfig: defaultConfig,
218
253
  logger,
219
254
  });
220
255
 
221
256
  await waitForSessionReady(logger);
222
-
223
- ws.simulateMessage(JSON.stringify({ type: "cancel" }));
224
- expect(session.onCancel).toHaveBeenCalledOnce();
257
+ simulateTextFrame(ws, JSON.stringify({ type: "cancel" }));
258
+ expect(core.onCancel).toHaveBeenCalledOnce();
225
259
  });
226
260
 
227
- test("reset message calls session.onReset", async () => {
228
- const session = makeStubSession();
261
+ test("reset JSON text frame routes to session.onReset", async () => {
262
+ const core = makeMockCore();
229
263
  const ws = new MockWebSocket("ws://test");
230
264
  ws.readyState = MockWebSocket.OPEN;
231
265
  const logger = makeLogger();
232
266
 
233
267
  wireSessionSocket(ws, {
234
268
  sessions: new Map(),
235
- createSession: () => session,
269
+ createSession: () => core,
236
270
  readyConfig: defaultConfig,
237
271
  logger,
238
272
  });
239
273
 
240
274
  await waitForSessionReady(logger);
241
-
242
- ws.simulateMessage(JSON.stringify({ type: "reset" }));
243
- expect(session.onReset).toHaveBeenCalledOnce();
275
+ simulateTextFrame(ws, JSON.stringify({ type: "reset" }));
276
+ expect(core.onReset).toHaveBeenCalledOnce();
244
277
  });
245
278
 
246
- test("history message calls session.onHistory", async () => {
247
- const session = makeStubSession();
279
+ test("history JSON text frame routes to session.onHistory with decoded messages", async () => {
280
+ const core = makeMockCore();
248
281
  const ws = new MockWebSocket("ws://test");
249
282
  ws.readyState = MockWebSocket.OPEN;
250
283
  const logger = makeLogger();
251
284
 
252
285
  wireSessionSocket(ws, {
253
286
  sessions: new Map(),
254
- createSession: () => session,
287
+ createSession: () => core,
255
288
  readyConfig: defaultConfig,
256
289
  logger,
257
290
  });
@@ -260,169 +293,123 @@ describe("wireSessionSocket", () => {
260
293
 
261
294
  const messages = [
262
295
  { role: "user" as const, content: "Hello" },
263
- { role: "assistant" as const, content: "Hi" },
296
+ { role: "assistant" as const, content: "Hi there" },
264
297
  ];
265
- ws.simulateMessage(JSON.stringify({ type: "history", messages }));
266
- expect(session.onHistory).toHaveBeenCalledWith(messages);
298
+ simulateTextFrame(ws, JSON.stringify({ type: "history", messages }));
299
+ expect(core.onHistory).toHaveBeenCalledOnce();
300
+ const passed = (core.onHistory as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
301
+ expect(passed).toEqual(messages);
267
302
  });
268
303
 
269
- test("invalid JSON is logged and ignored", async () => {
270
- const session = makeStubSession();
304
+ // ─── Text message error handling ─────────────────────────────────────────
305
+
306
+ test("invalid JSON text frame is dropped with warning, session not closed", async () => {
307
+ const core = makeMockCore();
271
308
  const ws = new MockWebSocket("ws://test");
272
309
  ws.readyState = MockWebSocket.OPEN;
273
- const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
310
+ const logger = makeLogger();
274
311
 
275
312
  wireSessionSocket(ws, {
276
313
  sessions: new Map(),
277
- createSession: () => session,
314
+ createSession: () => core,
278
315
  readyConfig: defaultConfig,
279
316
  logger,
280
317
  });
281
318
 
282
319
  await waitForSessionReady(logger);
283
320
 
284
- ws.simulateMessage("not-json{{{");
285
- expect(logger.warn).toHaveBeenCalledWith("Invalid JSON from client", expect.any(Object));
321
+ simulateTextFrame(ws, "this is not json{{{");
322
+ expect(logger.warn).toHaveBeenCalledWith("ws: invalid JSON; dropping", expect.any(Object));
323
+ // Session methods must not be called
324
+ expect(core.onAudioReady).not.toHaveBeenCalled();
325
+ // Socket must still be open (not closed)
326
+ expect(ws.readyState).toBe(MockWebSocket.OPEN);
286
327
  });
287
328
 
288
- test("invalid message schema is logged and ignored", async () => {
289
- const session = makeStubSession();
329
+ test("unknown client message type is silently dropped", async () => {
330
+ const core = makeMockCore();
290
331
  const ws = new MockWebSocket("ws://test");
291
332
  ws.readyState = MockWebSocket.OPEN;
292
- const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
333
+ const logger = makeLogger();
293
334
 
294
335
  wireSessionSocket(ws, {
295
336
  sessions: new Map(),
296
- createSession: () => session,
337
+ createSession: () => core,
297
338
  readyConfig: defaultConfig,
298
339
  logger,
299
340
  });
300
341
 
301
342
  await waitForSessionReady(logger);
302
343
 
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" }));
344
+ // Valid JSON with a valid { type } envelope but unknown type lenientParse returns ok:false, malformed:false
345
+ simulateTextFrame(ws, JSON.stringify({ type: "some_future_message_type" }));
346
+ // Must NOT warn — rolling-upgrade tolerance
306
347
  expect(logger.warn).not.toHaveBeenCalled();
348
+ expect(core.onAudioReady).not.toHaveBeenCalled();
349
+ expect(ws.readyState).toBe(MockWebSocket.OPEN);
307
350
  });
308
351
 
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;
352
+ // ─── Message buffering ───────────────────────────────────────────────────
335
353
 
336
- wireSessionSocket(ws, {
337
- sessions: new Map(),
338
- createSession: (_sid, client) => {
339
- capturedClient = client;
340
- return makeStubSession();
341
- },
342
- readyConfig: defaultConfig,
343
- logger: silentLogger,
354
+ test("frames before session is ready are buffered and replayed after start()", async () => {
355
+ let resolveStart!: () => void;
356
+ const core = makeMockCore({
357
+ start: vi.fn(
358
+ () =>
359
+ new Promise<void>((r) => {
360
+ resolveStart = r;
361
+ }),
362
+ ),
344
363
  });
345
364
 
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
365
  const ws = new MockWebSocket("ws://test");
354
366
  ws.readyState = MockWebSocket.OPEN;
367
+ const logger = makeLogger();
355
368
 
356
369
  wireSessionSocket(ws, {
357
370
  sessions: new Map(),
358
- createSession: (_sid, client) => {
359
- capturedClient = client;
360
- return makeStubSession();
361
- },
371
+ createSession: () => core,
362
372
  readyConfig: defaultConfig,
363
- logger: silentLogger,
373
+ logger,
364
374
  });
365
375
 
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;
376
+ // Session not ready yet — send a cancel text frame
377
+ simulateTextFrame(ws, JSON.stringify({ type: "cancel" }));
378
+ expect(core.onCancel).not.toHaveBeenCalled();
375
379
 
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
- });
380
+ // Now let start() resolve
381
+ resolveStart();
382
+ await waitForSessionReady(logger);
385
383
 
386
- expect(capturedClient.open).toBe(true);
387
- ws.readyState = MockWebSocket.CLOSED;
388
- expect(capturedClient.open).toBe(false);
384
+ expect(core.onCancel).toHaveBeenCalledOnce();
389
385
  });
390
386
 
391
- test("ClientSink tolerates ws.send throwing (closed socket)", () => {
392
- let capturedClient!: ClientSink;
387
+ test("messages before session is created (no open yet) are ignored", () => {
393
388
  const ws = new MockWebSocket("ws://test");
394
- ws.readyState = MockWebSocket.OPEN;
389
+ ws.readyState = MockWebSocket.CONNECTING;
395
390
 
396
391
  wireSessionSocket(ws, {
397
392
  sessions: new Map(),
398
- createSession: (_sid, client) => {
399
- capturedClient = client;
400
- return makeStubSession();
401
- },
393
+ createSession: () => makeMockCore(),
402
394
  readyConfig: defaultConfig,
403
395
  logger: silentLogger,
404
396
  });
405
397
 
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();
398
+ // No open yet — session is null, should be silently ignored
399
+ simulateTextFrame(ws, JSON.stringify({ type: "audio_ready" }));
400
+ // No error thrown
414
401
  });
415
402
 
416
- // ─── Close handler ──────────────────────────────────────────────────────
403
+ // ─── Close handler ───────────────────────────────────────────────────────
417
404
 
418
405
  test("close handler calls session.stop", async () => {
419
- const session = makeStubSession();
406
+ const core = makeMockCore();
420
407
  const ws = new MockWebSocket("ws://test");
421
408
  ws.readyState = MockWebSocket.OPEN;
422
409
 
423
410
  wireSessionSocket(ws, {
424
411
  sessions: new Map(),
425
- createSession: () => session,
412
+ createSession: () => core,
426
413
  readyConfig: defaultConfig,
427
414
  logger: silentLogger,
428
415
  });
@@ -430,20 +417,20 @@ describe("wireSessionSocket", () => {
430
417
  ws.close();
431
418
 
432
419
  await vi.waitFor(() => {
433
- expect(session.stop).toHaveBeenCalledOnce();
420
+ expect(core.stop).toHaveBeenCalledOnce();
434
421
  });
435
422
  });
436
423
 
437
- // ─── Error handler ──────────────────────────────────────────────────────
424
+ // ─── Error handler ───────────────────────────────────────────────────────
438
425
 
439
426
  test("error event is logged", () => {
440
427
  const ws = new MockWebSocket("ws://test");
441
428
  ws.readyState = MockWebSocket.OPEN;
442
- const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
429
+ const logger = makeLogger();
443
430
 
444
431
  wireSessionSocket(ws, {
445
432
  sessions: new Map(),
446
- createSession: () => makeStubSession(),
433
+ createSession: () => makeMockCore(),
447
434
  readyConfig: defaultConfig,
448
435
  logger,
449
436
  });
@@ -461,11 +448,11 @@ describe("wireSessionSocket", () => {
461
448
  test("generic error event logs default message", () => {
462
449
  const ws = new MockWebSocket("ws://test");
463
450
  ws.readyState = MockWebSocket.OPEN;
464
- const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
451
+ const logger = makeLogger();
465
452
 
466
453
  wireSessionSocket(ws, {
467
454
  sessions: new Map(),
468
- createSession: () => makeStubSession(),
455
+ createSession: () => makeMockCore(),
469
456
  readyConfig: defaultConfig,
470
457
  logger,
471
458
  });
@@ -478,7 +465,7 @@ describe("wireSessionSocket", () => {
478
465
  );
479
466
  });
480
467
 
481
- // ─── Callbacks ──────────────────────────────────────────────────────────
468
+ // ─── Callbacks ───────────────────────────────────────────────────────────
482
469
 
483
470
  test("onOpen callback is invoked when socket opens", () => {
484
471
  const onOpen = vi.fn();
@@ -487,7 +474,7 @@ describe("wireSessionSocket", () => {
487
474
 
488
475
  wireSessionSocket(ws, {
489
476
  sessions: new Map(),
490
- createSession: () => makeStubSession(),
477
+ createSession: () => makeMockCore(),
491
478
  readyConfig: defaultConfig,
492
479
  onOpen,
493
480
  logger: silentLogger,
@@ -503,7 +490,7 @@ describe("wireSessionSocket", () => {
503
490
 
504
491
  wireSessionSocket(ws, {
505
492
  sessions: new Map(),
506
- createSession: () => makeStubSession(),
493
+ createSession: () => makeMockCore(),
507
494
  readyConfig: defaultConfig,
508
495
  onClose,
509
496
  logger: silentLogger,
@@ -517,17 +504,16 @@ describe("wireSessionSocket", () => {
517
504
  const onSessionEnd = vi.fn();
518
505
  const ws = new MockWebSocket("ws://test");
519
506
  ws.readyState = MockWebSocket.OPEN;
520
- const sessions = new Map<string, Session>();
507
+ const sessions = new Map<string, SessionCore>();
521
508
 
522
509
  wireSessionSocket(ws, {
523
510
  sessions,
524
- createSession: () => makeStubSession(),
511
+ createSession: () => makeMockCore(),
525
512
  readyConfig: defaultConfig,
526
513
  onSessionEnd,
527
514
  logger: silentLogger,
528
515
  });
529
516
 
530
- // Session is now in the map
531
517
  expect(sessions.size).toBe(1);
532
518
  const sessionId = [...sessions.keys()][0] ?? "";
533
519
 
@@ -540,24 +526,138 @@ describe("wireSessionSocket", () => {
540
526
  expect(sessions.size).toBe(0);
541
527
  });
542
528
 
543
- // ─── Concurrency regression tests ─────────────────────────────────────
529
+ test("onSinkCreated callback is invoked with sessionId and ClientSink", () => {
530
+ const onSinkCreated = vi.fn();
531
+ const ws = new MockWebSocket("ws://test");
532
+ ws.readyState = MockWebSocket.OPEN;
533
+
534
+ wireSessionSocket(ws, {
535
+ sessions: new Map(),
536
+ createSession: () => makeMockCore(),
537
+ readyConfig: defaultConfig,
538
+ onSinkCreated,
539
+ logger: silentLogger,
540
+ });
541
+
542
+ expect(onSinkCreated).toHaveBeenCalledOnce();
543
+ expect(typeof onSinkCreated.mock.calls[0]?.[0]).toBe("string");
544
+ });
545
+
546
+ // ─── ClientSink (indirect testing via createSession capture) ─────────────
547
+
548
+ test("ClientSink.open reflects ws.readyState", () => {
549
+ let capturedClient!: ClientSink;
550
+ const ws = new MockWebSocket("ws://test");
551
+ ws.readyState = MockWebSocket.OPEN;
552
+
553
+ wireSessionSocket(ws, {
554
+ sessions: new Map(),
555
+ createSession: (_sid, client) => {
556
+ capturedClient = client;
557
+ return makeMockCore();
558
+ },
559
+ readyConfig: defaultConfig,
560
+ logger: silentLogger,
561
+ });
562
+
563
+ expect(capturedClient.open).toBe(true);
564
+ ws.readyState = MockWebSocket.CLOSED;
565
+ expect(capturedClient.open).toBe(false);
566
+ });
567
+
568
+ test("ClientSink.playAudioChunk sends raw binary Uint8Array", () => {
569
+ let capturedClient!: ClientSink;
570
+ const ws = new MockWebSocket("ws://test");
571
+ ws.readyState = MockWebSocket.OPEN;
572
+
573
+ wireSessionSocket(ws, {
574
+ sessions: new Map(),
575
+ createSession: (_sid, client) => {
576
+ capturedClient = client;
577
+ return makeMockCore();
578
+ },
579
+ readyConfig: defaultConfig,
580
+ logger: silentLogger,
581
+ });
582
+
583
+ const chunk = new Uint8Array([10, 20, 30]);
584
+ capturedClient.playAudioChunk(chunk);
585
+
586
+ // Find binary frames in sent (skip the initial config JSON string)
587
+ const binaryFrames = (ws.sent as unknown[]).filter((d) => d instanceof Uint8Array);
588
+ expect(binaryFrames.length).toBeGreaterThanOrEqual(1);
589
+ expect(binaryFrames[0]).toBe(chunk);
590
+ });
591
+
592
+ test("ClientSink.playAudioDone sends audio_done JSON text frame", () => {
593
+ let capturedClient!: ClientSink;
594
+ const ws = new MockWebSocket("ws://test");
595
+ ws.readyState = MockWebSocket.OPEN;
596
+
597
+ wireSessionSocket(ws, {
598
+ sessions: new Map(),
599
+ createSession: (_sid, client) => {
600
+ capturedClient = client;
601
+ return makeMockCore();
602
+ },
603
+ readyConfig: defaultConfig,
604
+ logger: silentLogger,
605
+ });
606
+
607
+ capturedClient.playAudioDone();
608
+
609
+ // Find JSON string frames after the initial config
610
+ const textFrames = (ws.sent as unknown[])
611
+ .filter((d): d is string => typeof d === "string")
612
+ .map((s) => JSON.parse(s));
613
+ const audioDoneFrame = textFrames.find((m) => m.type === "audio_done");
614
+ expect(audioDoneFrame).toBeDefined();
615
+ });
616
+
617
+ test("ClientSink tolerates ws.send throwing (closed socket)", () => {
618
+ let capturedClient!: ClientSink;
619
+ const ws = new MockWebSocket("ws://test");
620
+ ws.readyState = MockWebSocket.OPEN;
621
+
622
+ wireSessionSocket(ws, {
623
+ sessions: new Map(),
624
+ createSession: (_sid, client) => {
625
+ capturedClient = client;
626
+ return makeMockCore();
627
+ },
628
+ readyConfig: defaultConfig,
629
+ logger: silentLogger,
630
+ });
631
+
632
+ // Override send to throw
633
+ ws.send = () => {
634
+ throw new Error("socket closed");
635
+ };
636
+ // Should not throw
637
+ capturedClient.event({ type: "speech_started" });
638
+ capturedClient.playAudioChunk(new Uint8Array([1]));
639
+ capturedClient.playAudioDone();
640
+ });
641
+
642
+ // ─── Concurrency regression tests ────────────────────────────────────────
544
643
 
545
644
  test("close during start() does not double-stop or throw", async () => {
546
645
  let resolveStart!: () => void;
547
- const session = makeStubSession();
548
- session.start = vi.fn(
549
- () =>
550
- new Promise<void>((r) => {
551
- resolveStart = r;
552
- }),
553
- );
646
+ const core = makeMockCore({
647
+ start: vi.fn(
648
+ () =>
649
+ new Promise<void>((r) => {
650
+ resolveStart = r;
651
+ }),
652
+ ),
653
+ });
554
654
  const ws = new MockWebSocket("ws://test");
555
655
  ws.readyState = MockWebSocket.OPEN;
556
- const sessions = new Map<string, Session>();
656
+ const sessions = new Map<string, SessionCore>();
557
657
 
558
658
  wireSessionSocket(ws, {
559
659
  sessions,
560
- createSession: () => session,
660
+ createSession: () => core,
561
661
  readyConfig: defaultConfig,
562
662
  logger: silentLogger,
563
663
  });
@@ -568,25 +668,23 @@ describe("wireSessionSocket", () => {
568
668
  // Now start() resolves
569
669
  resolveStart();
570
670
  await vi.waitFor(() => {
571
- expect(session.stop).toHaveBeenCalledOnce();
671
+ expect(core.stop).toHaveBeenCalledOnce();
572
672
  });
573
673
  });
574
674
 
575
675
  test("start() failure removes session from map before close", async () => {
576
- const session = makeStubSession();
577
- session.start = vi.fn(() => Promise.reject(new Error("boom")));
676
+ const core = makeMockCore({ start: vi.fn(() => Promise.reject(new Error("boom"))) });
578
677
  const ws = new MockWebSocket("ws://test");
579
678
  ws.readyState = MockWebSocket.OPEN;
580
- const sessions = new Map<string, Session>();
679
+ const sessions = new Map<string, SessionCore>();
581
680
 
582
681
  wireSessionSocket(ws, {
583
682
  sessions,
584
- createSession: () => session,
683
+ createSession: () => core,
585
684
  readyConfig: defaultConfig,
586
685
  logger: silentLogger,
587
686
  });
588
687
 
589
- // Wait for start() rejection to propagate
590
688
  await vi.waitFor(() => {
591
689
  expect(sessions.size).toBe(0);
592
690
  });
@@ -595,25 +693,25 @@ describe("wireSessionSocket", () => {
595
693
  ws.close();
596
694
  });
597
695
 
598
- // ─── Session start timeout ─────────────────────────────────────────────
696
+ // ─── Session start timeout ────────────────────────────────────────────────
599
697
 
600
698
  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
- );
699
+ const core = makeMockCore({
700
+ start: vi.fn(
701
+ () =>
702
+ new Promise<void>(() => {
703
+ /* intentionally never resolves */
704
+ }),
705
+ ),
706
+ });
609
707
 
610
708
  const ws = new MockWebSocket("ws://test");
611
709
  ws.readyState = MockWebSocket.OPEN;
612
- const sessions = new Map<string, Session>();
710
+ const sessions = new Map<string, SessionCore>();
613
711
 
614
712
  wireSessionSocket(ws, {
615
713
  sessions,
616
- createSession: () => session,
714
+ createSession: () => core,
617
715
  readyConfig: defaultConfig,
618
716
  logger: silentLogger,
619
717
  sessionStartTimeoutMs: 50,
@@ -634,52 +732,34 @@ describe("wireSessionSocket", () => {
634
732
  );
635
733
  });
636
734
 
637
- // ─── Socket not yet open ───────────────────────────────────────────────
735
+ // ─── Socket not yet open ──────────────────────────────────────────────────
638
736
 
639
737
  test("waits for open event when readyState is not OPEN", async () => {
640
- const session = makeStubSession();
738
+ const core = makeMockCore();
641
739
  const ws = new MockWebSocket("ws://test");
642
740
  ws.readyState = MockWebSocket.CONNECTING;
643
741
 
644
742
  wireSessionSocket(ws, {
645
743
  sessions: new Map(),
646
- createSession: () => session,
744
+ createSession: () => core,
647
745
  readyConfig: defaultConfig,
648
746
  logger: silentLogger,
649
747
  });
650
748
 
651
749
  // Session not started yet — waiting for open
652
- expect(session.start).not.toHaveBeenCalled();
750
+ expect(core.start).not.toHaveBeenCalled();
653
751
 
654
752
  // Simulate open
655
753
  ws.readyState = MockWebSocket.OPEN;
656
754
  ws.dispatchEvent(new Event("open"));
657
755
 
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
756
+ expect(core.start).toHaveBeenCalledOnce();
677
757
  });
678
758
 
679
- // ─── Session resume ────────────────────────────────────────────────────
759
+ // ─── Session resume ───────────────────────────────────────────────────────
680
760
 
681
761
  test("resumeFrom reuses old session ID instead of generating new UUID", () => {
682
- const sessions = new Map<string, Session>();
762
+ const sessions = new Map<string, SessionCore>();
683
763
  const ws = new MockWebSocket("ws://test");
684
764
  ws.readyState = MockWebSocket.OPEN;
685
765
  let capturedId: string | undefined;
@@ -688,7 +768,7 @@ describe("wireSessionSocket", () => {
688
768
  sessions,
689
769
  createSession: (sid) => {
690
770
  capturedId = sid;
691
- return makeStubSession();
771
+ return makeMockCore();
692
772
  },
693
773
  readyConfig: defaultConfig,
694
774
  logger: silentLogger,
@@ -699,24 +779,26 @@ describe("wireSessionSocket", () => {
699
779
  expect(sessions.has("old-session-abc")).toBeTruthy();
700
780
  });
701
781
 
702
- test("config message includes resumed session ID", () => {
782
+ test("CONFIG frame contains resumed session ID as sessionId", () => {
703
783
  const ws = new MockWebSocket("ws://test");
704
784
  ws.readyState = MockWebSocket.OPEN;
705
785
 
706
786
  wireSessionSocket(ws, {
707
787
  sessions: new Map(),
708
- createSession: () => makeStubSession(),
788
+ createSession: () => makeMockCore(),
709
789
  readyConfig: defaultConfig,
710
790
  logger: silentLogger,
711
791
  resumeFrom: "resume-id-123",
712
792
  });
713
793
 
714
- const config = ws.sentJson()[0];
715
- expect(config).toMatchObject({ type: "config", sessionId: "resume-id-123" });
794
+ const firstFrame = ws.sent[0];
795
+ const msg = JSON.parse(firstFrame as string);
796
+ expect(msg.type).toBe("config");
797
+ expect(msg.sessionId).toBe("resume-id-123");
716
798
  });
717
799
 
718
800
  test("without resumeFrom, generates a new UUID session ID", () => {
719
- const sessions = new Map<string, Session>();
801
+ const sessions = new Map<string, SessionCore>();
720
802
  const ws = new MockWebSocket("ws://test");
721
803
  ws.readyState = MockWebSocket.OPEN;
722
804
  let capturedId: string | undefined;
@@ -725,7 +807,7 @@ describe("wireSessionSocket", () => {
725
807
  sessions,
726
808
  createSession: (sid) => {
727
809
  capturedId = sid;
728
- return makeStubSession();
810
+ return makeMockCore();
729
811
  },
730
812
  readyConfig: defaultConfig,
731
813
  logger: silentLogger,