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