@kuralle-syrinx/aisdk 2.1.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/LICENSE +22 -0
- package/README.md +53 -0
- package/package.json +23 -0
- package/src/from-ai-sdk.test.ts +276 -0
- package/src/from-ai-sdk.ts +226 -0
- package/src/index.test.ts +640 -0
- package/src/index.ts +480 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { 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("emits llm.error instead of llm.done when provider reaches token limit", async () => {
|
|
76
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
77
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
|
|
78
|
+
yield textDelta("This answer is incomplete");
|
|
79
|
+
yield finish("length", "MAX_TOKENS");
|
|
80
|
+
}));
|
|
81
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
82
|
+
const drain = bus.start();
|
|
83
|
+
|
|
84
|
+
await plugin.initialize(bus, baseConfig());
|
|
85
|
+
bus.push(Route.Main, turnComplete("turn-1", "Hi"));
|
|
86
|
+
|
|
87
|
+
await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error"));
|
|
88
|
+
bus.stop();
|
|
89
|
+
await drain;
|
|
90
|
+
await plugin.close();
|
|
91
|
+
|
|
92
|
+
expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
|
|
93
|
+
expect(packets).toContainEqual({
|
|
94
|
+
route: Route.Critical,
|
|
95
|
+
packet: expect.objectContaining({
|
|
96
|
+
kind: "llm.error",
|
|
97
|
+
contextId: "turn-1",
|
|
98
|
+
isRecoverable: false,
|
|
99
|
+
} satisfies Partial<LlmErrorPacket>),
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("emits llm.error when the stream ends without finish metadata", async () => {
|
|
104
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
105
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
|
|
106
|
+
yield textDelta("Hello.");
|
|
107
|
+
}));
|
|
108
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
109
|
+
const drain = bus.start();
|
|
110
|
+
|
|
111
|
+
await plugin.initialize(bus, baseConfig());
|
|
112
|
+
bus.push(Route.Main, turnComplete("turn-1", "Hi"));
|
|
113
|
+
|
|
114
|
+
await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error"));
|
|
115
|
+
bus.stop();
|
|
116
|
+
await drain;
|
|
117
|
+
await plugin.close();
|
|
118
|
+
|
|
119
|
+
expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
|
|
120
|
+
expect(packets).toContainEqual({
|
|
121
|
+
route: Route.Critical,
|
|
122
|
+
packet: expect.objectContaining({
|
|
123
|
+
kind: "llm.error",
|
|
124
|
+
contextId: "turn-1",
|
|
125
|
+
} satisfies Partial<LlmErrorPacket>),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("clears per-turn state when a generation errors before commit", async () => {
|
|
130
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
131
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
|
|
132
|
+
throw new Error("provider failed");
|
|
133
|
+
}));
|
|
134
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
135
|
+
const drain = bus.start();
|
|
136
|
+
|
|
137
|
+
await plugin.initialize(bus, baseConfig());
|
|
138
|
+
bus.push(Route.Main, turnComplete("turn-error-cleanup", "Hi"));
|
|
139
|
+
|
|
140
|
+
await waitFor(() => hasPacket(packets, "llm.error", "turn-error-cleanup"));
|
|
141
|
+
|
|
142
|
+
const internals = plugin as unknown as {
|
|
143
|
+
spokenByContext: Map<string, unknown>;
|
|
144
|
+
turnUserText: Map<string, unknown>;
|
|
145
|
+
assistantMsgByContext: Map<string, unknown>;
|
|
146
|
+
wordTimestampsByContext: Map<string, unknown>;
|
|
147
|
+
playedOutMsByContext: Map<string, unknown>;
|
|
148
|
+
};
|
|
149
|
+
expect(internals.spokenByContext.has("turn-error-cleanup")).toBe(false);
|
|
150
|
+
expect(internals.turnUserText.has("turn-error-cleanup")).toBe(false);
|
|
151
|
+
expect(internals.assistantMsgByContext.has("turn-error-cleanup")).toBe(false);
|
|
152
|
+
expect(internals.wordTimestampsByContext.has("turn-error-cleanup")).toBe(false);
|
|
153
|
+
expect(internals.playedOutMsByContext.has("turn-error-cleanup")).toBe(false);
|
|
154
|
+
|
|
155
|
+
bus.stop();
|
|
156
|
+
await drain;
|
|
157
|
+
await plugin.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rewrites an interrupted turn's history to the spoken prefix on barge-in during playback", async () => {
|
|
161
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
162
|
+
const capturedMessages: ModelMessage[][] = [];
|
|
163
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
|
|
164
|
+
capturedMessages.push(messages);
|
|
165
|
+
if (capturedMessages.length === 1) {
|
|
166
|
+
yield textDelta("Sentence one. Sentence two.");
|
|
167
|
+
yield finish("stop");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
yield textDelta("ok.");
|
|
171
|
+
yield finish("stop");
|
|
172
|
+
}));
|
|
173
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
174
|
+
const drain = bus.start();
|
|
175
|
+
await plugin.initialize(bus, baseConfig());
|
|
176
|
+
|
|
177
|
+
// Turn 1 generates fully and is committed to history (full text).
|
|
178
|
+
bus.push(Route.Main, turnComplete("turn-1", "first question"));
|
|
179
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-1"));
|
|
180
|
+
|
|
181
|
+
// Only the first sentence reached TTS before the user barged in.
|
|
182
|
+
bus.push(Route.Main, ttsText("turn-1", "Sentence one."));
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); // tts.text dispatched before the Critical interrupt
|
|
184
|
+
bus.push(Route.Critical, interruptLlm("turn-1"));
|
|
185
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
186
|
+
|
|
187
|
+
bus.push(Route.Main, turnComplete("turn-2", "second question"));
|
|
188
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-2"));
|
|
189
|
+
|
|
190
|
+
bus.stop();
|
|
191
|
+
await drain;
|
|
192
|
+
await plugin.close();
|
|
193
|
+
|
|
194
|
+
expect(capturedMessages[1]).toEqual([
|
|
195
|
+
{ role: "user", content: "first question" },
|
|
196
|
+
{ role: "assistant", content: "Sentence one." },
|
|
197
|
+
{ role: "user", content: "second question" },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// G25 / VE-04: word-level precision tests
|
|
202
|
+
it("uses word timestamps + playout position to compute exact spoken prefix at word boundaries", async () => {
|
|
203
|
+
// Deadlock regression scenario (G2 prior revert): full generation committed to
|
|
204
|
+
// history, then user barges in during playback. The spoken prefix must be
|
|
205
|
+
// exactly the words whose endMs falls before the playout cutoff.
|
|
206
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
207
|
+
const capturedMessages: ModelMessage[][] = [];
|
|
208
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
|
|
209
|
+
capturedMessages.push(messages);
|
|
210
|
+
if (capturedMessages.length === 1) {
|
|
211
|
+
yield textDelta("Hello world foo bar.");
|
|
212
|
+
yield finish("stop");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
yield textDelta("ok.");
|
|
216
|
+
yield finish("stop");
|
|
217
|
+
}));
|
|
218
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
219
|
+
const drain = bus.start();
|
|
220
|
+
await plugin.initialize(bus, baseConfig());
|
|
221
|
+
|
|
222
|
+
// Turn 1 generates fully and commits to history.
|
|
223
|
+
bus.push(Route.Main, turnComplete("turn-word", "first question"));
|
|
224
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-word"));
|
|
225
|
+
|
|
226
|
+
// Word timestamps for the generated text (cumulative from context start).
|
|
227
|
+
// Playout was at 450ms when the user barged in — only "Hello world" was heard.
|
|
228
|
+
bus.push(Route.Main, wordTimestamps("turn-word", [
|
|
229
|
+
{ word: "Hello", startMs: 0, endMs: 200 },
|
|
230
|
+
{ word: "world", startMs: 220, endMs: 400 },
|
|
231
|
+
{ word: "foo", startMs: 420, endMs: 600 },
|
|
232
|
+
{ word: "bar.", startMs: 620, endMs: 800 },
|
|
233
|
+
]));
|
|
234
|
+
bus.push(Route.Main, playoutProgress("turn-word", 450, false));
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
236
|
+
|
|
237
|
+
// Barge-in during playback (the previously-deadlocking scenario, now non-blocking).
|
|
238
|
+
bus.push(Route.Critical, interruptLlm("turn-word"));
|
|
239
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
240
|
+
|
|
241
|
+
bus.push(Route.Main, turnComplete("turn-word-2", "second question"));
|
|
242
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-word-2"));
|
|
243
|
+
|
|
244
|
+
bus.stop();
|
|
245
|
+
await drain;
|
|
246
|
+
await plugin.close();
|
|
247
|
+
|
|
248
|
+
// History must contain ONLY words heard (endMs <= 450ms), not the full text.
|
|
249
|
+
expect(capturedMessages[1]).toEqual([
|
|
250
|
+
{ role: "user", content: "first question" },
|
|
251
|
+
{ role: "assistant", content: "Hello world" },
|
|
252
|
+
{ role: "user", content: "second question" },
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("falls back to text-sent-to-TTS when no word timestamps are available", 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
|
+
bus.push(Route.Main, turnComplete("turn-fallback", "first question"));
|
|
274
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-fallback"));
|
|
275
|
+
|
|
276
|
+
// Only the first sentence reached TTS (no word timestamps — fallback path).
|
|
277
|
+
bus.push(Route.Main, ttsText("turn-fallback", "Sentence one."));
|
|
278
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
279
|
+
bus.push(Route.Critical, interruptLlm("turn-fallback"));
|
|
280
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
281
|
+
|
|
282
|
+
bus.push(Route.Main, turnComplete("turn-fallback-2", "second question"));
|
|
283
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-fallback-2"));
|
|
284
|
+
|
|
285
|
+
bus.stop();
|
|
286
|
+
await drain;
|
|
287
|
+
await plugin.close();
|
|
288
|
+
|
|
289
|
+
// Without word timestamps, history is the full tts.text sent before interrupt.
|
|
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
|
+
it("falls back to text-sent-to-TTS when playout position is unavailable (headless/browser path)", async () => {
|
|
298
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
299
|
+
const capturedMessages: ModelMessage[][] = [];
|
|
300
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ messages }) {
|
|
301
|
+
capturedMessages.push(messages);
|
|
302
|
+
if (capturedMessages.length === 1) {
|
|
303
|
+
yield textDelta("Hello world foo.");
|
|
304
|
+
yield finish("stop");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
yield textDelta("ok.");
|
|
308
|
+
yield finish("stop");
|
|
309
|
+
}));
|
|
310
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
311
|
+
const drain = bus.start();
|
|
312
|
+
await plugin.initialize(bus, baseConfig());
|
|
313
|
+
|
|
314
|
+
bus.push(Route.Main, turnComplete("turn-noplayout", "first question"));
|
|
315
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-noplayout"));
|
|
316
|
+
|
|
317
|
+
// Word timestamps present but NO tts.playout_progress → falls back to spokenByContext.
|
|
318
|
+
bus.push(Route.Main, ttsText("turn-noplayout", "Hello world foo."));
|
|
319
|
+
bus.push(Route.Main, wordTimestamps("turn-noplayout", [
|
|
320
|
+
{ word: "Hello", startMs: 0, endMs: 200 },
|
|
321
|
+
{ word: "world", startMs: 220, endMs: 400 },
|
|
322
|
+
{ word: "foo.", startMs: 420, endMs: 600 },
|
|
323
|
+
]));
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
325
|
+
bus.push(Route.Critical, interruptLlm("turn-noplayout"));
|
|
326
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
327
|
+
|
|
328
|
+
bus.push(Route.Main, turnComplete("turn-noplayout-2", "second question"));
|
|
329
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-noplayout-2"));
|
|
330
|
+
|
|
331
|
+
bus.stop();
|
|
332
|
+
await drain;
|
|
333
|
+
await plugin.close();
|
|
334
|
+
|
|
335
|
+
// Falls back to the full tts.text sent (no playout position to cut it).
|
|
336
|
+
expect(capturedMessages[1]).toEqual([
|
|
337
|
+
{ role: "user", content: "first question" },
|
|
338
|
+
{ role: "assistant", content: "Hello world foo." },
|
|
339
|
+
{ role: "user", content: "second question" },
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("records an interrupted mid-generation turn as the spoken prefix instead of dropping it", async () => {
|
|
344
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
345
|
+
const capturedMessages: ModelMessage[][] = [];
|
|
346
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* ({ signal, messages }) {
|
|
347
|
+
capturedMessages.push(messages);
|
|
348
|
+
if (capturedMessages.length === 1) {
|
|
349
|
+
yield textDelta("Hello");
|
|
350
|
+
await new Promise<void>((resolve) => {
|
|
351
|
+
if (signal.aborted) {
|
|
352
|
+
resolve();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
yield textDelta("ok.");
|
|
360
|
+
yield finish("stop");
|
|
361
|
+
}));
|
|
362
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
363
|
+
const drain = bus.start();
|
|
364
|
+
await plugin.initialize(bus, baseConfig());
|
|
365
|
+
|
|
366
|
+
bus.push(Route.Main, turnComplete("turn-1", "first question"));
|
|
367
|
+
await waitFor(() =>
|
|
368
|
+
packets.some(
|
|
369
|
+
({ packet }) =>
|
|
370
|
+
(packet as { kind?: string }).kind === "llm.delta" &&
|
|
371
|
+
(packet as { text?: string }).text === "Hello",
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// The session spoke "Hello", then the user barged in mid-generation (G10 makes
|
|
376
|
+
// this interrupt land while generation is still streaming).
|
|
377
|
+
bus.push(Route.Main, ttsText("turn-1", "Hello"));
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
379
|
+
bus.push(Route.Critical, interruptLlm("turn-1"));
|
|
380
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
381
|
+
|
|
382
|
+
bus.push(Route.Main, turnComplete("turn-2", "second question"));
|
|
383
|
+
await waitFor(() => hasPacket(packets, "llm.done", "turn-2"));
|
|
384
|
+
|
|
385
|
+
bus.stop();
|
|
386
|
+
await drain;
|
|
387
|
+
await plugin.close();
|
|
388
|
+
|
|
389
|
+
expect(capturedMessages[1]).toEqual([
|
|
390
|
+
{ role: "user", content: "first question" },
|
|
391
|
+
{ role: "assistant", content: "Hello" },
|
|
392
|
+
{ role: "user", content: "second question" },
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("ReasoningBridge suspend/resume", () => {
|
|
398
|
+
it("clean suspend → resume: saves pointer, resumes with userText, discards on finish", async () => {
|
|
399
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
400
|
+
const runStore = new FakeRunStore();
|
|
401
|
+
const { reasoner, capturedTurns } = createSuspendResumeReasoner();
|
|
402
|
+
const plugin = new ReasoningBridge(reasoner, { runStore });
|
|
403
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
404
|
+
const drain = bus.start();
|
|
405
|
+
await plugin.initialize(bus, baseConfig());
|
|
406
|
+
|
|
407
|
+
bus.push(Route.Main, turnComplete("ctx", "first question"));
|
|
408
|
+
await waitFor(() => hasPacket(packets, "llm.done", "ctx"));
|
|
409
|
+
|
|
410
|
+
expect(packets).toContainEqual({
|
|
411
|
+
route: Route.Main,
|
|
412
|
+
packet: expect.objectContaining({
|
|
413
|
+
kind: "llm.done",
|
|
414
|
+
contextId: "ctx",
|
|
415
|
+
text: "Approve?",
|
|
416
|
+
} satisfies Partial<LlmResponseDonePacket>),
|
|
417
|
+
});
|
|
418
|
+
expect(packets).toContainEqual({
|
|
419
|
+
route: Route.Background,
|
|
420
|
+
packet: expect.objectContaining({
|
|
421
|
+
kind: "reasoning.suspended",
|
|
422
|
+
contextId: "ctx",
|
|
423
|
+
runId: "r1",
|
|
424
|
+
prompt: "Approve?",
|
|
425
|
+
payload: { step: 1 },
|
|
426
|
+
} satisfies Partial<ReasoningSuspendedPacket>),
|
|
427
|
+
});
|
|
428
|
+
expect(runStore.saveCalls).toEqual([["ctx", "r1"]]);
|
|
429
|
+
|
|
430
|
+
bus.push(Route.Main, turnComplete("ctx", "yes"));
|
|
431
|
+
await waitFor(() => packets.filter(({ packet }) => (packet as { kind?: string }).kind === "llm.done").length >= 2);
|
|
432
|
+
|
|
433
|
+
expect(capturedTurns[1]?.resume).toEqual({ runId: "r1", data: "yes" });
|
|
434
|
+
expect(runStore.discardCalls).toEqual(["ctx"]);
|
|
435
|
+
|
|
436
|
+
bus.stop();
|
|
437
|
+
await drain;
|
|
438
|
+
await plugin.close();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("suspend → barge-in → next turn restarts without resume", async () => {
|
|
442
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
443
|
+
const runStore = new FakeRunStore();
|
|
444
|
+
const { reasoner, capturedTurns } = createSuspendResumeReasoner();
|
|
445
|
+
const plugin = new ReasoningBridge(reasoner, { runStore });
|
|
446
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
447
|
+
const drain = bus.start();
|
|
448
|
+
await plugin.initialize(bus, baseConfig());
|
|
449
|
+
|
|
450
|
+
bus.push(Route.Main, turnComplete("ctx", "first question"));
|
|
451
|
+
await waitFor(() => hasPacket(packets, "reasoning.suspended", "ctx"));
|
|
452
|
+
expect(runStore.saveCalls).toEqual([["ctx", "r1"]]);
|
|
453
|
+
|
|
454
|
+
bus.push(Route.Critical, interruptLlm("ctx"));
|
|
455
|
+
await waitFor(() => runStore.discardCalls.includes("ctx"));
|
|
456
|
+
|
|
457
|
+
bus.push(Route.Main, turnComplete("ctx", "corrected answer"));
|
|
458
|
+
await waitFor(() => capturedTurns.length >= 2);
|
|
459
|
+
|
|
460
|
+
expect(capturedTurns[1]?.resume).toBeUndefined();
|
|
461
|
+
|
|
462
|
+
bus.stop();
|
|
463
|
+
await drain;
|
|
464
|
+
await plugin.close();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("barge-in discards a pending run pointer", async () => {
|
|
468
|
+
const runStore = new FakeRunStore();
|
|
469
|
+
runStore.save("ctx", "r1");
|
|
470
|
+
const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
|
|
471
|
+
yield textDelta("ok.");
|
|
472
|
+
yield finish("stop");
|
|
473
|
+
}), { runStore });
|
|
474
|
+
const bus = new PipelineBusImpl({ onPacket: () => undefined });
|
|
475
|
+
const drain = bus.start();
|
|
476
|
+
await plugin.initialize(bus, baseConfig());
|
|
477
|
+
|
|
478
|
+
bus.push(Route.Critical, interruptLlm("ctx"));
|
|
479
|
+
await waitFor(() => runStore.discardCalls.includes("ctx"));
|
|
480
|
+
expect(runStore.takePending("ctx")).toBeNull();
|
|
481
|
+
|
|
482
|
+
bus.stop();
|
|
483
|
+
await drain;
|
|
484
|
+
await plugin.close();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("without runStore, suspended still emits reasoning.suspended without persistence", async () => {
|
|
488
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
489
|
+
const { reasoner } = createSuspendResumeReasoner();
|
|
490
|
+
const plugin = new ReasoningBridge(reasoner);
|
|
491
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
492
|
+
const drain = bus.start();
|
|
493
|
+
await plugin.initialize(bus, baseConfig());
|
|
494
|
+
|
|
495
|
+
bus.push(Route.Main, turnComplete("ctx", "question"));
|
|
496
|
+
await waitFor(() => hasPacket(packets, "reasoning.suspended", "ctx"));
|
|
497
|
+
|
|
498
|
+
expect(packets).toContainEqual({
|
|
499
|
+
route: Route.Background,
|
|
500
|
+
packet: expect.objectContaining({
|
|
501
|
+
kind: "reasoning.suspended",
|
|
502
|
+
contextId: "ctx",
|
|
503
|
+
runId: "r1",
|
|
504
|
+
} satisfies Partial<ReasoningSuspendedPacket>),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
bus.stop();
|
|
508
|
+
await drain;
|
|
509
|
+
await plugin.close();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("throws when onResumeConflict is replay", () => {
|
|
513
|
+
expect(
|
|
514
|
+
() => new ReasoningBridge(fromStreamFactory(async function* () {}), { onResumeConflict: "replay" }),
|
|
515
|
+
).toThrow("onResumeConflict 'replay' not yet supported — use 'restart'");
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
class FakeRunStore implements RunStore {
|
|
520
|
+
private pointers = new Map<string, string>();
|
|
521
|
+
saveCalls: Array<[string, string]> = [];
|
|
522
|
+
discardCalls: string[] = [];
|
|
523
|
+
takePendingCalls: string[] = [];
|
|
524
|
+
|
|
525
|
+
save(contextId: string, runId: string): void {
|
|
526
|
+
this.saveCalls.push([contextId, runId]);
|
|
527
|
+
this.pointers.set(contextId, runId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
takePending(contextId: string): RunPointer | null {
|
|
531
|
+
this.takePendingCalls.push(contextId);
|
|
532
|
+
const runId = this.pointers.get(contextId);
|
|
533
|
+
return runId ? { runId } : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
discard(contextId: string): void {
|
|
537
|
+
this.discardCalls.push(contextId);
|
|
538
|
+
this.pointers.delete(contextId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function createSuspendResumeReasoner(): {
|
|
543
|
+
reasoner: Reasoner;
|
|
544
|
+
capturedTurns: ReasonerTurn[];
|
|
545
|
+
} {
|
|
546
|
+
const capturedTurns: ReasonerTurn[] = [];
|
|
547
|
+
const reasoner: Reasoner = {
|
|
548
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart> {
|
|
549
|
+
capturedTurns.push(turn);
|
|
550
|
+
return (async function* () {
|
|
551
|
+
if (turn.resume) {
|
|
552
|
+
yield { type: "text-delta", text: "Resumed." };
|
|
553
|
+
yield { type: "finish", reason: "stop", text: "Resumed." };
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
yield {
|
|
557
|
+
type: "suspended",
|
|
558
|
+
runId: "r1",
|
|
559
|
+
prompt: "Approve?",
|
|
560
|
+
payload: { step: 1 },
|
|
561
|
+
};
|
|
562
|
+
})();
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
return { reasoner, capturedTurns };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function hasPacket(packets: Array<{ packet: unknown }>, kind: string, contextId: string): boolean {
|
|
569
|
+
return packets.some(
|
|
570
|
+
({ packet }) =>
|
|
571
|
+
(packet as { kind?: string }).kind === kind &&
|
|
572
|
+
(packet as { contextId?: string }).contextId === contextId,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function hasMetric(packets: Array<{ packet: unknown }>, name: string): boolean {
|
|
577
|
+
return packets.some(({ packet }) => (packet as { name?: string }).name === name);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function ttsText(contextId: string, text: string): TextToSpeechTextPacket {
|
|
581
|
+
return { kind: "tts.text", contextId, timestampMs: Date.now(), text };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function wordTimestamps(contextId: string, words: TtsWordTimestamp[]): TextToSpeechWordTimestampsPacket {
|
|
585
|
+
return { kind: "tts.word_timestamps", contextId, timestampMs: Date.now(), words };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function playoutProgress(contextId: string, playedOutMs: number, complete: boolean): TextToSpeechPlayoutProgressPacket {
|
|
589
|
+
return { kind: "tts.playout_progress", contextId, timestampMs: Date.now(), playedOutMs, complete };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function interruptLlm(contextId: string): InterruptLlmPacket {
|
|
593
|
+
return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function baseConfig(): Record<string, unknown> {
|
|
597
|
+
return {
|
|
598
|
+
api_key: "test-key",
|
|
599
|
+
model: "gpt-test",
|
|
600
|
+
system_prompt: "test",
|
|
601
|
+
retry_max_attempts: 1,
|
|
602
|
+
timeout_ms: 1000,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
607
|
+
return {
|
|
608
|
+
kind: "eos.turn_complete",
|
|
609
|
+
contextId,
|
|
610
|
+
timestampMs: Date.now(),
|
|
611
|
+
text,
|
|
612
|
+
transcripts: [],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
617
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
621
|
+
return {
|
|
622
|
+
type: "finish",
|
|
623
|
+
finishReason,
|
|
624
|
+
rawFinishReason,
|
|
625
|
+
totalUsage: ZERO_USAGE,
|
|
626
|
+
usage: ZERO_USAGE,
|
|
627
|
+
providerMetadata: undefined,
|
|
628
|
+
response: {},
|
|
629
|
+
} as TextStreamPart<ToolSet>;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
633
|
+
const started = Date.now();
|
|
634
|
+
while (!predicate()) {
|
|
635
|
+
if (Date.now() - started > 1000) {
|
|
636
|
+
throw new Error("Timed out waiting for packet");
|
|
637
|
+
}
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
639
|
+
}
|
|
640
|
+
}
|