@alexkroman1/aai 0.12.3 → 1.0.3

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 +176 -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,590 @@
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).toContainEqual(expect.objectContaining({ type: "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).toContainEqual(expect.objectContaining({ type: "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).toContainEqual({
123
+ type: "user_transcript",
124
+ text: "Hello there",
125
+ });
126
+ });
127
+
128
+ test("audio event forwards audio to client", async () => {
129
+ const { session, client, mockHandle } = setup();
130
+ await session.start();
131
+
132
+ const chunk = new Uint8Array([10, 20, 30]);
133
+ mockHandle._fire("audio", { audio: chunk });
134
+
135
+ expect(client.audioChunks).toContainEqual(chunk);
136
+ });
137
+
138
+ test("agent_transcript emits agent_transcript", async () => {
139
+ const { session, client, mockHandle } = setup();
140
+ await session.start();
141
+
142
+ mockHandle._fire("event", {
143
+ type: "agent_transcript",
144
+ text: "Full response",
145
+ _interrupted: false,
146
+ });
147
+
148
+ expect(client.events).toContainEqual({
149
+ type: "agent_transcript",
150
+ text: "Full response",
151
+ });
152
+ });
153
+
154
+ test("speech_started and speech_stopped events are forwarded", async () => {
155
+ const { session, client, mockHandle } = setup();
156
+ await session.start();
157
+
158
+ mockHandle._fire("event", { type: "speech_started" });
159
+ mockHandle._fire("event", { type: "speech_stopped" });
160
+
161
+ expect(client.events).toContainEqual({ type: "speech_started" });
162
+ expect(client.events).toContainEqual({ type: "speech_stopped" });
163
+ });
164
+
165
+ test("reply_started resets tool call count", async () => {
166
+ const { session, mockHandle } = setup();
167
+ await session.start();
168
+
169
+ mockHandle._fire("replyStarted", { replyId: "r1" });
170
+ // No error — internal counter reset
171
+ });
172
+
173
+ test("reply_done without pending tools calls playAudioDone", async () => {
174
+ const { session, client, mockHandle } = setup();
175
+ await session.start();
176
+
177
+ mockHandle._fire("event", { type: "reply_done" });
178
+
179
+ expect(client.audioDoneCount).toBe(1);
180
+ expect(client.events).toContainEqual({ type: "reply_done" });
181
+ });
182
+
183
+ test("cancelled event emits cancelled", async () => {
184
+ const { session, client, mockHandle } = setup();
185
+ await session.start();
186
+
187
+ mockHandle._fire("event", { type: "cancelled" });
188
+
189
+ expect(client.events).toContainEqual({ type: "cancelled" });
190
+ });
191
+
192
+ test("error event emits error to client and closes handle", async () => {
193
+ const { session, client, mockHandle } = setup();
194
+ await session.start();
195
+
196
+ mockHandle._fire("error", new Error("Something broke"));
197
+
198
+ expect(client.events).toContainEqual({
199
+ type: "error",
200
+ code: "internal",
201
+ message: "Something broke",
202
+ });
203
+ expect(mockHandle.close).toHaveBeenCalled();
204
+ });
205
+
206
+ // ─── Tool call handling ────────────────────────────────────────────────
207
+
208
+ test("tool_call executes tool and accumulates pending result", async () => {
209
+ const executeTool = vi.fn(async () => "tool-output");
210
+ const { session, client, mockHandle } = setup({ executeTool });
211
+ await session.start();
212
+
213
+ mockHandle._fire("replyStarted", { replyId: "r1" });
214
+ mockHandle._fire("event", {
215
+ type: "tool_call",
216
+ toolCallId: "call-1",
217
+ toolName: "my_tool",
218
+ args: { key: "value" },
219
+ });
220
+
221
+ // Wait for async tool execution
222
+ await session.waitForTurn();
223
+
224
+ expect(executeTool).toHaveBeenCalledWith(
225
+ "my_tool",
226
+ { key: "value" },
227
+ "session-1",
228
+ expect.any(Array),
229
+ );
230
+ expect(client.events).toContainEqual({
231
+ type: "tool_call",
232
+ toolCallId: "call-1",
233
+ toolName: "my_tool",
234
+ args: { key: "value" },
235
+ });
236
+ expect(client.events).toContainEqual({
237
+ type: "tool_call_done",
238
+ toolCallId: "call-1",
239
+ result: "tool-output",
240
+ });
241
+ });
242
+
243
+ test("tool_call batches result and sends on reply_done", async () => {
244
+ const executeTool = vi.fn(async () => "result-1");
245
+ const { session, mockHandle } = setup({ executeTool });
246
+ await session.start();
247
+
248
+ mockHandle._fire("replyStarted", { replyId: "r1" });
249
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
250
+ await session.waitForTurn();
251
+
252
+ // Result not sent yet — S2S protocol requires waiting for reply_done
253
+ expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
254
+
255
+ mockHandle._fire("event", { type: "reply_done" });
256
+ // reply_done waits for turnPromise then sends
257
+ await vi.waitFor(() => {
258
+ expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c1", "result-1");
259
+ });
260
+ });
261
+
262
+ test("tool execution error returns JSON error string", async () => {
263
+ const executeTool = vi.fn(async () => {
264
+ throw new Error("boom");
265
+ });
266
+ const { session, client, mockHandle } = setup({ executeTool });
267
+ await session.start();
268
+
269
+ mockHandle._fire("replyStarted", { replyId: "r1" });
270
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
271
+ await session.waitForTurn();
272
+
273
+ const doneEvent = client.events.find((e) => {
274
+ const ev = e as Record<string, unknown>;
275
+ return ev.type === "tool_call_done" && ev.toolCallId === "c1";
276
+ }) as Record<string, unknown>;
277
+ expect(doneEvent.result).toBe(JSON.stringify({ error: "boom" }));
278
+ });
279
+
280
+ test("consumeToolCallStep refuses tool when maxSteps exceeded", async () => {
281
+ const executeTool = vi.fn(async () => "ok");
282
+ const { session, client, mockHandle } = setup({
283
+ executeTool,
284
+ agentConfig: {
285
+ name: "test-agent",
286
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
287
+ greeting: "Hello!",
288
+ maxSteps: 1,
289
+ },
290
+ });
291
+ await session.start();
292
+
293
+ mockHandle._fire("replyStarted", { replyId: "r1" });
294
+ // First tool call — should succeed (count goes to 1, which equals maxSteps)
295
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
296
+ await session.waitForTurn();
297
+
298
+ // Second tool call — should be refused (count goes to 2 > maxSteps 1)
299
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c2", toolName: "t2", args: {} });
300
+ await session.waitForTurn();
301
+
302
+ const doneEvent = client.events.find((e) => {
303
+ const ev = e as Record<string, unknown>;
304
+ return ev.type === "tool_call_done" && ev.toolCallId === "c2";
305
+ }) as Record<string, unknown>;
306
+ expect(doneEvent.result).toContain("Maximum tool steps reached");
307
+ // executeTool should only be called for the first one
308
+ expect(executeTool).toHaveBeenCalledTimes(1);
309
+ });
310
+
311
+ // ─── connectS2s failure ────────────────────────────────────────────────
312
+
313
+ test("start() handles connectS2s failure gracefully", async () => {
314
+ makeMockHandle();
315
+ const spy = vi.spyOn(_internals, "connectS2s").mockRejectedValue(new Error("connect failed"));
316
+ const client = makeClient();
317
+ const session = createS2sSession(makeSessionOpts({ client }));
318
+
319
+ await session.start();
320
+
321
+ expect(client.events).toContainEqual(
322
+ expect.objectContaining({
323
+ type: "error",
324
+ code: "internal",
325
+ message: "connect failed",
326
+ }),
327
+ );
328
+
329
+ spy.mockRestore();
330
+ });
331
+
332
+ // ─── Concurrency bug regression tests ─────────────────────────────────
333
+
334
+ test("barge-in prevents in-flight tool results from being sent", async () => {
335
+ let resolveToolCall!: (value: string) => void;
336
+ const executeTool = vi.fn(
337
+ () =>
338
+ new Promise<string>((r) => {
339
+ resolveToolCall = r;
340
+ }),
341
+ );
342
+ const { session, mockHandle } = setup({ executeTool });
343
+ await session.start();
344
+
345
+ // Start a tool call (stays pending)
346
+ mockHandle._fire("replyStarted", { replyId: "r1" });
347
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
348
+
349
+ // Wait for executeTool to be called (handleToolCall has async steps before it)
350
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
351
+
352
+ // Barge-in before tool completes — invalidates currentReplyId
353
+ mockHandle._fire("event", { type: "cancelled" });
354
+
355
+ // Now the tool finishes — its result should be discarded (generation mismatch)
356
+ resolveToolCall("late-result");
357
+ await session.waitForTurn();
358
+
359
+ // Start new reply and trigger reply_done — stale result must not leak
360
+ mockHandle._fire("replyStarted", { replyId: "r2" });
361
+ mockHandle._fire("event", { type: "reply_done" });
362
+
363
+ expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
364
+ });
365
+
366
+ test("reply_done waits for slow tool calls before sending results", async () => {
367
+ let resolveToolCall!: (value: string) => void;
368
+ const executeTool = vi.fn(
369
+ () =>
370
+ new Promise<string>((r) => {
371
+ resolveToolCall = r;
372
+ }),
373
+ );
374
+ const { session, mockHandle } = setup({ executeTool });
375
+ await session.start();
376
+
377
+ mockHandle._fire("replyStarted", { replyId: "r1" });
378
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
379
+
380
+ // Wait for executeTool to be called
381
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
382
+
383
+ // reply_done fires while tool is still executing
384
+ mockHandle._fire("event", { type: "reply_done" });
385
+
386
+ // Result not sent yet — tool still running
387
+ expect(mockHandle.sendToolResult).not.toHaveBeenCalled();
388
+
389
+ // Tool finishes — reply_done's deferred handler should now send it
390
+ resolveToolCall("result-1");
391
+ await vi.waitFor(() => {
392
+ expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c1", "result-1");
393
+ });
394
+ });
395
+
396
+ test("stale tool results from interrupted reply don't leak into next reply", async () => {
397
+ let resolveToolCall!: (value: string) => void;
398
+ const executeTool = vi.fn(
399
+ () =>
400
+ new Promise<string>((r) => {
401
+ resolveToolCall = r;
402
+ }),
403
+ );
404
+ const { session, mockHandle } = setup({ executeTool });
405
+ await session.start();
406
+
407
+ // First reply — interrupted while tool is running
408
+ mockHandle._fire("replyStarted", { replyId: "r1" });
409
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
410
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
411
+ mockHandle._fire("event", { type: "cancelled" });
412
+
413
+ // Tool from first reply finishes late
414
+ resolveToolCall("stale-result");
415
+ await session.waitForTurn();
416
+
417
+ // Second reply has its own tool call
418
+ executeTool.mockImplementation(async () => "fresh-result");
419
+ mockHandle._fire("replyStarted", { replyId: "r2" });
420
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c2", toolName: "t2", args: {} });
421
+ await session.waitForTurn();
422
+ mockHandle._fire("event", { type: "reply_done" });
423
+
424
+ // Only the fresh result should be sent, not the stale one
425
+ await vi.waitFor(() => {
426
+ expect(mockHandle.sendToolResult).toHaveBeenCalledTimes(1);
427
+ });
428
+ expect(mockHandle.sendToolResult).toHaveBeenCalledWith("c2", "fresh-result");
429
+ });
430
+
431
+ test("stop() during start() closes S2S handle to prevent orphaned connection", async () => {
432
+ let resolveConnect!: (value: S2sHandle) => void;
433
+ const handle = makeMockHandle();
434
+ const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
435
+ () =>
436
+ new Promise((r) => {
437
+ resolveConnect = r as (value: S2sHandle) => void;
438
+ }),
439
+ );
440
+ const client = makeClient();
441
+ const session = createS2sSession(makeSessionOpts({ client }));
442
+
443
+ const startPromise = session.start();
444
+ // Stop before connect completes
445
+ const stopPromise = session.stop();
446
+
447
+ // Now connect completes — handle should be closed immediately
448
+ resolveConnect(handle);
449
+ await startPromise;
450
+ await stopPromise;
451
+
452
+ expect(handle.close).toHaveBeenCalled();
453
+ spy.mockRestore();
454
+ });
455
+
456
+ test("rapid onReset closes stale connections from earlier resets", async () => {
457
+ // Simulate three rapid resets where connectS2s resolves in reverse order.
458
+ // Only the last connection should be kept; earlier ones should be closed.
459
+ const handles: ReturnType<typeof makeMockHandle>[] = [];
460
+ const resolvers: ((h: S2sHandle) => void)[] = [];
461
+
462
+ const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
463
+ () =>
464
+ new Promise<S2sHandle>((resolve) => {
465
+ const h = makeMockHandle();
466
+ handles.push(h);
467
+ resolvers.push(resolve as (value: S2sHandle) => void);
468
+ }),
469
+ );
470
+
471
+ const client = makeClient();
472
+ const session = createS2sSession(makeSessionOpts({ client }));
473
+
474
+ // Initial start — creates first pending connection
475
+ const startPromise = session.start();
476
+
477
+ // Two rapid resets before initial connect completes
478
+ session.onReset();
479
+ session.onReset();
480
+
481
+ // We now have 3 pending connectS2s calls (1 from start + 2 from resets).
482
+ // Resolve them in order: first two are stale, third is current.
483
+ expect(resolvers.length).toBe(3);
484
+
485
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
486
+ resolvers[0]?.(handles[0]!);
487
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
488
+ resolvers[1]?.(handles[1]!);
489
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
490
+ resolvers[2]?.(handles[2]!);
491
+
492
+ await startPromise;
493
+ await flush();
494
+
495
+ // First two handles should be closed (stale generations)
496
+ expect(handles[0]?.close).toHaveBeenCalled();
497
+ expect(handles[1]?.close).toHaveBeenCalled();
498
+ // Third handle (most recent) should NOT be closed — it's the active one
499
+ expect(handles[2]?.close).not.toHaveBeenCalled();
500
+
501
+ spy.mockRestore();
502
+ });
503
+
504
+ // ─── Idle timeout tests ──────────────────────────────────────────────
505
+
506
+ function hasIdleTimeout(events: unknown[]): boolean {
507
+ return events.some((e) => (e as Record<string, unknown>).type === "idle_timeout");
508
+ }
509
+
510
+ test("idle timeout fires after configured period of inactivity", async () => {
511
+ vi.useFakeTimers();
512
+ const { session, client, mockHandle } = setup({
513
+ agentConfig: {
514
+ name: "test-agent",
515
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
516
+ greeting: "Hello!",
517
+ idleTimeoutMs: 10_000,
518
+ },
519
+ });
520
+ await session.start();
521
+ vi.advanceTimersByTime(10_000);
522
+ expect(hasIdleTimeout(client.events)).toBe(true);
523
+ expect(mockHandle.close).toHaveBeenCalled();
524
+ vi.useRealTimers();
525
+ });
526
+
527
+ test("idle timeout is reset by client audio", async () => {
528
+ vi.useFakeTimers();
529
+ const { session, client } = setup({
530
+ agentConfig: {
531
+ name: "test-agent",
532
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
533
+ greeting: "Hello!",
534
+ idleTimeoutMs: 10_000,
535
+ },
536
+ });
537
+ await session.start();
538
+ vi.advanceTimersByTime(8000);
539
+ session.onAudio(new Uint8Array([1, 2, 3]));
540
+ vi.advanceTimersByTime(8000);
541
+ expect(hasIdleTimeout(client.events)).toBe(false);
542
+ vi.advanceTimersByTime(2000);
543
+ expect(hasIdleTimeout(client.events)).toBe(true);
544
+ vi.useRealTimers();
545
+ });
546
+
547
+ test("idle timeout disabled when idleTimeoutMs is 0", async () => {
548
+ vi.useFakeTimers();
549
+ const { session, client } = setup({
550
+ agentConfig: {
551
+ name: "test-agent",
552
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
553
+ greeting: "Hello!",
554
+ idleTimeoutMs: 0,
555
+ },
556
+ });
557
+ await session.start();
558
+ vi.advanceTimersByTime(600_000);
559
+ expect(hasIdleTimeout(client.events)).toBe(false);
560
+ vi.useRealTimers();
561
+ });
562
+
563
+ test("idle timer is cleared on stop()", async () => {
564
+ vi.useFakeTimers();
565
+ const { session, client } = setup({
566
+ agentConfig: {
567
+ name: "test-agent",
568
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
569
+ greeting: "Hello!",
570
+ idleTimeoutMs: 10_000,
571
+ },
572
+ });
573
+ await session.start();
574
+ await session.stop();
575
+ vi.advanceTimersByTime(20_000);
576
+ expect(hasIdleTimeout(client.events)).toBe(false);
577
+ vi.useRealTimers();
578
+ });
579
+
580
+ test("default idle timeout is 5 minutes when not configured", async () => {
581
+ vi.useFakeTimers();
582
+ const { session, client } = setup();
583
+ await session.start();
584
+ vi.advanceTimersByTime(240_000);
585
+ expect(hasIdleTimeout(client.events)).toBe(false);
586
+ vi.advanceTimersByTime(60_000);
587
+ expect(hasIdleTimeout(client.events)).toBe(true);
588
+ vi.useRealTimers();
589
+ });
590
+ });