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