@kuralle-syrinx/realtime 4.2.0 → 4.4.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.
@@ -1,676 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
-
5
- import type { ManagedSocket, SocketData, SocketFactory } from "@kuralle-syrinx/ws";
6
-
7
- import type { RealtimeEvent } from "./realtime-adapter.js";
8
- import { base64ToBytes, bytesToBase64, fromOpenAIRealtime } from "./from-openai-realtime.js";
9
-
10
- interface MockSocketHarness {
11
- readonly factory: SocketFactory;
12
- readonly sent: string[];
13
- inject(msg: Record<string, unknown>): void;
14
- }
15
-
16
- function createMockSocketHarness(): MockSocketHarness {
17
- const sent: string[] = [];
18
- let messageHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
19
-
20
- const socket: ManagedSocket = {
21
- get isOpen() {
22
- return true;
23
- },
24
- send: (data: SocketData) => {
25
- sent.push(typeof data === "string" ? data : "");
26
- },
27
- keepAlivePing: () => {},
28
- verify: async () => true,
29
- dispose: () => {},
30
- onOpen: (handler) => {
31
- queueMicrotask(() => handler());
32
- },
33
- onMessage: (handler) => {
34
- messageHandler = handler;
35
- },
36
- onClose: () => {},
37
- onError: () => {},
38
- };
39
-
40
- return {
41
- factory: () => socket,
42
- sent,
43
- inject: (msg) => messageHandler?.(JSON.stringify(msg), false),
44
- };
45
- }
46
-
47
- interface ReconnectableHarness {
48
- readonly factory: SocketFactory;
49
- readonly sent: string[];
50
- readonly connectCount: () => number;
51
- /** Fire the current socket's onClose with a reconnectable code → triggers auto-reconnect. */
52
- drop(): void;
53
- inject(msg: Record<string, unknown>): void;
54
- }
55
-
56
- /**
57
- * Mock harness that models the connection lifecycle so a reconnect can be simulated: each
58
- * factory call mints a fresh socket that auto-fires onOpen, and `drop()` closes the current
59
- * socket which drives WebSocketConnection to reconnect (calling the factory again).
60
- */
61
- function createReconnectableHarness(): ReconnectableHarness {
62
- const sent: string[] = [];
63
- let connectCount = 0;
64
- let current: {
65
- open: boolean;
66
- closeHandler?: (code: number, reason: string) => void;
67
- messageHandler?: (data: SocketData, isBinary: boolean) => void;
68
- } | null = null;
69
-
70
- const factory: SocketFactory = () => {
71
- connectCount += 1;
72
- const state: {
73
- open: boolean;
74
- closeHandler?: (code: number, reason: string) => void;
75
- messageHandler?: (data: SocketData, isBinary: boolean) => void;
76
- } = { open: false };
77
- current = state;
78
- const socket: ManagedSocket = {
79
- get isOpen() {
80
- return state.open;
81
- },
82
- send: (data: SocketData) => {
83
- sent.push(typeof data === "string" ? data : "");
84
- },
85
- keepAlivePing: () => {},
86
- verify: async () => true,
87
- dispose: () => {
88
- state.open = false;
89
- },
90
- onOpen: (handler) => {
91
- queueMicrotask(() => {
92
- state.open = true;
93
- handler();
94
- });
95
- },
96
- onMessage: (handler) => {
97
- state.messageHandler = handler;
98
- },
99
- onClose: (handler) => {
100
- state.closeHandler = handler;
101
- },
102
- onError: () => {},
103
- };
104
- return socket;
105
- };
106
-
107
- return {
108
- factory,
109
- sent,
110
- connectCount: () => connectCount,
111
- drop: () => {
112
- const state = current;
113
- if (!state) return;
114
- state.open = false;
115
- state.closeHandler?.(1006, "dropped");
116
- },
117
- inject: (msg) => current?.messageHandler?.(JSON.stringify(msg), false),
118
- };
119
- }
120
-
121
- async function collectEvents(
122
- events: AsyncIterable<RealtimeEvent>,
123
- max = 8,
124
- ): Promise<RealtimeEvent[]> {
125
- const out: RealtimeEvent[] = [];
126
- for await (const event of events) {
127
- out.push(event);
128
- if (out.length >= max) break;
129
- }
130
- return out;
131
- }
132
-
133
- async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
134
- const startedAt = Date.now();
135
- while (Date.now() - startedAt < timeoutMs) {
136
- if (predicate()) return;
137
- await new Promise((resolve) => setTimeout(resolve, 5));
138
- }
139
- throw new Error("Timed out waiting for condition");
140
- }
141
-
142
- describe("fromOpenAIRealtime", () => {
143
- it("emits exact client events for open, audio, cancel, and tool result", async () => {
144
- const mock = createMockSocketHarness();
145
- const adapter = fromOpenAIRealtime({
146
- apiKey: "test-key",
147
- socketFactory: mock.factory,
148
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
149
- tools: [
150
- {
151
- name: "consult_knowledge",
152
- description: "Answer knowledge questions.",
153
- parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
154
- },
155
- ],
156
- });
157
-
158
- const openTask = adapter.open(new AbortController().signal);
159
- await waitFor(() => mock.sent.length > 0);
160
- mock.inject({ type: "session.updated" });
161
- await openTask;
162
-
163
- const sessionUpdate = JSON.parse(mock.sent[0]!) as Record<string, unknown>;
164
- expect(sessionUpdate).toEqual({
165
- type: "session.update",
166
- session: {
167
- type: "realtime",
168
- model: "gpt-realtime-2",
169
- output_modalities: ["audio"],
170
- audio: {
171
- input: {
172
- format: { type: "audio/pcm", rate: 24000 },
173
- turn_detection: { type: "semantic_vad" },
174
- },
175
- output: {
176
- format: { type: "audio/pcm", rate: 24000 },
177
- voice: "marin",
178
- },
179
- },
180
- tools: [
181
- {
182
- type: "function",
183
- name: "consult_knowledge",
184
- description: "Answer knowledge questions.",
185
- parameters: {
186
- type: "object",
187
- properties: { query: { type: "string" } },
188
- required: ["query"],
189
- },
190
- },
191
- ],
192
- tool_choice: "auto",
193
- },
194
- });
195
-
196
- const pcm = new Uint8Array([0, 1, 2, 3]);
197
- adapter.sendAudio(pcm);
198
- expect(JSON.parse(mock.sent[1]!)).toEqual({
199
- type: "input_audio_buffer.append",
200
- audio: bytesToBase64(pcm),
201
- });
202
-
203
- mock.inject({ type: "response.created" });
204
- mock.inject({
205
- type: "response.output_item.added",
206
- item: { type: "message", id: "item_assistant_1" },
207
- });
208
- adapter.cancelResponse(420);
209
- expect(JSON.parse(mock.sent[2]!)).toEqual({ type: "response.cancel" });
210
- expect(JSON.parse(mock.sent[3]!)).toEqual({
211
- type: "conversation.item.truncate",
212
- item_id: "item_assistant_1",
213
- content_index: 0,
214
- audio_end_ms: 420,
215
- });
216
-
217
- adapter.injectToolResult("call_abc", "Late Add Petition required.");
218
- expect(JSON.parse(mock.sent[4]!)).toEqual({
219
- type: "conversation.item.create",
220
- item: {
221
- type: "function_call_output",
222
- call_id: "call_abc",
223
- output: "Late Add Petition required.",
224
- },
225
- });
226
- expect(mock.sent).toHaveLength(5);
227
-
228
- mock.inject({ type: "response.done", response: {} });
229
- expect(JSON.parse(mock.sent[5]!)).toEqual({ type: "response.create" });
230
- });
231
-
232
- it("G4/WBS-4: replays resumeHistory as conversation items after session.update, without response.create", async () => {
233
- const mock = createMockSocketHarness();
234
- const adapter = fromOpenAIRealtime({
235
- apiKey: "test-key",
236
- socketFactory: mock.factory,
237
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
238
- resumeHistory: () => [
239
- { role: "user", content: "First question" },
240
- { role: "assistant", content: "First answer" },
241
- ],
242
- });
243
-
244
- const openTask = adapter.open(new AbortController().signal);
245
- await waitFor(() => mock.sent.length >= 3);
246
- mock.inject({ type: "session.updated" });
247
- await openTask;
248
-
249
- expect(JSON.parse(mock.sent[0]!)).toMatchObject({ type: "session.update" });
250
- expect(JSON.parse(mock.sent[1]!)).toEqual({
251
- type: "conversation.item.create",
252
- item: {
253
- type: "message",
254
- role: "user",
255
- content: [{ type: "input_text", text: "First question" }],
256
- },
257
- });
258
- expect(JSON.parse(mock.sent[2]!)).toEqual({
259
- type: "conversation.item.create",
260
- item: {
261
- type: "message",
262
- role: "assistant",
263
- content: [{ type: "text", text: "First answer" }],
264
- },
265
- });
266
- // Replay must never make the model speak (R6: no double-answer).
267
- expect(mock.sent.map((frame) => (JSON.parse(frame) as { type: string }).type)).not.toContain(
268
- "response.create",
269
- );
270
- expect(adapter.caps.supportsNativeResume).toBeUndefined();
271
- });
272
-
273
- it("G4/WBS-4: replays the CURRENT resumeHistory again on reconnect (no amnesia error)", async () => {
274
- const harness = createReconnectableHarness();
275
- const history: Array<{ role: "user" | "assistant"; content: string }> = [
276
- { role: "user", content: "Q1" },
277
- ];
278
- const adapter = fromOpenAIRealtime({
279
- apiKey: "test-key",
280
- socketFactory: harness.factory,
281
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
282
- resumeHistory: () => history,
283
- });
284
- const events: RealtimeEvent[] = [];
285
- void (async () => {
286
- for await (const event of adapter.events) events.push(event);
287
- })();
288
-
289
- const openTask = adapter.open(new AbortController().signal);
290
- await waitFor(() => harness.sent.length >= 2);
291
- harness.inject({ type: "session.updated" });
292
- await openTask;
293
-
294
- history.push({ role: "assistant", content: "A1" });
295
- harness.sent.length = 0;
296
- harness.drop();
297
- await waitFor(() => harness.connectCount() === 2 && harness.sent.length >= 3);
298
-
299
- const types = harness.sent.map((frame) => (JSON.parse(frame) as { type: string }).type);
300
- expect(types[0]).toBe("session.update");
301
- expect(types.filter((type) => type === "conversation.item.create")).toHaveLength(2);
302
- // With replay wired, the reconnect no longer surfaces the history-lost error.
303
- expect(events.some((event) => event.type === "error" && event.cause.message.includes("history was lost"))).toBe(false);
304
-
305
- await adapter.close();
306
- });
307
-
308
- it("sends a typed user turn as conversation.item.create(input_text) + response.create", async () => {
309
- const mock = createMockSocketHarness();
310
- const adapter = fromOpenAIRealtime({
311
- apiKey: "test-key",
312
- socketFactory: mock.factory,
313
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
314
- });
315
-
316
- const openTask = adapter.open(new AbortController().signal);
317
- await waitFor(() => mock.sent.length > 0);
318
- mock.inject({ type: "session.updated" });
319
- await openTask;
320
-
321
- adapter.sendText!("when is the late-add deadline?");
322
- expect(JSON.parse(mock.sent[1]!)).toEqual({
323
- type: "conversation.item.create",
324
- item: {
325
- type: "message",
326
- role: "user",
327
- content: [{ type: "input_text", text: "when is the late-add deadline?" }],
328
- },
329
- });
330
- expect(JSON.parse(mock.sent[2]!)).toEqual({ type: "response.create" });
331
- });
332
-
333
- it("normalizes provider server events into RealtimeEvent", async () => {
334
- const mock = createMockSocketHarness();
335
- const adapter = fromOpenAIRealtime({
336
- apiKey: "test-key",
337
- socketFactory: mock.factory,
338
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
339
- });
340
-
341
- const eventsTask = collectEvents(adapter.events, 8);
342
- const openTask = adapter.open(new AbortController().signal);
343
- await waitFor(() => mock.sent.length > 0);
344
- mock.inject({ type: "session.updated" });
345
- await openTask;
346
-
347
- const audioBytes = new Uint8Array([9, 10, 11, 12]);
348
- mock.inject({
349
- type: "response.created",
350
- });
351
- mock.inject({
352
- type: "response.output_audio.delta",
353
- delta: bytesToBase64(audioBytes),
354
- });
355
- mock.inject({ type: "input_audio_buffer.speech_started" });
356
- mock.inject({
357
- type: "response.output_audio_transcript.delta",
358
- delta: "Let me ",
359
- });
360
- mock.inject({
361
- type: "response.output_audio_transcript.done",
362
- transcript: "Let me check that.",
363
- });
364
- mock.inject({
365
- type: "response.done",
366
- response: {
367
- output: [
368
- {
369
- type: "function_call",
370
- call_id: "call_123",
371
- name: "consult_knowledge",
372
- arguments: JSON.stringify({ query: "late add biology" }),
373
- },
374
- ],
375
- },
376
- });
377
- mock.inject({
378
- type: "error",
379
- error: { message: "rate limited", code: "rate_limit_exceeded" },
380
- });
381
-
382
- const events = await eventsTask;
383
- expect(events.slice(0, 7)).toEqual([
384
- { type: "response_started" },
385
- { type: "audio", pcm16: audioBytes, sampleRateHz: 24000 },
386
- { type: "speech_started" },
387
- { type: "transcript", role: "assistant", text: "Let me ", final: false },
388
- { type: "transcript", role: "assistant", text: "Let me check that.", final: true },
389
- {
390
- type: "tool_call",
391
- toolId: "call_123",
392
- toolName: "consult_knowledge",
393
- args: { query: "late add biology" },
394
- },
395
- { type: "response_done" },
396
- ]);
397
- expect(events[7]).toMatchObject({ type: "error", recoverable: true });
398
- if (events[7]?.type === "error") {
399
- expect(events[7].cause.message).toBe("rate limited");
400
- }
401
- });
402
-
403
- it("exposes gpt-realtime-2 capability flags", () => {
404
- const mock = createMockSocketHarness();
405
- const adapter = fromOpenAIRealtime({
406
- apiKey: "test-key",
407
- socketFactory: mock.factory,
408
- });
409
- expect(adapter.caps).toEqual({
410
- inputSampleRateHz: 24000,
411
- outputSampleRateHz: 24000,
412
- supportsConcurrentToolAudio: true,
413
- supportsTruncate: true,
414
- emitsServerSpeechStarted: true,
415
- supportsTextOnlyModality: false,
416
- });
417
- });
418
-
419
- it("R-04: base64 encode/decode works without Buffer or process", async () => {
420
- const savedBuffer = (globalThis as { Buffer?: unknown }).Buffer;
421
- const savedProcess = (globalThis as { process?: unknown }).process;
422
- delete (globalThis as { Buffer?: unknown }).Buffer;
423
- delete (globalThis as { process?: unknown }).process;
424
-
425
- try {
426
- const pcm = new Uint8Array([0, 1, 2, 3, 255, 128]);
427
- const encoded = bytesToBase64(pcm);
428
- expect(base64ToBytes(encoded)).toEqual(pcm);
429
-
430
- const mock = createMockSocketHarness();
431
- const adapter = fromOpenAIRealtime({
432
- apiKey: "test-key",
433
- socketFactory: mock.factory,
434
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
435
- });
436
-
437
- const openTask = adapter.open(new AbortController().signal);
438
- await waitFor(() => mock.sent.length > 0);
439
- mock.inject({ type: "session.updated" });
440
- await openTask;
441
-
442
- adapter.sendAudio(pcm);
443
- const sent = JSON.parse(mock.sent[1]!) as { audio: string };
444
- expect(sent.audio).toBe(encoded);
445
- } finally {
446
- if (savedBuffer !== undefined) (globalThis as { Buffer?: unknown }).Buffer = savedBuffer;
447
- if (savedProcess !== undefined) (globalThis as { process?: unknown }).process = savedProcess;
448
- }
449
- });
450
-
451
- it("R-06: forwards optional session config into session.update", async () => {
452
- const mock = createMockSocketHarness();
453
- const adapter = fromOpenAIRealtime({
454
- apiKey: "test-key",
455
- socketFactory: mock.factory,
456
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
457
- instructions: "You are a helpful assistant.",
458
- modalities: ["audio", "text"],
459
- temperature: 0.7,
460
- inputTranscription: { model: "whisper-1" },
461
- toolChoice: "required",
462
- inputRateHz: 16000,
463
- outputRateHz: 16000,
464
- });
465
-
466
- const openTask = adapter.open(new AbortController().signal);
467
- await waitFor(() => mock.sent.length > 0);
468
- mock.inject({ type: "session.updated" });
469
- await openTask;
470
-
471
- expect(JSON.parse(mock.sent[0]!)).toEqual({
472
- type: "session.update",
473
- session: {
474
- type: "realtime",
475
- model: "gpt-realtime-2",
476
- output_modalities: ["audio", "text"],
477
- instructions: "You are a helpful assistant.",
478
- temperature: 0.7,
479
- audio: {
480
- input: {
481
- format: { type: "audio/pcm", rate: 16000 },
482
- turn_detection: { type: "semantic_vad" },
483
- transcription: { model: "whisper-1" },
484
- },
485
- output: {
486
- format: { type: "audio/pcm", rate: 16000 },
487
- voice: "marin",
488
- },
489
- },
490
- tools: [],
491
- tool_choice: "required",
492
- },
493
- });
494
- expect(adapter.caps.inputSampleRateHz).toBe(16000);
495
- expect(adapter.caps.outputSampleRateHz).toBe(16000);
496
- });
497
-
498
- it("R-10: skips response.create when requiresResponseCreateAfterToolOutput is false", async () => {
499
- const mock = createMockSocketHarness();
500
- const adapter = fromOpenAIRealtime({
501
- apiKey: "test-key",
502
- socketFactory: mock.factory,
503
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
504
- requiresResponseCreateAfterToolOutput: false,
505
- });
506
-
507
- const openTask = adapter.open(new AbortController().signal);
508
- await waitFor(() => mock.sent.length > 0);
509
- mock.inject({ type: "session.updated" });
510
- await openTask;
511
-
512
- adapter.injectToolResult("call_xyz", "done");
513
- expect(mock.sent).toHaveLength(2);
514
- expect(JSON.parse(mock.sent[1]!)).toEqual({
515
- type: "conversation.item.create",
516
- item: {
517
- type: "function_call_output",
518
- call_id: "call_xyz",
519
- output: "done",
520
- },
521
- });
522
- });
523
-
524
- it("B1: does not send response.create while response is active (cancel-in-flight interleaving)", async () => {
525
- const mock = createMockSocketHarness();
526
- const adapter = fromOpenAIRealtime({
527
- apiKey: "test-key",
528
- socketFactory: mock.factory,
529
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
530
- });
531
-
532
- const openTask = adapter.open(new AbortController().signal);
533
- await waitFor(() => mock.sent.length > 0);
534
- mock.inject({ type: "session.updated" });
535
- await openTask;
536
-
537
- mock.inject({ type: "response.created" });
538
- mock.inject({
539
- type: "response.output_item.added",
540
- item: { type: "message", id: "item_assistant_1" },
541
- });
542
-
543
- adapter.cancelResponse(420);
544
- expect(JSON.parse(mock.sent[1]!)).toEqual({ type: "response.cancel" });
545
- expect(JSON.parse(mock.sent[2]!)).toEqual({
546
- type: "conversation.item.truncate",
547
- item_id: "item_assistant_1",
548
- content_index: 0,
549
- audio_end_ms: 420,
550
- });
551
-
552
- adapter.injectToolResult("call_abc", "Late Add Petition required.");
553
- const typesAfterInject = mock.sent.map(
554
- (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
555
- );
556
- expect(typesAfterInject).toContain("conversation.item.create");
557
- expect(typesAfterInject).not.toContain("response.create");
558
-
559
- mock.inject({ type: "response.done", response: {} });
560
- const typesAfterDone = mock.sent.map(
561
- (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
562
- );
563
- expect(typesAfterDone.filter((t) => t === "response.create")).toHaveLength(1);
564
- expect(JSON.parse(mock.sent[mock.sent.length - 1]!)).toEqual({ type: "response.create" });
565
- });
566
-
567
- it("B1: does not send response.create while response is active (direct inject without cancel)", async () => {
568
- const mock = createMockSocketHarness();
569
- const adapter = fromOpenAIRealtime({
570
- apiKey: "test-key",
571
- socketFactory: mock.factory,
572
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
573
- });
574
-
575
- const openTask = adapter.open(new AbortController().signal);
576
- await waitFor(() => mock.sent.length > 0);
577
- mock.inject({ type: "session.updated" });
578
- await openTask;
579
-
580
- mock.inject({ type: "response.created" });
581
-
582
- adapter.injectToolResult("call_xyz", "inline result");
583
- const typesAfterInject = mock.sent.map(
584
- (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
585
- );
586
- expect(typesAfterInject).toContain("conversation.item.create");
587
- expect(typesAfterInject).not.toContain("response.create");
588
-
589
- mock.inject({ type: "response.done", response: {} });
590
- expect(
591
- mock.sent.map((raw) => (JSON.parse(raw) as Record<string, unknown>)["type"]),
592
- ).toContain("response.create");
593
- });
594
-
595
- it("R-11: does not truncate stale item when new response is canceled before output_item.added", async () => {
596
- const mock = createMockSocketHarness();
597
- const adapter = fromOpenAIRealtime({
598
- apiKey: "test-key",
599
- socketFactory: mock.factory,
600
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
601
- });
602
-
603
- const openTask = adapter.open(new AbortController().signal);
604
- await waitFor(() => mock.sent.length > 0);
605
- mock.inject({ type: "session.updated" });
606
- await openTask;
607
-
608
- mock.inject({ type: "response.created" });
609
- mock.inject({
610
- type: "response.output_item.added",
611
- item: { type: "message", id: "item_old" },
612
- });
613
- mock.inject({ type: "response.done" });
614
-
615
- mock.inject({ type: "response.created" });
616
- adapter.cancelResponse(100);
617
-
618
- const truncateMessages = mock.sent
619
- .map((raw) => JSON.parse(raw) as Record<string, unknown>)
620
- .filter((msg) => msg["type"] === "conversation.item.truncate");
621
- expect(truncateMessages).toHaveLength(0);
622
- });
623
-
624
- it("BUG2: re-sends session.update (config + tools) on reconnect and surfaces history loss", async () => {
625
- const harness = createReconnectableHarness();
626
- const adapter = fromOpenAIRealtime({
627
- apiKey: "test-key",
628
- socketFactory: harness.factory,
629
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
630
- tools: [
631
- {
632
- name: "consult_knowledge",
633
- description: "Answer knowledge questions.",
634
- parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
635
- },
636
- ],
637
- });
638
-
639
- const errors: RealtimeEvent[] = [];
640
- const drainTask = (async () => {
641
- for await (const ev of adapter.events) {
642
- if (ev.type === "error") errors.push(ev);
643
- }
644
- })();
645
-
646
- const openTask = adapter.open(new AbortController().signal);
647
- await waitFor(() => harness.sent.some((s) => (JSON.parse(s) as { type?: string }).type === "session.update"));
648
- harness.inject({ type: "session.updated" });
649
- await openTask;
650
-
651
- const countSessionUpdate = (): string[] =>
652
- harness.sent.filter((s) => (JSON.parse(s) as { type?: string }).type === "session.update");
653
- expect(countSessionUpdate()).toHaveLength(1);
654
- expect(harness.connectCount()).toBe(1);
655
-
656
- // Mid-call socket drop → WebSocketConnection auto-reconnects.
657
- harness.drop();
658
- await waitFor(() => countSessionUpdate().length >= 2);
659
- expect(harness.connectCount()).toBeGreaterThanOrEqual(2);
660
-
661
- // The reconnected session got a fresh session.update carrying the tools + config.
662
- const secondUpdate = JSON.parse(countSessionUpdate()[1]!) as {
663
- session: { tools: Array<{ name: string }> };
664
- };
665
- expect(secondUpdate.session.tools).toHaveLength(1);
666
- expect(secondUpdate.session.tools[0]!.name).toBe("consult_knowledge");
667
-
668
- // History loss is surfaced as a recoverable error, not silence.
669
- await waitFor(() =>
670
- errors.some((e) => e.type === "error" && e.recoverable && /history/i.test(e.cause.message)),
671
- );
672
-
673
- await adapter.close();
674
- await drainTask;
675
- });
676
- });