@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,634 +0,0 @@
1
- import { afterEach, describe, expect, test, vi } from "vitest";
2
- import { DEFAULT_SYSTEM_PROMPT } from "../sdk/types.ts";
3
- import { flush, makeClient, makeMockHandle, makeSessionOpts } from "./_test-utils.ts";
4
- import type { S2sHandle } from "./s2s.ts";
5
- import { _internals, createS2sSession, type S2sSessionOptions } from "./session.ts";
6
-
7
- // ─── createS2sSession tests ─────────────────────────────────────────────────
8
-
9
- describe("createS2sSession", () => {
10
- let connectSpy: ReturnType<typeof vi.spyOn>;
11
- let mockHandle: ReturnType<typeof makeMockHandle>;
12
-
13
- function setup(overrides?: Partial<S2sSessionOptions>) {
14
- mockHandle = makeMockHandle();
15
- connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
16
- const client = makeClient();
17
- const opts = makeSessionOpts({ client, ...overrides });
18
- const session = createS2sSession(opts);
19
- return { session, client, opts, mockHandle };
20
- }
21
-
22
- afterEach(() => {
23
- connectSpy?.mockRestore();
24
- });
25
-
26
- test("start() calls connectS2s", async () => {
27
- const { session } = setup();
28
-
29
- await session.start();
30
- expect(connectSpy).toHaveBeenCalledOnce();
31
- });
32
-
33
- test("start() sends updateSession with greeting on initial connect", async () => {
34
- const { session, mockHandle } = setup();
35
- await session.start();
36
- expect(mockHandle.updateSession).toHaveBeenCalledOnce();
37
- const arg = vi.mocked(mockHandle.updateSession).mock.calls[0]?.[0];
38
- expect(arg).toBeDefined();
39
- expect(arg?.greeting).toBe("Hello!");
40
- expect(arg?.systemPrompt).toContain(DEFAULT_SYSTEM_PROMPT);
41
- });
42
-
43
- test("skipGreeting clears greeting in updateSession", async () => {
44
- const { session, mockHandle } = setup({ skipGreeting: true });
45
- await session.start();
46
- const arg = vi.mocked(mockHandle.updateSession).mock.calls[0]?.[0];
47
- expect(arg?.greeting).toBeUndefined();
48
- });
49
-
50
- test("stop() aborts session and closes s2s handle", async () => {
51
- const { session, mockHandle } = setup();
52
- await session.start();
53
- await session.stop();
54
- expect(mockHandle.close).toHaveBeenCalled();
55
- });
56
-
57
- test("stop() is idempotent", async () => {
58
- const { session, mockHandle } = setup();
59
- await session.start();
60
- await session.stop();
61
- await session.stop();
62
- // close is only called once because second stop short-circuits
63
- expect(mockHandle.close).toHaveBeenCalledTimes(1);
64
- });
65
-
66
- test("onAudio forwards data to s2s handle", async () => {
67
- const { session, mockHandle } = setup();
68
- await session.start();
69
- const audio = new Uint8Array([1, 2, 3, 4]);
70
- session.onAudio(audio);
71
- expect(mockHandle.sendAudio).toHaveBeenCalledWith(audio);
72
- });
73
-
74
- test("onAudioReady is idempotent", async () => {
75
- const { session } = setup();
76
- await session.start();
77
- session.onAudioReady();
78
- session.onAudioReady();
79
- // No error thrown, second call is a no-op
80
- });
81
-
82
- test("onCancel emits cancelled event", async () => {
83
- const { session, client } = setup();
84
- await session.start();
85
- session.onCancel();
86
- expect(client.events).toContainEvent("cancelled");
87
- });
88
-
89
- test("onReset clears state and emits reset event", async () => {
90
- const { session, client, mockHandle } = setup();
91
- await session.start();
92
- session.onReset();
93
- expect(client.events).toContainEvent("reset");
94
- expect(mockHandle.close).toHaveBeenCalled();
95
- });
96
-
97
- test("onHistory appends messages to conversation", async () => {
98
- const { session } = setup();
99
- await session.start();
100
- session.onHistory([
101
- { role: "user", content: "Hello" },
102
- { role: "assistant", content: "Hi" },
103
- ]);
104
- // No error — messages stored internally
105
- });
106
-
107
- test("waitForTurn resolves immediately when no turn in progress", async () => {
108
- const { session } = setup();
109
- await session.start();
110
- await expect(session.waitForTurn()).resolves.toBeUndefined();
111
- });
112
-
113
- // ─── S2S event handling tests ───────────────────────────────────────────
114
-
115
- test("user_transcript event emits user_transcript", async () => {
116
- const { session, client, mockHandle } = setup();
117
- await session.start();
118
-
119
- mockHandle._fire("event", { type: "user_transcript", text: "Hello there" });
120
- await flush();
121
-
122
- expect(client.events).toContainEvent("user_transcript", { text: "Hello there" });
123
- });
124
-
125
- test("audio event forwards audio to client", async () => {
126
- const { session, client, mockHandle } = setup();
127
- await session.start();
128
-
129
- const chunk = new Uint8Array([10, 20, 30]);
130
- mockHandle._fire("audio", { audio: chunk });
131
-
132
- expect(client.audioChunks).toContainEqual(chunk);
133
- });
134
-
135
- test("agent_transcript emits agent_transcript", async () => {
136
- const { session, client, mockHandle } = setup();
137
- await session.start();
138
-
139
- mockHandle._fire("event", {
140
- type: "agent_transcript",
141
- text: "Full response",
142
- _interrupted: false,
143
- });
144
-
145
- expect(client.events).toContainEvent("agent_transcript", { text: "Full response" });
146
- });
147
-
148
- test("speech_started and speech_stopped events are forwarded", async () => {
149
- const { session, client, mockHandle } = setup();
150
- await session.start();
151
-
152
- mockHandle._fire("event", { type: "speech_started" });
153
- mockHandle._fire("event", { type: "speech_stopped" });
154
-
155
- expect(client.events).toContainEvent("speech_started");
156
- expect(client.events).toContainEvent("speech_stopped");
157
- });
158
-
159
- test("reply_started resets tool call count", async () => {
160
- const { session, mockHandle } = setup();
161
- await session.start();
162
-
163
- mockHandle._fire("replyStarted", { replyId: "r1" });
164
- // No error — internal counter reset
165
- });
166
-
167
- test("reply_done without pending tools calls playAudioDone", async () => {
168
- const { session, client, mockHandle } = setup();
169
- await session.start();
170
-
171
- mockHandle._fire("replyStarted", { replyId: "r1" });
172
- mockHandle._fire("event", { type: "reply_done" });
173
-
174
- expect(client.audioDoneCount).toBe(1);
175
- expect(client.events).toContainEvent("reply_done");
176
- });
177
-
178
- test("duplicate reply_done is suppressed after reply completes", async () => {
179
- const { session, client, mockHandle } = setup();
180
- await session.start();
181
-
182
- mockHandle._fire("replyStarted", { replyId: "r1" });
183
- mockHandle._fire("event", { type: "reply_done" });
184
- mockHandle._fire("event", { type: "reply_done" });
185
-
186
- const replyDones = client.events.filter(
187
- (e): e is { type: string } =>
188
- typeof e === "object" && e !== null && "type" in e && e.type === "reply_done",
189
- );
190
- expect(replyDones).toHaveLength(1);
191
- expect(client.audioDoneCount).toBe(1);
192
- });
193
-
194
- test("S2S close with active reply logs a warn", async () => {
195
- const warn = vi.fn();
196
- const info = vi.fn();
197
- const logger = { debug: vi.fn(), info, warn, error: vi.fn() };
198
- const { session, mockHandle } = setup({ logger });
199
- await session.start();
200
-
201
- // reply started but not yet done → currentReplyId is non-null
202
- mockHandle._fire("replyStarted", { replyId: "r-active" });
203
- mockHandle._fire("close", 1006, "abnormal");
204
-
205
- expect(warn).toHaveBeenCalledWith(
206
- "S2S closed with active reply",
207
- expect.objectContaining({ activeReplyId: "r-active", code: 1006, reason: "abnormal" }),
208
- );
209
- });
210
-
211
- test("S2S clean close (no active reply) logs at info, not warn", async () => {
212
- const warn = vi.fn();
213
- const info = vi.fn();
214
- const logger = { debug: vi.fn(), info, warn, error: vi.fn() };
215
- const { session, mockHandle } = setup({ logger });
216
- await session.start();
217
-
218
- mockHandle._fire("replyStarted", { replyId: "r1" });
219
- mockHandle._fire("event", { type: "reply_done" });
220
- mockHandle._fire("close", 1000, "ok");
221
-
222
- expect(warn).not.toHaveBeenCalledWith("S2S closed with active reply", expect.any(Object));
223
- expect(info).toHaveBeenCalledWith("S2S closed", expect.objectContaining({ code: 1000 }));
224
- });
225
-
226
- test("fast reply_done dispatch does not warn", async () => {
227
- const warn = vi.fn();
228
- const logger = {
229
- debug: vi.fn(),
230
- info: vi.fn(),
231
- warn,
232
- error: vi.fn(),
233
- };
234
- const { session, mockHandle } = setup({ logger });
235
- await session.start();
236
-
237
- mockHandle._fire("replyStarted", { replyId: "r1" });
238
- mockHandle._fire("event", { type: "reply_done" });
239
-
240
- expect(warn).not.toHaveBeenCalledWith("slow reply_done dispatch", expect.any(Object));
241
- });
242
-
243
- test("cancelled event emits cancelled", async () => {
244
- const { session, client, mockHandle } = setup();
245
- await session.start();
246
-
247
- mockHandle._fire("event", { type: "cancelled" });
248
-
249
- expect(client.events).toContainEvent("cancelled");
250
- });
251
-
252
- test("error event emits error to client and closes handle", async () => {
253
- const { session, client, mockHandle } = setup();
254
- await session.start();
255
-
256
- mockHandle._fire("error", new Error("Something broke"));
257
-
258
- expect(client.events).toContainEvent("error", { code: "internal", message: "Something broke" });
259
- expect(mockHandle.close).toHaveBeenCalled();
260
- });
261
-
262
- // ─── Tool call handling ────────────────────────────────────────────────
263
-
264
- test("tool_call executes tool and accumulates pending result", async () => {
265
- const executeTool = vi.fn(async () => "tool-output");
266
- const { session, client, mockHandle } = setup({ executeTool });
267
- await session.start();
268
-
269
- mockHandle._fire("replyStarted", { replyId: "r1" });
270
- mockHandle._fire("event", {
271
- type: "tool_call",
272
- toolCallId: "call-1",
273
- toolName: "my_tool",
274
- args: { key: "value" },
275
- });
276
-
277
- // Wait for async tool execution
278
- await session.waitForTurn();
279
-
280
- expect(executeTool).toHaveBeenCalledWith(
281
- "my_tool",
282
- { key: "value" },
283
- "session-1",
284
- expect.any(Array),
285
- );
286
- expect(client.events).toContainEvent("tool_call", {
287
- toolCallId: "call-1",
288
- toolName: "my_tool",
289
- args: { key: "value" },
290
- });
291
- expect(client.events).toContainEvent("tool_call_done", {
292
- toolCallId: "call-1",
293
- result: "tool-output",
294
- });
295
- });
296
-
297
- test("tool_call batches result and sends on reply_done", async () => {
298
- const executeTool = vi.fn(async () => "result-1");
299
- const { session, mockHandle } = setup({ executeTool });
300
- await session.start();
301
-
302
- mockHandle._fire("replyStarted", { replyId: "r1" });
303
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
304
- await session.waitForTurn();
305
-
306
- // Result not sent yet — S2S protocol requires waiting for reply_done
307
- expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
308
-
309
- mockHandle._fire("event", { type: "reply_done" });
310
- // reply_done waits for turnPromise then sends
311
- await vi.waitFor(() => {
312
- expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c1", "result-1");
313
- });
314
- });
315
-
316
- test("tool execution error returns JSON error string", async () => {
317
- const executeTool = vi.fn(async () => {
318
- throw new Error("boom");
319
- });
320
- const { session, client, mockHandle } = setup({ executeTool });
321
- await session.start();
322
-
323
- mockHandle._fire("replyStarted", { replyId: "r1" });
324
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
325
- await session.waitForTurn();
326
-
327
- const doneEvent = client.events.find((e) => {
328
- const ev = e as Record<string, unknown>;
329
- return ev.type === "tool_call_done" && ev.toolCallId === "c1";
330
- }) as Record<string, unknown>;
331
- expect(doneEvent.result).toBe(JSON.stringify({ error: "boom" }));
332
- });
333
-
334
- test("consumeToolCallStep refuses tool when maxSteps exceeded", async () => {
335
- const executeTool = vi.fn(async () => "ok");
336
- const { session, client, mockHandle } = setup({
337
- executeTool,
338
- agentConfig: {
339
- name: "test-agent",
340
- systemPrompt: DEFAULT_SYSTEM_PROMPT,
341
- greeting: "Hello!",
342
- maxSteps: 1,
343
- },
344
- });
345
- await session.start();
346
-
347
- mockHandle._fire("replyStarted", { replyId: "r1" });
348
- // First tool call — should succeed (count goes to 1, which equals maxSteps)
349
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
350
- await session.waitForTurn();
351
-
352
- // Second tool call — should be refused (count goes to 2 > maxSteps 1)
353
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c2", toolName: "t2", args: {} });
354
- await session.waitForTurn();
355
-
356
- const doneEvent = client.events.find((e) => {
357
- const ev = e as Record<string, unknown>;
358
- return ev.type === "tool_call_done" && ev.toolCallId === "c2";
359
- }) as Record<string, unknown>;
360
- expect(doneEvent.result).toContain("Maximum tool steps reached");
361
- // executeTool should only be called for the first one
362
- expect(executeTool).toHaveBeenCalledTimes(1);
363
- });
364
-
365
- // ─── connectS2s failure ────────────────────────────────────────────────
366
-
367
- test("start() handles connectS2s failure gracefully", async () => {
368
- makeMockHandle();
369
- const spy = vi.spyOn(_internals, "connectS2s").mockRejectedValue(new Error("connect failed"));
370
- const client = makeClient();
371
- const session = createS2sSession(makeSessionOpts({ client }));
372
-
373
- await session.start();
374
-
375
- expect(client.events).toContainEvent("error", { code: "internal", message: "connect failed" });
376
-
377
- spy.mockRestore();
378
- });
379
-
380
- // ─── Concurrency bug regression tests ─────────────────────────────────
381
-
382
- test("barge-in prevents in-flight tool results from being sent", async () => {
383
- let resolveToolCall!: (value: string) => void;
384
- const executeTool = vi.fn(
385
- () =>
386
- new Promise<string>((r) => {
387
- resolveToolCall = r;
388
- }),
389
- );
390
- const { session, mockHandle } = setup({ executeTool });
391
- await session.start();
392
-
393
- // Start a tool call (stays pending)
394
- mockHandle._fire("replyStarted", { replyId: "r1" });
395
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
396
-
397
- // Wait for executeTool to be called (handleToolCall has async steps before it)
398
- await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
399
-
400
- // Barge-in before tool completes — invalidates currentReplyId
401
- mockHandle._fire("event", { type: "cancelled" });
402
-
403
- // Now the tool finishes — its result should be discarded (generation mismatch)
404
- resolveToolCall("late-result");
405
- await session.waitForTurn();
406
-
407
- // Start new reply and trigger reply_done — stale result must not leak
408
- mockHandle._fire("replyStarted", { replyId: "r2" });
409
- mockHandle._fire("event", { type: "reply_done" });
410
-
411
- expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
412
- });
413
-
414
- test("reply_done waits for slow tool calls before sending results", async () => {
415
- let resolveToolCall!: (value: string) => void;
416
- const executeTool = vi.fn(
417
- () =>
418
- new Promise<string>((r) => {
419
- resolveToolCall = r;
420
- }),
421
- );
422
- const { session, mockHandle } = setup({ executeTool });
423
- await session.start();
424
-
425
- mockHandle._fire("replyStarted", { replyId: "r1" });
426
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
427
-
428
- // Wait for executeTool to be called
429
- await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
430
-
431
- // reply_done fires while tool is still executing
432
- mockHandle._fire("event", { type: "reply_done" });
433
-
434
- // Result not sent yet — tool still running
435
- expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
436
-
437
- // Tool finishes — reply_done's deferred handler should now send it
438
- resolveToolCall("result-1");
439
- await vi.waitFor(() => {
440
- expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c1", "result-1");
441
- });
442
- });
443
-
444
- test("stale tool results from interrupted reply don't leak into next reply", async () => {
445
- let resolveToolCall!: (value: string) => void;
446
- const executeTool = vi.fn(
447
- () =>
448
- new Promise<string>((r) => {
449
- resolveToolCall = r;
450
- }),
451
- );
452
- const { session, mockHandle } = setup({ executeTool });
453
- await session.start();
454
-
455
- // First reply — interrupted while tool is running
456
- mockHandle._fire("replyStarted", { replyId: "r1" });
457
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
458
- await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
459
- mockHandle._fire("event", { type: "cancelled" });
460
-
461
- // Tool from first reply finishes late
462
- resolveToolCall("stale-result");
463
- await session.waitForTurn();
464
-
465
- // Second reply has its own tool call
466
- executeTool.mockImplementation(async () => "fresh-result");
467
- mockHandle._fire("replyStarted", { replyId: "r2" });
468
- mockHandle._fire("event", { type: "tool_call", toolCallId: "c2", toolName: "t2", args: {} });
469
- await session.waitForTurn();
470
- mockHandle._fire("event", { type: "reply_done" });
471
-
472
- // Only the fresh result should be sent, not the stale one
473
- await vi.waitFor(() => {
474
- expect(mockHandle.sendToolResult).toHaveBeenCalledTimes(1);
475
- });
476
- expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c2", "fresh-result");
477
- });
478
-
479
- test("stop() during start() closes S2S handle to prevent orphaned connection", async () => {
480
- let resolveConnect!: (value: S2sHandle) => void;
481
- const handle = makeMockHandle();
482
- const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
483
- () =>
484
- new Promise((r) => {
485
- resolveConnect = r as (value: S2sHandle) => void;
486
- }),
487
- );
488
- const client = makeClient();
489
- const session = createS2sSession(makeSessionOpts({ client }));
490
-
491
- const startPromise = session.start();
492
- // Stop before connect completes
493
- const stopPromise = session.stop();
494
-
495
- // Now connect completes — handle should be closed immediately
496
- resolveConnect(handle);
497
- await startPromise;
498
- await stopPromise;
499
-
500
- expect(handle.close).toHaveBeenCalled();
501
- spy.mockRestore();
502
- });
503
-
504
- test("rapid onReset closes stale connections from earlier resets", async () => {
505
- // Simulate three rapid resets where connectS2s resolves in reverse order.
506
- // Only the last connection should be kept; earlier ones should be closed.
507
- const handles: ReturnType<typeof makeMockHandle>[] = [];
508
- const resolvers: ((h: S2sHandle) => void)[] = [];
509
-
510
- const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
511
- () =>
512
- new Promise<S2sHandle>((resolve) => {
513
- const h = makeMockHandle();
514
- handles.push(h);
515
- resolvers.push(resolve as (value: S2sHandle) => void);
516
- }),
517
- );
518
-
519
- const client = makeClient();
520
- const session = createS2sSession(makeSessionOpts({ client }));
521
-
522
- // Initial start — creates first pending connection
523
- const startPromise = session.start();
524
-
525
- // Two rapid resets before initial connect completes
526
- session.onReset();
527
- session.onReset();
528
-
529
- // We now have 3 pending connectS2s calls (1 from start + 2 from resets).
530
- // Resolve them in order: first two are stale, third is current.
531
- expect(resolvers.length).toBe(3);
532
-
533
- // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
534
- resolvers[0]?.(handles[0]!);
535
- // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
536
- resolvers[1]?.(handles[1]!);
537
- // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
538
- resolvers[2]?.(handles[2]!);
539
-
540
- await startPromise;
541
- await flush();
542
-
543
- // First two handles should be closed (stale generations)
544
- expect(handles[0]?.close).toHaveBeenCalled();
545
- expect(handles[1]?.close).toHaveBeenCalled();
546
- // Third handle (most recent) should NOT be closed — it's the active one
547
- expect(handles[2]?.close).not.toHaveBeenCalled();
548
-
549
- spy.mockRestore();
550
- });
551
-
552
- // ─── Idle timeout tests ──────────────────────────────────────────────
553
-
554
- test("idle timeout fires after configured period of inactivity", async () => {
555
- vi.useFakeTimers();
556
- const { session, client, mockHandle } = setup({
557
- agentConfig: {
558
- name: "test-agent",
559
- systemPrompt: DEFAULT_SYSTEM_PROMPT,
560
- greeting: "Hello!",
561
- idleTimeoutMs: 10_000,
562
- },
563
- });
564
- await session.start();
565
- vi.advanceTimersByTime(10_000);
566
- expect(client.events).toContainEvent("idle_timeout");
567
- expect(mockHandle.close).toHaveBeenCalled();
568
- vi.useRealTimers();
569
- });
570
-
571
- test("idle timeout is reset by client audio", async () => {
572
- vi.useFakeTimers();
573
- const { session, client } = setup({
574
- agentConfig: {
575
- name: "test-agent",
576
- systemPrompt: DEFAULT_SYSTEM_PROMPT,
577
- greeting: "Hello!",
578
- idleTimeoutMs: 10_000,
579
- },
580
- });
581
- await session.start();
582
- vi.advanceTimersByTime(8000);
583
- session.onAudio(new Uint8Array([1, 2, 3]));
584
- vi.advanceTimersByTime(8000);
585
- expect(client.events).not.toContainEvent("idle_timeout");
586
- vi.advanceTimersByTime(2000);
587
- expect(client.events).toContainEvent("idle_timeout");
588
- vi.useRealTimers();
589
- });
590
-
591
- test("idle timeout disabled when idleTimeoutMs is 0", async () => {
592
- vi.useFakeTimers();
593
- const { session, client } = setup({
594
- agentConfig: {
595
- name: "test-agent",
596
- systemPrompt: DEFAULT_SYSTEM_PROMPT,
597
- greeting: "Hello!",
598
- idleTimeoutMs: 0,
599
- },
600
- });
601
- await session.start();
602
- vi.advanceTimersByTime(600_000);
603
- expect(client.events).not.toContainEvent("idle_timeout");
604
- vi.useRealTimers();
605
- });
606
-
607
- test("idle timer is cleared on stop()", async () => {
608
- vi.useFakeTimers();
609
- const { session, client } = setup({
610
- agentConfig: {
611
- name: "test-agent",
612
- systemPrompt: DEFAULT_SYSTEM_PROMPT,
613
- greeting: "Hello!",
614
- idleTimeoutMs: 10_000,
615
- },
616
- });
617
- await session.start();
618
- await session.stop();
619
- vi.advanceTimersByTime(20_000);
620
- expect(client.events).not.toContainEvent("idle_timeout");
621
- vi.useRealTimers();
622
- });
623
-
624
- test("default idle timeout is 5 minutes when not configured", async () => {
625
- vi.useFakeTimers();
626
- const { session, client } = setup();
627
- await session.start();
628
- vi.advanceTimersByTime(240_000);
629
- expect(client.events).not.toContainEvent("idle_timeout");
630
- vi.advanceTimersByTime(60_000);
631
- expect(client.events).toContainEvent("idle_timeout");
632
- vi.useRealTimers();
633
- });
634
- });