@kuralle-syrinx/aisdk 4.1.0 → 4.2.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/package.json +21 -3
- package/src/heard-prefix-commit.test.ts +291 -0
- package/src/index.ts +78 -14
- package/src/speculative-on-ledger.test.ts +211 -0
- package/src/speculative-post-promotion.test.ts +86 -0
package/package.json
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
5
|
+
"description": "Vercel AI SDK bridge for Syrinx — drive any ai@6 model or agent as the voice pipeline's reasoner, with opt-in speculative generation",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"ai-sdk",
|
|
12
|
+
"vercel-ai"
|
|
13
|
+
],
|
|
6
14
|
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
19
|
+
"directory": "packages/aisdk"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
7
25
|
"main": "./src/index.ts",
|
|
8
26
|
"types": "./src/index.ts",
|
|
9
27
|
"dependencies": {
|
|
10
28
|
"@ai-sdk/openai": "^3.0.67",
|
|
11
29
|
"ai": "^6.0.0",
|
|
12
30
|
"zod": "^4.1.8",
|
|
13
|
-
"@kuralle-syrinx/core": "4.
|
|
31
|
+
"@kuralle-syrinx/core": "4.2.0"
|
|
14
32
|
},
|
|
15
33
|
"devDependencies": {
|
|
16
34
|
"typescript": "^5.7.0",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
PipelineBusImpl,
|
|
6
|
+
Route,
|
|
7
|
+
type EndOfSpeechPacket,
|
|
8
|
+
type InMemoryIuLedger,
|
|
9
|
+
type IncrementalUnitId,
|
|
10
|
+
type InterruptLlmPacket,
|
|
11
|
+
type TextToSpeechPlayoutProgressPacket,
|
|
12
|
+
type TextToSpeechTextPacket,
|
|
13
|
+
type TextToSpeechWordTimestampsPacket,
|
|
14
|
+
type TtsWordTimestamp,
|
|
15
|
+
} from "@kuralle-syrinx/core";
|
|
16
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
17
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
18
|
+
import { ReasoningBridge } from "./index.js";
|
|
19
|
+
|
|
20
|
+
const ZERO_USAGE = {
|
|
21
|
+
inputTokens: 0,
|
|
22
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
23
|
+
outputTokens: 0,
|
|
24
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
25
|
+
totalTokens: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type BridgeLedgerAccess = {
|
|
29
|
+
iuLedger: InMemoryIuLedger;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function bridgeLedger(plugin: ReasoningBridge): BridgeLedgerAccess {
|
|
33
|
+
return plugin as unknown as BridgeLedgerAccess;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assistantId(contextId: string, epoch = 1): IncrementalUnitId {
|
|
37
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function userTurnId(contextId: string, epoch = 1): IncrementalUnitId {
|
|
41
|
+
return { contextId, iuId: contextId, epoch };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("ReasoningBridge heard-prefix commit (S2-01)", () => {
|
|
45
|
+
it("commits assistant IU with word-boundary prefix on barge-in during playback", async () => {
|
|
46
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
47
|
+
const plugin = new ReasoningBridge(
|
|
48
|
+
fromStreamFactory(async function* () {
|
|
49
|
+
yield textDelta("Hello world foo bar.");
|
|
50
|
+
yield finish("stop");
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
54
|
+
const drain = bus.start();
|
|
55
|
+
await plugin.initialize(bus, baseConfig());
|
|
56
|
+
const ledger = bridgeLedger(plugin);
|
|
57
|
+
const ctx = "turn-word";
|
|
58
|
+
|
|
59
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
60
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
61
|
+
|
|
62
|
+
bus.push(Route.Main, wordTimestamps(ctx, [
|
|
63
|
+
{ word: "Hello", startMs: 0, endMs: 200 },
|
|
64
|
+
{ word: "world", startMs: 220, endMs: 400 },
|
|
65
|
+
{ word: "foo", startMs: 420, endMs: 600 },
|
|
66
|
+
{ word: "bar.", startMs: 620, endMs: 800 },
|
|
67
|
+
]));
|
|
68
|
+
bus.push(Route.Main, playoutProgress(ctx, 450));
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
70
|
+
|
|
71
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
72
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
73
|
+
|
|
74
|
+
const spoken = "Hello world";
|
|
75
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
76
|
+
expect(iu.state).toBe("committed");
|
|
77
|
+
expect(iu.committedPrefix?.chars).toBe(spoken.length);
|
|
78
|
+
expect(iu.committedPrefix?.ms).toBe(450);
|
|
79
|
+
expect(iu.committedPrefix?.chars).toBeLessThan("Hello world foo bar.".length);
|
|
80
|
+
|
|
81
|
+
bus.stop();
|
|
82
|
+
await drain;
|
|
83
|
+
await plugin.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("commits assistant IU with spokenByContext prefix when word timestamps are absent", async () => {
|
|
87
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
88
|
+
const plugin = new ReasoningBridge(
|
|
89
|
+
fromStreamFactory(async function* () {
|
|
90
|
+
yield textDelta("Sentence one. Sentence two.");
|
|
91
|
+
yield finish("stop");
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
95
|
+
const drain = bus.start();
|
|
96
|
+
await plugin.initialize(bus, baseConfig());
|
|
97
|
+
const ledger = bridgeLedger(plugin);
|
|
98
|
+
const ctx = "turn-fallback";
|
|
99
|
+
|
|
100
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
101
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
102
|
+
|
|
103
|
+
bus.push(Route.Main, ttsText(ctx, "Sentence one."));
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
105
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
106
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
107
|
+
|
|
108
|
+
const spoken = "Sentence one.";
|
|
109
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
110
|
+
expect(iu.state).toBe("committed");
|
|
111
|
+
expect(iu.committedPrefix?.chars).toBe(spoken.length);
|
|
112
|
+
|
|
113
|
+
bus.stop();
|
|
114
|
+
await drain;
|
|
115
|
+
await plugin.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("commits heard prefix on mid-stream interrupt without losing streamed packets", async () => {
|
|
119
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
120
|
+
const plugin = new ReasoningBridge(
|
|
121
|
+
fromStreamFactory(async function* ({ signal }) {
|
|
122
|
+
yield textDelta("Hello");
|
|
123
|
+
await new Promise<void>((resolve) => {
|
|
124
|
+
if (signal.aborted) {
|
|
125
|
+
resolve();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
129
|
+
});
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
133
|
+
const drain = bus.start();
|
|
134
|
+
await plugin.initialize(bus, baseConfig());
|
|
135
|
+
const ledger = bridgeLedger(plugin);
|
|
136
|
+
const ctx = "turn-mid";
|
|
137
|
+
|
|
138
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
139
|
+
await waitFor(() =>
|
|
140
|
+
packets.some(
|
|
141
|
+
({ packet }) =>
|
|
142
|
+
(packet as { kind?: string }).kind === "llm.delta" &&
|
|
143
|
+
(packet as { text?: string }).text === "Hello",
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
bus.push(Route.Main, ttsText(ctx, "Hello"));
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
149
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
150
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
151
|
+
|
|
152
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
153
|
+
expect(iu.state).toBe("committed");
|
|
154
|
+
expect(iu.committedPrefix?.chars).toBe("Hello".length);
|
|
155
|
+
|
|
156
|
+
expect(packets).toContainEqual({
|
|
157
|
+
route: Route.Main,
|
|
158
|
+
packet: expect.objectContaining({ kind: "llm.delta", contextId: ctx, text: "Hello" }),
|
|
159
|
+
});
|
|
160
|
+
expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
|
|
161
|
+
|
|
162
|
+
bus.stop();
|
|
163
|
+
await drain;
|
|
164
|
+
await plugin.close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("commits assistant IU fully on clean completion without a truncated prefix", async () => {
|
|
168
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
169
|
+
const plugin = new ReasoningBridge(
|
|
170
|
+
fromStreamFactory(async function* () {
|
|
171
|
+
yield textDelta("Clean answer.");
|
|
172
|
+
yield finish("stop");
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
176
|
+
const drain = bus.start();
|
|
177
|
+
await plugin.initialize(bus, baseConfig());
|
|
178
|
+
const ledger = bridgeLedger(plugin);
|
|
179
|
+
const ctx = "turn-clean";
|
|
180
|
+
|
|
181
|
+
bus.push(Route.Main, turnComplete(ctx, "question"));
|
|
182
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
183
|
+
|
|
184
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
185
|
+
expect(iu.state).toBe("committed");
|
|
186
|
+
expect(iu.committedPrefix).toBeUndefined();
|
|
187
|
+
|
|
188
|
+
bus.stop();
|
|
189
|
+
await drain;
|
|
190
|
+
await plugin.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps distinct assistant and user-turn IUs in the ledger for one contextId", async () => {
|
|
194
|
+
const plugin = new ReasoningBridge(
|
|
195
|
+
fromStreamFactory(async function* () {
|
|
196
|
+
yield textDelta("Answer.");
|
|
197
|
+
yield finish("stop");
|
|
198
|
+
}),
|
|
199
|
+
{ speculative: true },
|
|
200
|
+
);
|
|
201
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
202
|
+
const drain = bus.start();
|
|
203
|
+
await plugin.initialize(bus, baseConfig());
|
|
204
|
+
const ledger = bridgeLedger(plugin);
|
|
205
|
+
const ctx = "turn-dual";
|
|
206
|
+
|
|
207
|
+
bus.push(Route.Main, eosInterim(ctx, "hello"));
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
209
|
+
|
|
210
|
+
const userIu = ledger.iuLedger.get(userTurnId(ctx));
|
|
211
|
+
const assistantIu = ledger.iuLedger.get(assistantId(ctx));
|
|
212
|
+
expect(userIu?.kind).toBe("user_turn");
|
|
213
|
+
expect(assistantIu?.kind).toBe("assistant_response");
|
|
214
|
+
expect(userIu?.id.iuId).toBe(ctx);
|
|
215
|
+
expect(assistantIu?.id.iuId).toBe(`${ctx}#assistant`);
|
|
216
|
+
expect(userIu?.id.epoch).toBe(assistantIu?.id.epoch);
|
|
217
|
+
|
|
218
|
+
bus.stop();
|
|
219
|
+
await drain;
|
|
220
|
+
await plugin.close();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function baseConfig(): Record<string, unknown> {
|
|
225
|
+
return {
|
|
226
|
+
api_key: "test-key",
|
|
227
|
+
model: "gpt-test",
|
|
228
|
+
system_prompt: "test",
|
|
229
|
+
retry_max_attempts: 1,
|
|
230
|
+
timeout_ms: 1000,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
235
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
|
|
239
|
+
return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ttsText(contextId: string, text: string): TextToSpeechTextPacket {
|
|
243
|
+
return { kind: "tts.text", contextId, timestampMs: Date.now(), text };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function wordTimestamps(contextId: string, words: TtsWordTimestamp[]): TextToSpeechWordTimestampsPacket {
|
|
247
|
+
return { kind: "tts.word_timestamps", contextId, timestampMs: Date.now(), words };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function playoutProgress(contextId: string, playedOutMs: number): TextToSpeechPlayoutProgressPacket {
|
|
251
|
+
return { kind: "tts.playout_progress", contextId, timestampMs: Date.now(), playedOutMs, complete: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function interruptLlm(contextId: string): InterruptLlmPacket {
|
|
255
|
+
return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
259
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finish(finishReason: FinishReason): TextStreamPart<ToolSet> {
|
|
263
|
+
return {
|
|
264
|
+
type: "finish",
|
|
265
|
+
finishReason,
|
|
266
|
+
totalUsage: ZERO_USAGE,
|
|
267
|
+
usage: ZERO_USAGE,
|
|
268
|
+
providerMetadata: undefined,
|
|
269
|
+
response: {},
|
|
270
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hasPacket(packets: Array<{ packet: unknown }>, kind: string, contextId: string): boolean {
|
|
274
|
+
return packets.some(
|
|
275
|
+
({ packet }) =>
|
|
276
|
+
(packet as { kind?: string }).kind === kind &&
|
|
277
|
+
(packet as { contextId?: string }).contextId === contextId,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function hasMetric(packets: Array<{ packet: unknown }>, name: string): boolean {
|
|
282
|
+
return packets.some(({ packet }) => (packet as { name?: string }).name === name);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
286
|
+
const started = Date.now();
|
|
287
|
+
while (!predicate()) {
|
|
288
|
+
if (Date.now() - started > 2000) throw new Error("Timed out waiting for condition");
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
type ReasonerSessionStore,
|
|
21
21
|
type ReasonerTurn,
|
|
22
22
|
type TtsWordTimestamp,
|
|
23
|
+
type IncrementalUnitId,
|
|
23
24
|
categorizeLlmError,
|
|
24
25
|
isRecoverable,
|
|
25
26
|
readRetryConfig,
|
|
26
27
|
waitForRetryDelay,
|
|
27
28
|
ErrorCategory,
|
|
28
29
|
type RetryConfig,
|
|
30
|
+
InMemoryIuLedger,
|
|
29
31
|
} from "@kuralle-syrinx/core";
|
|
30
32
|
|
|
31
33
|
export {
|
|
@@ -60,8 +62,6 @@ export interface RunStore {
|
|
|
60
62
|
* simply dropped — the generation was never observable.
|
|
61
63
|
*/
|
|
62
64
|
interface SpeculativeHold {
|
|
63
|
-
promoted: boolean;
|
|
64
|
-
failed: boolean;
|
|
65
65
|
buffered: Array<() => void>;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -77,7 +77,11 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
77
77
|
userText: string;
|
|
78
78
|
controller: AbortController;
|
|
79
79
|
hold: SpeculativeHold;
|
|
80
|
+
id: IncrementalUnitId;
|
|
80
81
|
} | null = null;
|
|
82
|
+
private iuLedger!: InMemoryIuLedger;
|
|
83
|
+
private readonly epochByContext = new Map<string, number>();
|
|
84
|
+
private turnEpochCounter = 0;
|
|
81
85
|
private retryConfig: RetryConfig = readRetryConfig({});
|
|
82
86
|
private disposers: Array<() => void> = [];
|
|
83
87
|
// G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
|
|
@@ -134,6 +138,21 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
134
138
|
|
|
135
139
|
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
136
140
|
this.bus = bus;
|
|
141
|
+
this.iuLedger = new InMemoryIuLedger((a) => {
|
|
142
|
+
const detail =
|
|
143
|
+
a.kind === "terminal_op"
|
|
144
|
+
? `${a.op} on ${a.state} IU`
|
|
145
|
+
: `${a.op} on unknown IU`;
|
|
146
|
+
this.bus?.push(Route.Background, {
|
|
147
|
+
kind: "llm.error",
|
|
148
|
+
contextId: a.id.contextId,
|
|
149
|
+
timestampMs: Date.now(),
|
|
150
|
+
component: "iu_ledger",
|
|
151
|
+
category: ErrorCategory.InternalFault,
|
|
152
|
+
cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
|
|
153
|
+
isRecoverable: true,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
137
156
|
this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
|
|
138
157
|
this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
|
|
139
158
|
this.retryConfig = readRetryConfig(config);
|
|
@@ -165,10 +184,10 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
165
184
|
draft.contextId === eos.contextId &&
|
|
166
185
|
draft.userText === eos.text &&
|
|
167
186
|
!draft.controller.signal.aborted &&
|
|
168
|
-
|
|
187
|
+
this.iuLedger.get(draft.id)?.state === "hypothesized"
|
|
169
188
|
) {
|
|
189
|
+
this.iuLedger.commit(draft.id);
|
|
170
190
|
this.speculativeDraft = null;
|
|
171
|
-
draft.hold.promoted = true;
|
|
172
191
|
for (const flush of draft.hold.buffered.splice(0)) flush();
|
|
173
192
|
return;
|
|
174
193
|
}
|
|
@@ -240,17 +259,41 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
240
259
|
/** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
|
|
241
260
|
private async runDraft(userText: string, contextId: string): Promise<void> {
|
|
242
261
|
this.discardDraft();
|
|
262
|
+
const id = this.iuIdFor(contextId);
|
|
263
|
+
this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
|
|
243
264
|
const controller = new AbortController();
|
|
244
|
-
const hold: SpeculativeHold = {
|
|
245
|
-
this.speculativeDraft = { contextId, userText, controller, hold };
|
|
246
|
-
await this.processTurn(userText, contextId, hold, controller);
|
|
265
|
+
const hold: SpeculativeHold = { buffered: [] };
|
|
266
|
+
this.speculativeDraft = { contextId, userText, controller, hold, id };
|
|
267
|
+
await this.processTurn(userText, contextId, hold, controller, id);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private iuIdFor(contextId: string): IncrementalUnitId {
|
|
271
|
+
let epoch = this.epochByContext.get(contextId);
|
|
272
|
+
if (epoch === undefined) {
|
|
273
|
+
epoch = ++this.turnEpochCounter;
|
|
274
|
+
this.epochByContext.set(contextId, epoch);
|
|
275
|
+
}
|
|
276
|
+
return { contextId, iuId: contextId, epoch };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private assistantIuIdFor(contextId: string): IncrementalUnitId {
|
|
280
|
+
let epoch = this.epochByContext.get(contextId);
|
|
281
|
+
if (epoch === undefined) {
|
|
282
|
+
epoch = ++this.turnEpochCounter;
|
|
283
|
+
this.epochByContext.set(contextId, epoch);
|
|
284
|
+
}
|
|
285
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
247
286
|
}
|
|
248
287
|
|
|
249
288
|
private discardDraft(): void {
|
|
250
289
|
const draft = this.speculativeDraft;
|
|
251
290
|
if (!draft) return;
|
|
252
291
|
this.speculativeDraft = null;
|
|
253
|
-
|
|
292
|
+
const committed = this.iuLedger.get(draft.id)?.state === "committed";
|
|
293
|
+
if (!committed) {
|
|
294
|
+
this.iuLedger.revoke(draft.id);
|
|
295
|
+
draft.controller.abort();
|
|
296
|
+
}
|
|
254
297
|
}
|
|
255
298
|
|
|
256
299
|
private async processTurn(
|
|
@@ -258,6 +301,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
258
301
|
contextId: string,
|
|
259
302
|
hold?: SpeculativeHold,
|
|
260
303
|
presetController?: AbortController,
|
|
304
|
+
iuId?: IncrementalUnitId,
|
|
261
305
|
): Promise<void> {
|
|
262
306
|
if (!this.bus) return;
|
|
263
307
|
|
|
@@ -268,21 +312,27 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
268
312
|
this.activeGeneration?.controller.abort();
|
|
269
313
|
const controller = presetController ?? new AbortController();
|
|
270
314
|
this.activeGeneration = { contextId, controller };
|
|
315
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
316
|
+
this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
|
|
271
317
|
const signal = controller.signal;
|
|
272
318
|
|
|
273
319
|
// R2: while a speculative hold is unpromoted, every push/mutation buffers.
|
|
274
320
|
// Packets are constructed eagerly (their timestamps are event time); only
|
|
275
321
|
// delivery is deferred. Promotion replays in order, then later effects run live.
|
|
322
|
+
const isBuffering = (): boolean =>
|
|
323
|
+
hold !== undefined &&
|
|
324
|
+
iuId !== undefined &&
|
|
325
|
+
this.iuLedger.get(iuId)?.state !== "committed";
|
|
276
326
|
const push = <T extends Parameters<PipelineBus["push"]>[1]>(route: Route, packet: T): void => {
|
|
277
|
-
if (
|
|
278
|
-
if ((packet as { kind?: string }).kind === "llm.error")
|
|
279
|
-
hold
|
|
327
|
+
if (isBuffering()) {
|
|
328
|
+
if ((packet as { kind?: string }).kind === "llm.error" && iuId) this.iuLedger.revoke(iuId);
|
|
329
|
+
hold!.buffered.push(() => this.bus?.push(route, packet));
|
|
280
330
|
return;
|
|
281
331
|
}
|
|
282
332
|
this.bus?.push(route, packet);
|
|
283
333
|
};
|
|
284
334
|
const defer = (fn: () => void): void => {
|
|
285
|
-
if (
|
|
335
|
+
if (isBuffering()) hold!.buffered.push(fn);
|
|
286
336
|
else fn();
|
|
287
337
|
};
|
|
288
338
|
|
|
@@ -393,8 +443,8 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
393
443
|
if (this.opts.runStore) {
|
|
394
444
|
const store = this.opts.runStore;
|
|
395
445
|
const runId = part.runId;
|
|
396
|
-
if (
|
|
397
|
-
hold
|
|
446
|
+
if (isBuffering()) {
|
|
447
|
+
hold!.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
|
|
398
448
|
} else {
|
|
399
449
|
await Promise.resolve(store.save(contextId, runId));
|
|
400
450
|
}
|
|
@@ -513,6 +563,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
513
563
|
const assistantMsg = { role: "assistant" as const, content: assistantText };
|
|
514
564
|
this.history.push({ role: "user", content: userText }, assistantMsg);
|
|
515
565
|
this.assistantMsgByContext.set(contextId, assistantMsg);
|
|
566
|
+
this.iuLedger.commit(this.assistantIuIdFor(contextId));
|
|
516
567
|
this.trimHistory();
|
|
517
568
|
this.persistHistory();
|
|
518
569
|
}
|
|
@@ -558,6 +609,15 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
558
609
|
*/
|
|
559
610
|
private commitInterruptedHistory(contextId: string): void {
|
|
560
611
|
const spoken = this.computeSpokenPrefix(contextId);
|
|
612
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
613
|
+
const ms = this.playedOutMsByContext.get(contextId);
|
|
614
|
+
const prefix = { chars: spoken.length, ms };
|
|
615
|
+
const assistantIu = this.iuLedger.get(aid);
|
|
616
|
+
if (assistantIu?.state === "hypothesized") {
|
|
617
|
+
this.iuLedger.commit(aid, prefix);
|
|
618
|
+
} else if (assistantIu?.state === "committed") {
|
|
619
|
+
assistantIu.committedPrefix = prefix;
|
|
620
|
+
}
|
|
561
621
|
const existing = this.assistantMsgByContext.get(contextId);
|
|
562
622
|
if (existing) {
|
|
563
623
|
if (spoken) {
|
|
@@ -597,6 +657,10 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
597
657
|
}
|
|
598
658
|
|
|
599
659
|
private clearTurnState(contextId: string): void {
|
|
660
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
661
|
+
if (this.iuLedger.get(aid)?.state === "hypothesized") {
|
|
662
|
+
this.iuLedger.revoke(aid);
|
|
663
|
+
}
|
|
600
664
|
this.spokenByContext.delete(contextId);
|
|
601
665
|
this.turnUserText.delete(contextId);
|
|
602
666
|
this.assistantMsgByContext.delete(contextId);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
PipelineBusImpl,
|
|
6
|
+
Route,
|
|
7
|
+
type EndOfSpeechPacket,
|
|
8
|
+
type InMemoryIuLedger,
|
|
9
|
+
type IncrementalUnitId,
|
|
10
|
+
} from "@kuralle-syrinx/core";
|
|
11
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
12
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
13
|
+
import { ReasoningBridge } from "./index.js";
|
|
14
|
+
|
|
15
|
+
const ZERO_USAGE = {
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
18
|
+
outputTokens: 0,
|
|
19
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type BridgeLedgerAccess = {
|
|
24
|
+
iuLedger: InMemoryIuLedger;
|
|
25
|
+
speculativeDraft: { id: IncrementalUnitId } | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function bridgeLedger(plugin: ReasoningBridge): BridgeLedgerAccess {
|
|
29
|
+
return plugin as unknown as BridgeLedgerAccess;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("ReasoningBridge speculative-on-ledger", () => {
|
|
33
|
+
it("eos.interim adds a hypothesized IU and matching eos.turn_complete commits it", async () => {
|
|
34
|
+
const plugin = new ReasoningBridge(
|
|
35
|
+
fromStreamFactory(async function* () {
|
|
36
|
+
yield textDelta("Answer.");
|
|
37
|
+
yield finish("stop");
|
|
38
|
+
}),
|
|
39
|
+
{ speculative: true },
|
|
40
|
+
);
|
|
41
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
42
|
+
const drain = bus.start();
|
|
43
|
+
await plugin.initialize(bus, baseConfig());
|
|
44
|
+
const ledger = bridgeLedger(plugin);
|
|
45
|
+
|
|
46
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello"));
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
48
|
+
|
|
49
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
50
|
+
expect(draftId).toEqual({ contextId: "turn-1", iuId: "turn-1", epoch: 1 });
|
|
51
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("hypothesized");
|
|
52
|
+
|
|
53
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello"));
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
55
|
+
|
|
56
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("committed");
|
|
57
|
+
bus.stop();
|
|
58
|
+
await drain;
|
|
59
|
+
await plugin.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("eos.retracted revokes the hypothesized IU", async () => {
|
|
63
|
+
const plugin = new ReasoningBridge(
|
|
64
|
+
fromStreamFactory(async function* () {
|
|
65
|
+
yield textDelta("Draft.");
|
|
66
|
+
yield finish("stop");
|
|
67
|
+
}),
|
|
68
|
+
{ speculative: true },
|
|
69
|
+
);
|
|
70
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
71
|
+
const drain = bus.start();
|
|
72
|
+
await plugin.initialize(bus, baseConfig());
|
|
73
|
+
const ledger = bridgeLedger(plugin);
|
|
74
|
+
|
|
75
|
+
bus.push(Route.Main, eosInterim("turn-1", "book a"));
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
77
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
78
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("hypothesized");
|
|
79
|
+
|
|
80
|
+
bus.push(Route.Main, eosRetracted("turn-1"));
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
82
|
+
|
|
83
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("revoked");
|
|
84
|
+
bus.stop();
|
|
85
|
+
await drain;
|
|
86
|
+
await plugin.close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("double-commit fires an iu_ledger anomaly on the bus", async () => {
|
|
90
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
91
|
+
const plugin = new ReasoningBridge(
|
|
92
|
+
fromStreamFactory(async function* () {
|
|
93
|
+
yield textDelta("Answer.");
|
|
94
|
+
yield finish("stop");
|
|
95
|
+
}),
|
|
96
|
+
{ speculative: true },
|
|
97
|
+
);
|
|
98
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
99
|
+
const drain = bus.start();
|
|
100
|
+
await plugin.initialize(bus, baseConfig());
|
|
101
|
+
const ledger = bridgeLedger(plugin);
|
|
102
|
+
|
|
103
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello"));
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
105
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
106
|
+
expect(draftId).toBeDefined();
|
|
107
|
+
|
|
108
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello"));
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
110
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("committed");
|
|
111
|
+
|
|
112
|
+
ledger.iuLedger.commit(draftId!);
|
|
113
|
+
await waitFor(() =>
|
|
114
|
+
packets.some(
|
|
115
|
+
({ packet }) =>
|
|
116
|
+
(packet as { kind?: string; component?: string }).kind === "llm.error" &&
|
|
117
|
+
(packet as { component?: string }).component === "iu_ledger",
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(packets).toContainEqual({
|
|
122
|
+
route: Route.Background,
|
|
123
|
+
packet: expect.objectContaining({
|
|
124
|
+
kind: "llm.error",
|
|
125
|
+
component: "iu_ledger",
|
|
126
|
+
contextId: "turn-1",
|
|
127
|
+
isRecoverable: true,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
bus.stop();
|
|
131
|
+
await drain;
|
|
132
|
+
await plugin.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("assigns monotonic epoch across distinct contextIds", async () => {
|
|
136
|
+
const plugin = new ReasoningBridge(
|
|
137
|
+
fromStreamFactory(async function* () {
|
|
138
|
+
yield textDelta("Answer.");
|
|
139
|
+
yield finish("stop");
|
|
140
|
+
}),
|
|
141
|
+
{ speculative: true },
|
|
142
|
+
);
|
|
143
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
144
|
+
const drain = bus.start();
|
|
145
|
+
await plugin.initialize(bus, baseConfig());
|
|
146
|
+
const ledger = bridgeLedger(plugin);
|
|
147
|
+
|
|
148
|
+
bus.push(Route.Main, eosInterim("ctx-a", "first"));
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
150
|
+
const idA = ledger.speculativeDraft?.id;
|
|
151
|
+
bus.push(Route.Main, turnComplete("ctx-a", "first"));
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
153
|
+
|
|
154
|
+
bus.push(Route.Main, eosInterim("ctx-b", "second"));
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
156
|
+
const idB = ledger.speculativeDraft?.id;
|
|
157
|
+
|
|
158
|
+
expect(idA?.epoch).toBe(1);
|
|
159
|
+
expect(idB?.epoch).toBe(2);
|
|
160
|
+
expect(idB!.epoch).toBeGreaterThan(idA!.epoch);
|
|
161
|
+
bus.stop();
|
|
162
|
+
await drain;
|
|
163
|
+
await plugin.close();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function baseConfig(): Record<string, unknown> {
|
|
168
|
+
return {
|
|
169
|
+
api_key: "test-key",
|
|
170
|
+
model: "gpt-test",
|
|
171
|
+
system_prompt: "test",
|
|
172
|
+
retry_max_attempts: 1,
|
|
173
|
+
timeout_ms: 1000,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
178
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
|
|
182
|
+
return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function eosRetracted(contextId: string): { kind: "eos.retracted"; contextId: string; timestampMs: number } {
|
|
186
|
+
return { kind: "eos.retracted", contextId, timestampMs: Date.now() };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
190
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
194
|
+
return {
|
|
195
|
+
type: "finish",
|
|
196
|
+
finishReason,
|
|
197
|
+
rawFinishReason,
|
|
198
|
+
totalUsage: ZERO_USAGE,
|
|
199
|
+
usage: ZERO_USAGE,
|
|
200
|
+
providerMetadata: undefined,
|
|
201
|
+
response: {},
|
|
202
|
+
} as TextStreamPart<ToolSet>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
while (!predicate()) {
|
|
208
|
+
if (Date.now() - started > 1000) throw new Error("Timed out waiting for packet");
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Regression guard (S1-01, RFC C2): when a speculative draft is promoted MID-STREAM,
|
|
4
|
+
// deltas produced AFTER the promotion must go live, not stay buffered. The speculative
|
|
5
|
+
// side-effect gate must re-check commit state per push — hoisting it to a single value
|
|
6
|
+
// captured at generation start loses the post-promotion tail (incl. llm.done).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { PipelineBusImpl, Route } from "@kuralle-syrinx/core";
|
|
10
|
+
import type { EndOfSpeechPacket } from "@kuralle-syrinx/core";
|
|
11
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
12
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
13
|
+
import { ReasoningBridge } from "./index.js";
|
|
14
|
+
|
|
15
|
+
const ZERO_USAGE = {
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
18
|
+
outputTokens: 0,
|
|
19
|
+
outputTokenDetails: { reasoningTokens: 0 },
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
reasoningTokens: 0,
|
|
22
|
+
cachedInputTokens: 0,
|
|
23
|
+
} as unknown as Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
26
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
27
|
+
}
|
|
28
|
+
function finish(finishReason: FinishReason): TextStreamPart<ToolSet> {
|
|
29
|
+
return { type: "finish", finishReason, totalUsage: ZERO_USAGE, usage: ZERO_USAGE, providerMetadata: undefined, response: {} } as unknown as TextStreamPart<ToolSet>;
|
|
30
|
+
}
|
|
31
|
+
function eosInterim(contextId: string, text: string) {
|
|
32
|
+
return { kind: "eos.interim" as const, contextId, timestampMs: Date.now(), text };
|
|
33
|
+
}
|
|
34
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
35
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
36
|
+
}
|
|
37
|
+
function baseConfig(): Record<string, unknown> {
|
|
38
|
+
return { api_key: "test-key", model: "gpt-test", system_prompt: "test", retry_max_attempts: 1, timeout_ms: 1000 };
|
|
39
|
+
}
|
|
40
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
41
|
+
for (let i = 0; i < 200; i++) {
|
|
42
|
+
if (predicate()) return;
|
|
43
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
44
|
+
}
|
|
45
|
+
throw new Error("waitFor timed out");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("S1 repro — post-promotion streaming", () => {
|
|
49
|
+
it("a delta streamed AFTER a mid-stream promotion must still reach the bus", async () => {
|
|
50
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
51
|
+
let release!: () => void;
|
|
52
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
53
|
+
|
|
54
|
+
const plugin = new ReasoningBridge(
|
|
55
|
+
fromStreamFactory(async function* () {
|
|
56
|
+
yield textDelta("first "); // buffered while hypothesized
|
|
57
|
+
await gate; // still streaming — pause BEFORE promotion completes
|
|
58
|
+
yield textDelta("second"); // streamed AFTER promotion
|
|
59
|
+
yield finish("stop");
|
|
60
|
+
}),
|
|
61
|
+
{ speculative: true },
|
|
62
|
+
);
|
|
63
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
64
|
+
const drain = bus.start();
|
|
65
|
+
await plugin.initialize(bus, baseConfig());
|
|
66
|
+
|
|
67
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello there"));
|
|
68
|
+
await new Promise((r) => setTimeout(r, 50)); // "first " buffered; generator paused at gate
|
|
69
|
+
|
|
70
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello there")); // promote (commit) mid-stream
|
|
71
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
72
|
+
release(); // now let the generator stream the post-promotion delta
|
|
73
|
+
|
|
74
|
+
await waitFor(() => packets.some((p) => (p.packet as { kind: string }).kind === "llm.done"));
|
|
75
|
+
bus.stop();
|
|
76
|
+
await drain;
|
|
77
|
+
await plugin.close();
|
|
78
|
+
|
|
79
|
+
const deltaTexts = packets
|
|
80
|
+
.filter((p) => (p.packet as { kind: string }).kind === "llm.delta")
|
|
81
|
+
.map((p) => (p.packet as { text: string }).text);
|
|
82
|
+
|
|
83
|
+
// The post-promotion "second" delta MUST have reached the bus.
|
|
84
|
+
expect(deltaTexts).toContain("second");
|
|
85
|
+
});
|
|
86
|
+
});
|