@kuralle-syrinx/aisdk 4.2.0 → 4.3.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/src/index.test.ts DELETED
@@ -1,989 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import { InMemoryReasonerSessionStore, PipelineBusImpl, Route } from "@kuralle-syrinx/core";
5
- import type {
6
- EndOfSpeechPacket,
7
- InterruptLlmPacket,
8
- LlmErrorPacket,
9
- LlmResponseDonePacket,
10
- ReasoningSuspendedPacket,
11
- Reasoner,
12
- ReasonerTurn,
13
- ReasoningPart,
14
- TextToSpeechPlayoutProgressPacket,
15
- TextToSpeechTextPacket,
16
- TextToSpeechWordTimestampsPacket,
17
- TtsWordTimestamp,
18
- } from "@kuralle-syrinx/core";
19
- import type { FinishReason, ModelMessage, TextStreamPart, ToolSet } from "ai";
20
- import { fromStreamFactory } from "./from-ai-sdk.js";
21
- import { ReasoningBridge, type RunPointer, type RunStore } from "./index.js";
22
-
23
- const ZERO_USAGE = {
24
- inputTokens: 0,
25
- inputTokenDetails: {
26
- noCacheTokens: 0,
27
- cacheReadTokens: 0,
28
- cacheWriteTokens: 0,
29
- },
30
- outputTokens: 0,
31
- outputTokenDetails: {
32
- textTokens: 0,
33
- reasoningTokens: 0,
34
- },
35
- totalTokens: 0,
36
- };
37
-
38
- describe("ReasoningBridge", () => {
39
- it("emits llm.done only after a normal provider stop finish", async () => {
40
- const packets: Array<{ route: Route; packet: unknown }> = [];
41
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
42
- yield textDelta("Hello.");
43
- yield finish("stop");
44
- }));
45
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
46
- const drain = bus.start();
47
-
48
- await plugin.initialize(bus, baseConfig());
49
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
50
-
51
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
52
- bus.stop();
53
- await drain;
54
- await plugin.close();
55
-
56
- expect(packets).toContainEqual({
57
- route: Route.Main,
58
- packet: expect.objectContaining({
59
- kind: "llm.done",
60
- contextId: "turn-1",
61
- text: "Hello.",
62
- } satisfies Partial<LlmResponseDonePacket>),
63
- });
64
- expect(packets).toContainEqual({
65
- route: Route.Background,
66
- packet: expect.objectContaining({
67
- kind: "metric.conversation",
68
- contextId: "turn-1",
69
- name: "llm.finish_reason",
70
- value: "stop",
71
- }),
72
- });
73
- });
74
-
75
- it("G2/WBS-1: cascade turn emits delegate.query then delegate.result on the Background route", async () => {
76
- const packets: Array<{ route: Route; packet: unknown }> = [];
77
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
78
- yield toolCall("rag-1", "retrieve", { q: "deadline" });
79
- yield toolResult("rag-1", "retrieve", "chunk");
80
- yield textDelta("The deadline is March 31.");
81
- yield finish("stop");
82
- }));
83
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
84
- const drain = bus.start();
85
-
86
- await plugin.initialize(bus, baseConfig());
87
- bus.push(Route.Main, turnComplete("turn-1", "When is the deadline?"));
88
-
89
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "delegate.result"));
90
- bus.stop();
91
- await drain;
92
- await plugin.close();
93
-
94
- const delegatePackets = packets.filter(({ packet }) =>
95
- String((packet as { kind?: string }).kind).startsWith("delegate."),
96
- );
97
- expect(delegatePackets.map(({ route }) => route)).toEqual([Route.Background, Route.Background]);
98
- expect(delegatePackets[0]!.packet).toMatchObject({
99
- kind: "delegate.query",
100
- contextId: "turn-1",
101
- query: "When is the deadline?",
102
- });
103
- expect((delegatePackets[0]!.packet as { toolName?: string }).toolName).toBeUndefined();
104
- expect(delegatePackets[1]!.packet).toMatchObject({
105
- kind: "delegate.result",
106
- contextId: "turn-1",
107
- query: "When is the deadline?",
108
- answer: "The deadline is March 31.",
109
- grounded: true,
110
- });
111
- expect((delegatePackets[1]!.packet as { durationMs: number }).durationMs).toBeGreaterThanOrEqual(0);
112
- // delegate.query precedes the reasoner's first output.
113
- const queryIndex = packets.findIndex(({ packet }) => (packet as { kind?: string }).kind === "delegate.query");
114
- const firstDeltaIndex = packets.findIndex(({ packet }) => (packet as { kind?: string }).kind === "llm.delta");
115
- expect(queryIndex).toBeGreaterThanOrEqual(0);
116
- expect(queryIndex).toBeLessThan(firstDeltaIndex);
117
- });
118
-
119
- it("G2/WBS-1: cascade delegate.result grounded=false without tool use; none on error", async () => {
120
- const packets: Array<{ route: Route; packet: unknown }> = [];
121
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
122
- yield textDelta("From memory.");
123
- yield finish("stop");
124
- }));
125
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
126
- const drain = bus.start();
127
-
128
- await plugin.initialize(bus, baseConfig());
129
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
130
-
131
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "delegate.result"));
132
- bus.stop();
133
- await drain;
134
- await plugin.close();
135
-
136
- const result = packets.find(({ packet }) => (packet as { kind?: string }).kind === "delegate.result")!;
137
- expect(result.packet).toMatchObject({ grounded: false, answer: "From memory." });
138
- });
139
-
140
- it("accepts the truncated reply on token-limit finish (fails the turn, never the call)", async () => {
141
- // A `length` finish means the model hit the token cap: the streamed reply is
142
- // truncated but usable. It must be spoken and the call kept up (L2) — never
143
- // escalated to a session-killing llm.error.
144
- const packets: Array<{ route: Route; packet: unknown }> = [];
145
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
146
- yield textDelta("This answer is incomplete");
147
- yield finish("length", "MAX_TOKENS");
148
- }));
149
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
150
- const drain = bus.start();
151
-
152
- await plugin.initialize(bus, baseConfig());
153
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
154
-
155
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
156
- bus.stop();
157
- await drain;
158
- await plugin.close();
159
-
160
- // The partial reply is committed as a normal turn completion.
161
- expect(packets).toContainEqual({
162
- route: Route.Main,
163
- packet: expect.objectContaining({ kind: "llm.done", contextId: "turn-1", text: "This answer is incomplete" }),
164
- });
165
- // No session-killing error was emitted.
166
- expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error")).toBe(false);
167
- // The truncation is observable for telemetry.
168
- expect(packets.some(({ packet }) => (packet as { kind?: string; name?: string }).name === "llm.finish_length_truncated")).toBe(true);
169
- });
170
-
171
- it("fails the turn recoverably (not the call) on an unfinished tool-loop finish", async () => {
172
- const packets: Array<{ route: Route; packet: unknown }> = [];
173
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
174
- yield textDelta("partial");
175
- yield finish("tool-calls");
176
- }));
177
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
178
- const drain = bus.start();
179
-
180
- await plugin.initialize(bus, baseConfig());
181
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
182
-
183
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error"));
184
- bus.stop();
185
- await drain;
186
- await plugin.close();
187
-
188
- expect(packets).toContainEqual({
189
- route: Route.Critical,
190
- packet: expect.objectContaining({
191
- kind: "llm.error",
192
- contextId: "turn-1",
193
- isRecoverable: true, // recoverable → fallback spoken, session stays open
194
- } satisfies Partial<LlmErrorPacket>),
195
- });
196
- expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
197
- });
198
-
199
- it("emits llm.error when the stream ends without finish metadata", async () => {
200
- const packets: Array<{ route: Route; packet: unknown }> = [];
201
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
202
- yield textDelta("Hello.");
203
- }));
204
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
205
- const drain = bus.start();
206
-
207
- await plugin.initialize(bus, baseConfig());
208
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
209
-
210
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error"));
211
- bus.stop();
212
- await drain;
213
- await plugin.close();
214
-
215
- expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
216
- expect(packets).toContainEqual({
217
- route: Route.Critical,
218
- packet: expect.objectContaining({
219
- kind: "llm.error",
220
- contextId: "turn-1",
221
- } satisfies Partial<LlmErrorPacket>),
222
- });
223
- });
224
-
225
- it("clears per-turn state when a generation errors before commit", async () => {
226
- const packets: Array<{ route: Route; packet: unknown }> = [];
227
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
228
- throw new Error("provider failed");
229
- }));
230
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
231
- const drain = bus.start();
232
-
233
- await plugin.initialize(bus, baseConfig());
234
- bus.push(Route.Main, turnComplete("turn-error-cleanup", "Hi"));
235
-
236
- await waitFor(() => hasPacket(packets, "llm.error", "turn-error-cleanup"));
237
-
238
- const internals = plugin as unknown as {
239
- spokenByContext: Map<string, unknown>;
240
- turnUserText: Map<string, unknown>;
241
- assistantMsgByContext: Map<string, unknown>;
242
- wordTimestampsByContext: Map<string, unknown>;
243
- playedOutMsByContext: Map<string, unknown>;
244
- };
245
- expect(internals.spokenByContext.has("turn-error-cleanup")).toBe(false);
246
- expect(internals.turnUserText.has("turn-error-cleanup")).toBe(false);
247
- expect(internals.assistantMsgByContext.has("turn-error-cleanup")).toBe(false);
248
- expect(internals.wordTimestampsByContext.has("turn-error-cleanup")).toBe(false);
249
- expect(internals.playedOutMsByContext.has("turn-error-cleanup")).toBe(false);
250
-
251
- bus.stop();
252
- await drain;
253
- await plugin.close();
254
- });
255
-
256
- it("rewrites an interrupted turn's history to the spoken prefix on barge-in during playback", async () => {
257
- const packets: Array<{ route: Route; packet: unknown }> = [];
258
- const capturedMessages: ModelMessage[][] = [];
259
- const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
260
- capturedMessages.push(messages);
261
- if (capturedMessages.length === 1) {
262
- yield textDelta("Sentence one. Sentence two.");
263
- yield finish("stop");
264
- return;
265
- }
266
- yield textDelta("ok.");
267
- yield finish("stop");
268
- }));
269
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
270
- const drain = bus.start();
271
- await plugin.initialize(bus, baseConfig());
272
-
273
- // Turn 1 generates fully and is committed to history (full text).
274
- bus.push(Route.Main, turnComplete("turn-1", "first question"));
275
- await waitFor(() => hasPacket(packets, "llm.done", "turn-1"));
276
-
277
- // Only the first sentence reached TTS before the user barged in.
278
- bus.push(Route.Main, ttsText("turn-1", "Sentence one."));
279
- await new Promise((resolve) => setTimeout(resolve, 10)); // tts.text dispatched before the Critical interrupt
280
- bus.push(Route.Critical, interruptLlm("turn-1"));
281
- await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
282
-
283
- bus.push(Route.Main, turnComplete("turn-2", "second question"));
284
- await waitFor(() => hasPacket(packets, "llm.done", "turn-2"));
285
-
286
- bus.stop();
287
- await drain;
288
- await plugin.close();
289
-
290
- expect(capturedMessages[1]).toEqual([
291
- { role: "user", content: "first question" },
292
- { role: "assistant", content: "Sentence one." },
293
- { role: "user", content: "second question" },
294
- ]);
295
- });
296
-
297
- // G25 / VE-04: word-level precision tests
298
- it("uses word timestamps + playout position to compute exact spoken prefix at word boundaries", async () => {
299
- // Deadlock regression scenario (G2 prior revert): full generation committed to
300
- // history, then user barges in during playback. The spoken prefix must be
301
- // exactly the words whose endMs falls before the playout cutoff.
302
- const packets: Array<{ route: Route; packet: unknown }> = [];
303
- const capturedMessages: ModelMessage[][] = [];
304
- const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
305
- capturedMessages.push(messages);
306
- if (capturedMessages.length === 1) {
307
- yield textDelta("Hello world foo bar.");
308
- yield finish("stop");
309
- return;
310
- }
311
- yield textDelta("ok.");
312
- yield finish("stop");
313
- }));
314
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
315
- const drain = bus.start();
316
- await plugin.initialize(bus, baseConfig());
317
-
318
- // Turn 1 generates fully and commits to history.
319
- bus.push(Route.Main, turnComplete("turn-word", "first question"));
320
- await waitFor(() => hasPacket(packets, "llm.done", "turn-word"));
321
-
322
- // Word timestamps for the generated text (cumulative from context start).
323
- // Playout was at 450ms when the user barged in — only "Hello world" was heard.
324
- bus.push(Route.Main, wordTimestamps("turn-word", [
325
- { word: "Hello", startMs: 0, endMs: 200 },
326
- { word: "world", startMs: 220, endMs: 400 },
327
- { word: "foo", startMs: 420, endMs: 600 },
328
- { word: "bar.", startMs: 620, endMs: 800 },
329
- ]));
330
- bus.push(Route.Main, playoutProgress("turn-word", 450, false));
331
- await new Promise((resolve) => setTimeout(resolve, 20));
332
-
333
- // Barge-in during playback (the previously-deadlocking scenario, now non-blocking).
334
- bus.push(Route.Critical, interruptLlm("turn-word"));
335
- await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
336
-
337
- bus.push(Route.Main, turnComplete("turn-word-2", "second question"));
338
- await waitFor(() => hasPacket(packets, "llm.done", "turn-word-2"));
339
-
340
- bus.stop();
341
- await drain;
342
- await plugin.close();
343
-
344
- // History must contain ONLY words heard (endMs <= 450ms), not the full text.
345
- expect(capturedMessages[1]).toEqual([
346
- { role: "user", content: "first question" },
347
- { role: "assistant", content: "Hello world" },
348
- { role: "user", content: "second question" },
349
- ]);
350
- });
351
-
352
- it("falls back to text-sent-to-TTS when no word timestamps are available", async () => {
353
- const packets: Array<{ route: Route; packet: unknown }> = [];
354
- const capturedMessages: ModelMessage[][] = [];
355
- const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
356
- capturedMessages.push(messages);
357
- if (capturedMessages.length === 1) {
358
- yield textDelta("Sentence one. Sentence two.");
359
- yield finish("stop");
360
- return;
361
- }
362
- yield textDelta("ok.");
363
- yield finish("stop");
364
- }));
365
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
366
- const drain = bus.start();
367
- await plugin.initialize(bus, baseConfig());
368
-
369
- bus.push(Route.Main, turnComplete("turn-fallback", "first question"));
370
- await waitFor(() => hasPacket(packets, "llm.done", "turn-fallback"));
371
-
372
- // Only the first sentence reached TTS (no word timestamps — fallback path).
373
- bus.push(Route.Main, ttsText("turn-fallback", "Sentence one."));
374
- await new Promise((resolve) => setTimeout(resolve, 10));
375
- bus.push(Route.Critical, interruptLlm("turn-fallback"));
376
- await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
377
-
378
- bus.push(Route.Main, turnComplete("turn-fallback-2", "second question"));
379
- await waitFor(() => hasPacket(packets, "llm.done", "turn-fallback-2"));
380
-
381
- bus.stop();
382
- await drain;
383
- await plugin.close();
384
-
385
- // Without word timestamps, history is the full tts.text sent before interrupt.
386
- expect(capturedMessages[1]).toEqual([
387
- { role: "user", content: "first question" },
388
- { role: "assistant", content: "Sentence one." },
389
- { role: "user", content: "second question" },
390
- ]);
391
- });
392
-
393
- it("falls back to text-sent-to-TTS when playout position is unavailable (headless/browser path)", async () => {
394
- const packets: Array<{ route: Route; packet: unknown }> = [];
395
- const capturedMessages: ModelMessage[][] = [];
396
- const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
397
- capturedMessages.push(messages);
398
- if (capturedMessages.length === 1) {
399
- yield textDelta("Hello world foo.");
400
- yield finish("stop");
401
- return;
402
- }
403
- yield textDelta("ok.");
404
- yield finish("stop");
405
- }));
406
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
407
- const drain = bus.start();
408
- await plugin.initialize(bus, baseConfig());
409
-
410
- bus.push(Route.Main, turnComplete("turn-noplayout", "first question"));
411
- await waitFor(() => hasPacket(packets, "llm.done", "turn-noplayout"));
412
-
413
- // Word timestamps present but NO tts.playout_progress → falls back to spokenByContext.
414
- bus.push(Route.Main, ttsText("turn-noplayout", "Hello world foo."));
415
- bus.push(Route.Main, wordTimestamps("turn-noplayout", [
416
- { word: "Hello", startMs: 0, endMs: 200 },
417
- { word: "world", startMs: 220, endMs: 400 },
418
- { word: "foo.", startMs: 420, endMs: 600 },
419
- ]));
420
- await new Promise((resolve) => setTimeout(resolve, 20));
421
- bus.push(Route.Critical, interruptLlm("turn-noplayout"));
422
- await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
423
-
424
- bus.push(Route.Main, turnComplete("turn-noplayout-2", "second question"));
425
- await waitFor(() => hasPacket(packets, "llm.done", "turn-noplayout-2"));
426
-
427
- bus.stop();
428
- await drain;
429
- await plugin.close();
430
-
431
- // Falls back to the full tts.text sent (no playout position to cut it).
432
- expect(capturedMessages[1]).toEqual([
433
- { role: "user", content: "first question" },
434
- { role: "assistant", content: "Hello world foo." },
435
- { role: "user", content: "second question" },
436
- ]);
437
- });
438
-
439
- it("records an interrupted mid-generation turn as the spoken prefix instead of dropping it", async () => {
440
- const packets: Array<{ route: Route; packet: unknown }> = [];
441
- const capturedMessages: ModelMessage[][] = [];
442
- const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ signal, messages }) {
443
- capturedMessages.push(messages);
444
- if (capturedMessages.length === 1) {
445
- yield textDelta("Hello");
446
- await new Promise<void>((resolve) => {
447
- if (signal.aborted) {
448
- resolve();
449
- return;
450
- }
451
- signal.addEventListener("abort", () => resolve(), { once: true });
452
- });
453
- return;
454
- }
455
- yield textDelta("ok.");
456
- yield finish("stop");
457
- }));
458
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
459
- const drain = bus.start();
460
- await plugin.initialize(bus, baseConfig());
461
-
462
- bus.push(Route.Main, turnComplete("turn-1", "first question"));
463
- await waitFor(() =>
464
- packets.some(
465
- ({ packet }) =>
466
- (packet as { kind?: string }).kind === "llm.delta" &&
467
- (packet as { text?: string }).text === "Hello",
468
- ),
469
- );
470
-
471
- // The session spoke "Hello", then the user barged in mid-generation (G10 makes
472
- // this interrupt land while generation is still streaming).
473
- bus.push(Route.Main, ttsText("turn-1", "Hello"));
474
- await new Promise((resolve) => setTimeout(resolve, 10));
475
- bus.push(Route.Critical, interruptLlm("turn-1"));
476
- await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
477
-
478
- bus.push(Route.Main, turnComplete("turn-2", "second question"));
479
- await waitFor(() => hasPacket(packets, "llm.done", "turn-2"));
480
-
481
- bus.stop();
482
- await drain;
483
- await plugin.close();
484
-
485
- expect(capturedMessages[1]).toEqual([
486
- { role: "user", content: "first question" },
487
- { role: "assistant", content: "Hello" },
488
- { role: "user", content: "second question" },
489
- ]);
490
- });
491
- });
492
-
493
- describe("ReasoningBridge durable session (G4/WBS-4)", () => {
494
- it("re-seeds context from the session store after a simulated eviction; no double-answer", async () => {
495
- const store = new InMemoryReasonerSessionStore();
496
-
497
- // First lifetime: one committed turn, then the host is evicted (bridge closed).
498
- const first = new ReasoningBridge(
499
- fromStreamFactory(async function* () {
500
- yield textDelta("Answer one.");
501
- yield finish("stop");
502
- }),
503
- { sessionStore: store, sessionId: "s1" },
504
- );
505
- const firstPackets: Array<{ packet: unknown }> = [];
506
- const firstBus = new PipelineBusImpl({ onPacket: (_route, packet) => firstPackets.push({ packet }) });
507
- const firstDrain = firstBus.start();
508
- await first.initialize(firstBus, baseConfig());
509
- firstBus.push(Route.Main, turnComplete("turn-1", "First question"));
510
- await waitFor(() => firstPackets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
511
- firstBus.stop();
512
- await firstDrain;
513
- await first.close();
514
-
515
- // Second lifetime: a fresh bridge over the same store must hand the reasoner
516
- // the prior turn as context — and must not re-answer it.
517
- const seenMessages: Array<ReasonerTurn["messages"]> = [];
518
- const secondReasoner: Reasoner = {
519
- stream: (turn) => {
520
- seenMessages.push([...turn.messages]);
521
- return (async function* (): AsyncGenerator<ReasoningPart> {
522
- yield { type: "text-delta", text: "Answer two." };
523
- yield { type: "finish", reason: "stop", text: "Answer two." };
524
- })();
525
- },
526
- };
527
- const second = new ReasoningBridge(secondReasoner, { sessionStore: store, sessionId: "s1" });
528
- const packets: Array<{ packet: unknown }> = [];
529
- const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
530
- const drain = bus.start();
531
- await second.initialize(bus, baseConfig());
532
- // Nothing speaks spontaneously on resume (no double-answer).
533
- expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
534
-
535
- bus.push(Route.Main, turnComplete("turn-2", "Second question"));
536
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
537
- bus.stop();
538
- await drain;
539
- await second.close();
540
-
541
- expect(seenMessages[0]).toEqual([
542
- { role: "user", content: "First question" },
543
- { role: "assistant", content: "Answer one." },
544
- ]);
545
- // The store now carries both turns for the next resume.
546
- expect(store.load("s1")).toEqual([
547
- { role: "user", content: "First question" },
548
- { role: "assistant", content: "Answer one." },
549
- { role: "user", content: "Second question" },
550
- { role: "assistant", content: "Answer two." },
551
- ]);
552
- });
553
-
554
- it("persists the interrupted turn's history as the heard prefix", async () => {
555
- const store = new InMemoryReasonerSessionStore();
556
- const plugin = new ReasoningBridge(
557
- fromStreamFactory(async function* () {
558
- yield textDelta("Full generated reply that was cut off.");
559
- yield finish("stop");
560
- }),
561
- { sessionStore: store, sessionId: "s1" },
562
- );
563
- const packets: Array<{ packet: unknown }> = [];
564
- const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
565
- const drain = bus.start();
566
- await plugin.initialize(bus, baseConfig());
567
-
568
- bus.push(Route.Main, turnComplete("turn-1", "Hi"));
569
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
570
- // What actually reached TTS before the barge-in.
571
- bus.push(Route.Main, {
572
- kind: "tts.text",
573
- contextId: "turn-1",
574
- timestampMs: Date.now(),
575
- text: "Full generated",
576
- } satisfies TextToSpeechTextPacket);
577
- await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "tts.text"));
578
- bus.push(Route.Critical, {
579
- kind: "interrupt.llm",
580
- contextId: "turn-1",
581
- timestampMs: Date.now(),
582
- } satisfies InterruptLlmPacket);
583
- await waitFor(() =>
584
- packets.some(({ packet }) => (packet as { name?: string }).name === "llm.history_truncated_to_spoken"),
585
- );
586
- bus.stop();
587
- await drain;
588
- await plugin.close();
589
-
590
- expect(store.load("s1")).toEqual([
591
- { role: "user", content: "Hi" },
592
- { role: "assistant", content: "Full generated" },
593
- ]);
594
- });
595
- });
596
-
597
- describe("ReasoningBridge suspend/resume", () => {
598
- it("clean suspend → resume: saves pointer, resumes with userText, discards on finish", async () => {
599
- const packets: Array<{ route: Route; packet: unknown }> = [];
600
- const runStore = new FakeRunStore();
601
- const { reasoner, capturedTurns } = createSuspendResumeReasoner();
602
- const plugin = new ReasoningBridge(reasoner, { runStore });
603
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
604
- const drain = bus.start();
605
- await plugin.initialize(bus, baseConfig());
606
-
607
- bus.push(Route.Main, turnComplete("ctx", "first question"));
608
- await waitFor(() => hasPacket(packets, "llm.done", "ctx"));
609
-
610
- expect(packets).toContainEqual({
611
- route: Route.Main,
612
- packet: expect.objectContaining({
613
- kind: "llm.done",
614
- contextId: "ctx",
615
- text: "Approve?",
616
- } satisfies Partial<LlmResponseDonePacket>),
617
- });
618
- expect(packets).toContainEqual({
619
- route: Route.Background,
620
- packet: expect.objectContaining({
621
- kind: "reasoning.suspended",
622
- contextId: "ctx",
623
- runId: "r1",
624
- prompt: "Approve?",
625
- payload: { step: 1 },
626
- } satisfies Partial<ReasoningSuspendedPacket>),
627
- });
628
- expect(runStore.saveCalls).toEqual([["ctx", "r1"]]);
629
-
630
- bus.push(Route.Main, turnComplete("ctx", "yes"));
631
- await waitFor(() => packets.filter(({ packet }) => (packet as { kind?: string }).kind === "llm.done").length >= 2);
632
-
633
- expect(capturedTurns[1]?.resume).toEqual({ runId: "r1", data: "yes" });
634
- expect(runStore.discardCalls).toEqual(["ctx"]);
635
-
636
- bus.stop();
637
- await drain;
638
- await plugin.close();
639
- });
640
-
641
- it("suspend → barge-in → next turn restarts without resume", async () => {
642
- const packets: Array<{ route: Route; packet: unknown }> = [];
643
- const runStore = new FakeRunStore();
644
- const { reasoner, capturedTurns } = createSuspendResumeReasoner();
645
- const plugin = new ReasoningBridge(reasoner, { runStore });
646
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
647
- const drain = bus.start();
648
- await plugin.initialize(bus, baseConfig());
649
-
650
- bus.push(Route.Main, turnComplete("ctx", "first question"));
651
- await waitFor(() => hasPacket(packets, "reasoning.suspended", "ctx"));
652
- expect(runStore.saveCalls).toEqual([["ctx", "r1"]]);
653
-
654
- bus.push(Route.Critical, interruptLlm("ctx"));
655
- await waitFor(() => runStore.discardCalls.includes("ctx"));
656
-
657
- bus.push(Route.Main, turnComplete("ctx", "corrected answer"));
658
- await waitFor(() => capturedTurns.length >= 2);
659
-
660
- expect(capturedTurns[1]?.resume).toBeUndefined();
661
-
662
- bus.stop();
663
- await drain;
664
- await plugin.close();
665
- });
666
-
667
- it("barge-in discards a pending run pointer", async () => {
668
- const runStore = new FakeRunStore();
669
- runStore.save("ctx", "r1");
670
- const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
671
- yield textDelta("ok.");
672
- yield finish("stop");
673
- }), { runStore });
674
- const bus = new PipelineBusImpl({ onPacket: () => undefined });
675
- const drain = bus.start();
676
- await plugin.initialize(bus, baseConfig());
677
-
678
- bus.push(Route.Critical, interruptLlm("ctx"));
679
- await waitFor(() => runStore.discardCalls.includes("ctx"));
680
- expect(runStore.takePending("ctx")).toBeNull();
681
-
682
- bus.stop();
683
- await drain;
684
- await plugin.close();
685
- });
686
-
687
- it("without runStore, suspended still emits reasoning.suspended without persistence", async () => {
688
- const packets: Array<{ route: Route; packet: unknown }> = [];
689
- const { reasoner } = createSuspendResumeReasoner();
690
- const plugin = new ReasoningBridge(reasoner);
691
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
692
- const drain = bus.start();
693
- await plugin.initialize(bus, baseConfig());
694
-
695
- bus.push(Route.Main, turnComplete("ctx", "question"));
696
- await waitFor(() => hasPacket(packets, "reasoning.suspended", "ctx"));
697
-
698
- expect(packets).toContainEqual({
699
- route: Route.Background,
700
- packet: expect.objectContaining({
701
- kind: "reasoning.suspended",
702
- contextId: "ctx",
703
- runId: "r1",
704
- } satisfies Partial<ReasoningSuspendedPacket>),
705
- });
706
-
707
- bus.stop();
708
- await drain;
709
- await plugin.close();
710
- });
711
-
712
- it("throws when onResumeConflict is replay", () => {
713
- expect(
714
- () => new ReasoningBridge(fromStreamFactory(async function* () {}), { onResumeConflict: "replay" }),
715
- ).toThrow("onResumeConflict 'replay' not yet supported — use 'restart'");
716
- });
717
- });
718
-
719
- class FakeRunStore implements RunStore {
720
- private pointers = new Map<string, string>();
721
- saveCalls: Array<[string, string]> = [];
722
- discardCalls: string[] = [];
723
- takePendingCalls: string[] = [];
724
-
725
- save(contextId: string, runId: string): void {
726
- this.saveCalls.push([contextId, runId]);
727
- this.pointers.set(contextId, runId);
728
- }
729
-
730
- takePending(contextId: string): RunPointer | null {
731
- this.takePendingCalls.push(contextId);
732
- const runId = this.pointers.get(contextId);
733
- return runId ? { runId } : null;
734
- }
735
-
736
- discard(contextId: string): void {
737
- this.discardCalls.push(contextId);
738
- this.pointers.delete(contextId);
739
- }
740
- }
741
-
742
- function createSuspendResumeReasoner(): {
743
- reasoner: Reasoner;
744
- capturedTurns: ReasonerTurn[];
745
- } {
746
- const capturedTurns: ReasonerTurn[] = [];
747
- const reasoner: Reasoner = {
748
- stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
749
- capturedTurns.push(turn);
750
- return (async function* () {
751
- if (turn.resume) {
752
- yield { type: "text-delta", text: "Resumed." };
753
- yield { type: "finish", reason: "stop", text: "Resumed." };
754
- return;
755
- }
756
- yield {
757
- type: "suspended",
758
- runId: "r1",
759
- prompt: "Approve?",
760
- payload: { step: 1 },
761
- };
762
- })();
763
- },
764
- };
765
- return { reasoner, capturedTurns };
766
- }
767
-
768
- function hasPacket(packets: Array<{ packet: unknown }>, kind: string, contextId: string): boolean {
769
- return packets.some(
770
- ({ packet }) =>
771
- (packet as { kind?: string }).kind === kind &&
772
- (packet as { contextId?: string }).contextId === contextId,
773
- );
774
- }
775
-
776
- function hasMetric(packets: Array<{ packet: unknown }>, name: string): boolean {
777
- return packets.some(({ packet }) => (packet as { name?: string }).name === name);
778
- }
779
-
780
- function ttsText(contextId: string, text: string): TextToSpeechTextPacket {
781
- return { kind: "tts.text", contextId, timestampMs: Date.now(), text };
782
- }
783
-
784
- function wordTimestamps(contextId: string, words: TtsWordTimestamp[]): TextToSpeechWordTimestampsPacket {
785
- return { kind: "tts.word_timestamps", contextId, timestampMs: Date.now(), words };
786
- }
787
-
788
- function playoutProgress(contextId: string, playedOutMs: number, complete: boolean): TextToSpeechPlayoutProgressPacket {
789
- return { kind: "tts.playout_progress", contextId, timestampMs: Date.now(), playedOutMs, complete };
790
- }
791
-
792
- function interruptLlm(contextId: string): InterruptLlmPacket {
793
- return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
794
- }
795
-
796
- function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
797
- return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
798
- }
799
-
800
- function eosRetracted(contextId: string): { kind: "eos.retracted"; contextId: string; timestampMs: number } {
801
- return { kind: "eos.retracted", contextId, timestampMs: Date.now() };
802
- }
803
-
804
- describe("ReasoningBridge speculative generation", () => {
805
- function kinds(packets: Array<{ packet: unknown }>): string[] {
806
- return packets.map(({ packet }) => (packet as { kind: string }).kind);
807
- }
808
-
809
- it("buffers a draft on eos.interim and flushes it on a matching eos.turn_complete (one generation)", async () => {
810
- const packets: Array<{ route: Route; packet: unknown }> = [];
811
- let streams = 0;
812
- const plugin = new ReasoningBridge(
813
- fromStreamFactory(async function* () {
814
- streams += 1;
815
- yield textDelta("The fee is ten dollars.");
816
- yield finish("stop");
817
- }),
818
- { speculative: true },
819
- );
820
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
821
- const drain = bus.start();
822
- await plugin.initialize(bus, baseConfig());
823
-
824
- bus.push(Route.Main, eosInterim("turn-1", "what are the lab fees"));
825
- // Give the draft time to stream fully — nothing may reach the bus yet.
826
- await new Promise((resolve) => setTimeout(resolve, 50));
827
- expect(kinds(packets)).not.toContain("llm.delta");
828
- expect(kinds(packets)).not.toContain("llm.done");
829
- expect(kinds(packets)).not.toContain("delegate.query");
830
-
831
- bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
832
- await waitFor(() => kinds(packets).includes("llm.done"));
833
- bus.stop();
834
- await drain;
835
- await plugin.close();
836
-
837
- expect(streams).toBe(1); // the draft WAS the generation — no second LLM call
838
- expect(packets).toContainEqual({
839
- route: Route.Main,
840
- packet: expect.objectContaining({ kind: "llm.delta", contextId: "turn-1", text: "The fee is ten dollars." }),
841
- });
842
- expect(packets).toContainEqual({
843
- route: Route.Background,
844
- packet: expect.objectContaining({ kind: "delegate.result", contextId: "turn-1", answer: "The fee is ten dollars." }),
845
- });
846
- });
847
-
848
- it("discards the draft on eos.retracted — nothing is ever pushed for it", async () => {
849
- const packets: Array<{ route: Route; packet: unknown }> = [];
850
- let streams = 0;
851
- const plugin = new ReasoningBridge(
852
- fromStreamFactory(async function* () {
853
- streams += 1;
854
- yield textDelta("Draft answer.");
855
- yield finish("stop");
856
- }),
857
- { speculative: true },
858
- );
859
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
860
- const drain = bus.start();
861
- await plugin.initialize(bus, baseConfig());
862
-
863
- bus.push(Route.Main, eosInterim("turn-1", "book a"));
864
- await new Promise((resolve) => setTimeout(resolve, 30));
865
- bus.push(Route.Main, eosRetracted("turn-1"));
866
- await new Promise((resolve) => setTimeout(resolve, 30));
867
-
868
- // The user finishes the real utterance later; a fresh generation answers it.
869
- bus.push(Route.Main, turnComplete("turn-1", "book a room for tomorrow"));
870
- await waitFor(() => kinds(packets).includes("llm.done"));
871
- bus.stop();
872
- await drain;
873
- await plugin.close();
874
-
875
- expect(streams).toBe(2); // draft + fresh confirmed run
876
- const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
877
- expect(deltas).toHaveLength(1); // the discarded draft's delta never surfaced
878
- });
879
-
880
- it("regenerates when the confirmed transcript differs from the draft's", async () => {
881
- const packets: Array<{ route: Route; packet: unknown }> = [];
882
- const seenTexts: string[] = [];
883
- const plugin = new ReasoningBridge(
884
- fromStreamFactory(async function* (request: { userText: string }) {
885
- seenTexts.push(request.userText);
886
- yield textDelta(`Answer to: ${request.userText}`);
887
- yield finish("stop");
888
- }),
889
- { speculative: true },
890
- );
891
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
892
- const drain = bus.start();
893
- await plugin.initialize(bus, baseConfig());
894
-
895
- bus.push(Route.Main, eosInterim("turn-1", "what are the"));
896
- await new Promise((resolve) => setTimeout(resolve, 30));
897
- bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
898
- await waitFor(() => kinds(packets).includes("llm.done"));
899
- bus.stop();
900
- await drain;
901
- await plugin.close();
902
-
903
- expect(seenTexts).toEqual(["what are the", "what are the lab fees"]);
904
- expect(packets).toContainEqual({
905
- route: Route.Main,
906
- packet: expect.objectContaining({ kind: "llm.done", text: "Answer to: what are the lab fees" }),
907
- });
908
- const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
909
- expect(deltas).toHaveLength(1);
910
- });
911
-
912
- it("ignores eos.interim when speculative mode is off (default)", async () => {
913
- const packets: Array<{ route: Route; packet: unknown }> = [];
914
- let streams = 0;
915
- const plugin = new ReasoningBridge(
916
- fromStreamFactory(async function* () {
917
- streams += 1;
918
- yield textDelta("Hello.");
919
- yield finish("stop");
920
- }),
921
- );
922
- const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
923
- const drain = bus.start();
924
- await plugin.initialize(bus, baseConfig());
925
-
926
- bus.push(Route.Main, eosInterim("turn-1", "hi"));
927
- await new Promise((resolve) => setTimeout(resolve, 30));
928
- bus.stop();
929
- await drain;
930
- await plugin.close();
931
-
932
- expect(streams).toBe(0);
933
- expect(kinds(packets).filter((k) => k.startsWith("llm."))).toHaveLength(0);
934
- });
935
- });
936
-
937
- function baseConfig(): Record<string, unknown> {
938
- return {
939
- api_key: "test-key",
940
- model: "gpt-test",
941
- system_prompt: "test",
942
- retry_max_attempts: 1,
943
- timeout_ms: 1000,
944
- };
945
- }
946
-
947
- function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
948
- return {
949
- kind: "eos.turn_complete",
950
- contextId,
951
- timestampMs: Date.now(),
952
- text,
953
- transcripts: [],
954
- };
955
- }
956
-
957
- function textDelta(text: string): TextStreamPart<ToolSet> {
958
- return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
959
- }
960
-
961
- function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
962
- return { type: "tool-call", toolCallId, toolName, input } as TextStreamPart<ToolSet>;
963
- }
964
-
965
- function toolResult(toolCallId: string, toolName: string, output: unknown): TextStreamPart<ToolSet> {
966
- return { type: "tool-result", toolCallId, toolName, input: {}, output } as TextStreamPart<ToolSet>;
967
- }
968
-
969
- function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
970
- return {
971
- type: "finish",
972
- finishReason,
973
- rawFinishReason,
974
- totalUsage: ZERO_USAGE,
975
- usage: ZERO_USAGE,
976
- providerMetadata: undefined,
977
- response: {},
978
- } as TextStreamPart<ToolSet>;
979
- }
980
-
981
- async function waitFor(predicate: () => boolean): Promise<void> {
982
- const started = Date.now();
983
- while (!predicate()) {
984
- if (Date.now() - started > 1000) {
985
- throw new Error("Timed out waiting for packet");
986
- }
987
- await new Promise((resolve) => setTimeout(resolve, 10));
988
- }
989
- }