@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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +13 -0
- package/dist/assemblyai-C969QGi4.js +35 -0
- package/dist/cartesia-BfQPOQ7Y.js +37 -0
- package/dist/host/_pipeline-test-fakes.d.ts +3 -1
- package/dist/host/providers/stt/deepgram.d.ts +28 -0
- package/dist/host/providers/tts/cartesia.d.ts +1 -1
- package/dist/host/providers/tts/rime.d.ts +44 -0
- package/dist/host/runtime-barrel.d.ts +4 -2
- package/dist/host/runtime-barrel.js +1432 -1208
- package/dist/host/runtime.d.ts +2 -2
- package/dist/host/s2s.d.ts +16 -16
- package/dist/host/session-core.d.ts +37 -0
- package/dist/host/transports/pipeline-transport.d.ts +48 -0
- package/dist/host/transports/s2s-transport.d.ts +19 -0
- package/dist/host/transports/types.d.ts +45 -0
- package/dist/host/ws-handler.d.ts +14 -10
- package/dist/sdk/protocol.d.ts +6 -5
- package/dist/sdk/providers/llm-barrel.js +1 -1
- package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
- package/dist/sdk/providers/stt-barrel.d.ts +1 -0
- package/dist/sdk/providers/stt-barrel.js +2 -2
- package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
- package/dist/sdk/providers/tts/rime.d.ts +42 -0
- package/dist/sdk/providers/tts-barrel.d.ts +1 -0
- package/dist/sdk/providers/tts-barrel.js +2 -2
- package/host/_pipeline-test-fakes.ts +6 -3
- package/host/_test-utils.ts +209 -128
- package/host/cleanup.test.ts +25 -298
- package/host/integration/pipeline-reference.integration.test.ts +30 -35
- package/host/providers/resolve.ts +10 -2
- package/host/providers/stt/deepgram.test.ts +229 -0
- package/host/providers/stt/deepgram.ts +172 -0
- package/host/providers/tts/cartesia.ts +7 -3
- package/host/providers/tts/rime.test.ts +251 -0
- package/host/providers/tts/rime.ts +322 -0
- package/host/runtime-barrel.ts +4 -2
- package/host/runtime.test.ts +13 -46
- package/host/runtime.ts +131 -23
- package/host/s2s.test.ts +122 -131
- package/host/s2s.ts +44 -52
- package/host/session-core.test.ts +257 -0
- package/host/session-core.ts +262 -0
- package/host/transports/pipeline-transport.test.ts +651 -0
- package/host/transports/pipeline-transport.ts +532 -0
- package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
- package/host/transports/s2s-transport.test.ts +56 -0
- package/host/transports/s2s-transport.ts +116 -0
- package/host/transports/types.test.ts +22 -0
- package/host/transports/types.ts +51 -0
- package/host/ws-handler.test.ts +324 -242
- package/host/ws-handler.ts +56 -59
- package/package.json +2 -1
- package/sdk/__snapshots__/exports.test.ts.snap +3 -3
- package/sdk/protocol-compat.test.ts +8 -0
- package/sdk/protocol.ts +6 -5
- package/sdk/providers/stt/deepgram.ts +43 -0
- package/sdk/providers/stt-barrel.ts +2 -0
- package/sdk/providers/tts/cartesia.ts +15 -5
- package/sdk/providers/tts/rime.ts +52 -0
- package/sdk/providers/tts-barrel.ts +2 -0
- package/dist/assemblyai-Cxg9eobY.js +0 -18
- package/dist/cartesia-DwDk2tEu.js +0 -10
- package/dist/host/pipeline-session-ctx.d.ts +0 -24
- package/dist/host/pipeline-session.d.ts +0 -52
- package/dist/host/session-ctx.d.ts +0 -73
- package/dist/host/session.d.ts +0 -62
- package/host/pipeline-session-ctx.test.ts +0 -31
- package/host/pipeline-session-ctx.ts +0 -36
- package/host/pipeline-session.test.ts +0 -672
- package/host/pipeline-session.ts +0 -533
- package/host/s2s-fixtures.test.ts +0 -237
- package/host/session-ctx.test.ts +0 -387
- package/host/session-ctx.ts +0 -134
- package/host/session-fixture-replay.test.ts +0 -128
- package/host/session.test.ts +0 -634
- package/host/session.ts +0 -412
- /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
package/host/session.test.ts
DELETED
|
@@ -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
|
-
});
|