@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 CHANGED
@@ -1,16 +1,34 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/aisdk",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "private": false,
5
- "type": "module",
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.1.0"
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
- !draft.hold.failed
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 = { promoted: false, failed: false, buffered: [] };
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
- if (!draft.hold.promoted) draft.controller.abort();
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 (hold && !hold.promoted) {
278
- if ((packet as { kind?: string }).kind === "llm.error") hold.failed = true;
279
- hold.buffered.push(() => this.bus?.push(route, packet));
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 (hold && !hold.promoted) hold.buffered.push(fn);
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 (hold && !hold.promoted) {
397
- hold.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
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
+ });