@kuralle-syrinx/aisdk 4.0.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.0.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.0.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.test.ts CHANGED
@@ -793,6 +793,147 @@ function interruptLlm(contextId: string): InterruptLlmPacket {
793
793
  return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
794
794
  }
795
795
 
796
+ function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
797
+ return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
798
+ }
799
+
800
+ function eosRetracted(contextId: string): { kind: "eos.retracted"; contextId: string; timestampMs: number } {
801
+ return { kind: "eos.retracted", contextId, timestampMs: Date.now() };
802
+ }
803
+
804
+ describe("ReasoningBridge speculative generation", () => {
805
+ function kinds(packets: Array<{ packet: unknown }>): string[] {
806
+ return packets.map(({ packet }) => (packet as { kind: string }).kind);
807
+ }
808
+
809
+ it("buffers a draft on eos.interim and flushes it on a matching eos.turn_complete (one generation)", async () => {
810
+ const packets: Array<{ route: Route; packet: unknown }> = [];
811
+ let streams = 0;
812
+ const plugin = new ReasoningBridge(
813
+ fromStreamFactory(async function* () {
814
+ streams += 1;
815
+ yield textDelta("The fee is ten dollars.");
816
+ yield finish("stop");
817
+ }),
818
+ { speculative: true },
819
+ );
820
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
821
+ const drain = bus.start();
822
+ await plugin.initialize(bus, baseConfig());
823
+
824
+ bus.push(Route.Main, eosInterim("turn-1", "what are the lab fees"));
825
+ // Give the draft time to stream fully — nothing may reach the bus yet.
826
+ await new Promise((resolve) => setTimeout(resolve, 50));
827
+ expect(kinds(packets)).not.toContain("llm.delta");
828
+ expect(kinds(packets)).not.toContain("llm.done");
829
+ expect(kinds(packets)).not.toContain("delegate.query");
830
+
831
+ bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
832
+ await waitFor(() => kinds(packets).includes("llm.done"));
833
+ bus.stop();
834
+ await drain;
835
+ await plugin.close();
836
+
837
+ expect(streams).toBe(1); // the draft WAS the generation — no second LLM call
838
+ expect(packets).toContainEqual({
839
+ route: Route.Main,
840
+ packet: expect.objectContaining({ kind: "llm.delta", contextId: "turn-1", text: "The fee is ten dollars." }),
841
+ });
842
+ expect(packets).toContainEqual({
843
+ route: Route.Background,
844
+ packet: expect.objectContaining({ kind: "delegate.result", contextId: "turn-1", answer: "The fee is ten dollars." }),
845
+ });
846
+ });
847
+
848
+ it("discards the draft on eos.retracted — nothing is ever pushed for it", async () => {
849
+ const packets: Array<{ route: Route; packet: unknown }> = [];
850
+ let streams = 0;
851
+ const plugin = new ReasoningBridge(
852
+ fromStreamFactory(async function* () {
853
+ streams += 1;
854
+ yield textDelta("Draft answer.");
855
+ yield finish("stop");
856
+ }),
857
+ { speculative: true },
858
+ );
859
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
860
+ const drain = bus.start();
861
+ await plugin.initialize(bus, baseConfig());
862
+
863
+ bus.push(Route.Main, eosInterim("turn-1", "book a"));
864
+ await new Promise((resolve) => setTimeout(resolve, 30));
865
+ bus.push(Route.Main, eosRetracted("turn-1"));
866
+ await new Promise((resolve) => setTimeout(resolve, 30));
867
+
868
+ // The user finishes the real utterance later; a fresh generation answers it.
869
+ bus.push(Route.Main, turnComplete("turn-1", "book a room for tomorrow"));
870
+ await waitFor(() => kinds(packets).includes("llm.done"));
871
+ bus.stop();
872
+ await drain;
873
+ await plugin.close();
874
+
875
+ expect(streams).toBe(2); // draft + fresh confirmed run
876
+ const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
877
+ expect(deltas).toHaveLength(1); // the discarded draft's delta never surfaced
878
+ });
879
+
880
+ it("regenerates when the confirmed transcript differs from the draft's", async () => {
881
+ const packets: Array<{ route: Route; packet: unknown }> = [];
882
+ const seenTexts: string[] = [];
883
+ const plugin = new ReasoningBridge(
884
+ fromStreamFactory(async function* (request: { userText: string }) {
885
+ seenTexts.push(request.userText);
886
+ yield textDelta(`Answer to: ${request.userText}`);
887
+ yield finish("stop");
888
+ }),
889
+ { speculative: true },
890
+ );
891
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
892
+ const drain = bus.start();
893
+ await plugin.initialize(bus, baseConfig());
894
+
895
+ bus.push(Route.Main, eosInterim("turn-1", "what are the"));
896
+ await new Promise((resolve) => setTimeout(resolve, 30));
897
+ bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
898
+ await waitFor(() => kinds(packets).includes("llm.done"));
899
+ bus.stop();
900
+ await drain;
901
+ await plugin.close();
902
+
903
+ expect(seenTexts).toEqual(["what are the", "what are the lab fees"]);
904
+ expect(packets).toContainEqual({
905
+ route: Route.Main,
906
+ packet: expect.objectContaining({ kind: "llm.done", text: "Answer to: what are the lab fees" }),
907
+ });
908
+ const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
909
+ expect(deltas).toHaveLength(1);
910
+ });
911
+
912
+ it("ignores eos.interim when speculative mode is off (default)", async () => {
913
+ const packets: Array<{ route: Route; packet: unknown }> = [];
914
+ let streams = 0;
915
+ const plugin = new ReasoningBridge(
916
+ fromStreamFactory(async function* () {
917
+ streams += 1;
918
+ yield textDelta("Hello.");
919
+ yield finish("stop");
920
+ }),
921
+ );
922
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
923
+ const drain = bus.start();
924
+ await plugin.initialize(bus, baseConfig());
925
+
926
+ bus.push(Route.Main, eosInterim("turn-1", "hi"));
927
+ await new Promise((resolve) => setTimeout(resolve, 30));
928
+ bus.stop();
929
+ await drain;
930
+ await plugin.close();
931
+
932
+ expect(streams).toBe(0);
933
+ expect(kinds(packets).filter((k) => k.startsWith("llm."))).toHaveLength(0);
934
+ });
935
+ });
936
+
796
937
  function baseConfig(): Record<string, unknown> {
797
938
  return {
798
939
  api_key: "test-key",
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 {
@@ -53,12 +55,33 @@ export interface RunStore {
53
55
  discard(contextId: string): void | Promise<void>;
54
56
  }
55
57
 
58
+ /**
59
+ * Gate for a speculative draft's side effects. While unpromoted, every bus push
60
+ * and history/store mutation is buffered; promotion replays them in order and
61
+ * lets the still-running stream continue live. A discarded draft's buffer is
62
+ * simply dropped — the generation was never observable.
63
+ */
64
+ interface SpeculativeHold {
65
+ buffered: Array<() => void>;
66
+ }
67
+
56
68
  export class ReasoningBridge implements VoicePlugin {
57
69
  private bus: PipelineBus | null = null;
58
70
  private timeoutMs: number = 30_000;
59
71
  private maxHistoryTurns: number = 12;
60
72
  private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
61
73
  private activeGeneration: { contextId: string; controller: AbortController } | null = null;
74
+ // At most one speculative draft at a time; `hold` gates its side effects.
75
+ private speculativeDraft: {
76
+ contextId: string;
77
+ userText: string;
78
+ controller: AbortController;
79
+ hold: SpeculativeHold;
80
+ id: IncrementalUnitId;
81
+ } | null = null;
82
+ private iuLedger!: InMemoryIuLedger;
83
+ private readonly epochByContext = new Map<string, number>();
84
+ private turnEpochCounter = 0;
62
85
  private retryConfig: RetryConfig = readRetryConfig({});
63
86
  private disposers: Array<() => void> = [];
64
87
  // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
@@ -95,6 +118,17 @@ export class ReasoningBridge implements VoicePlugin {
95
118
  */
96
119
  sessionStore?: ReasonerSessionStore;
97
120
  sessionId?: string;
121
+ /**
122
+ * Speculative generation (LiveKit preemptive-generation / Deepgram Flux
123
+ * eager-EOT semantics): start the LLM on `eos.interim` with every side effect
124
+ * held back; commit as-is when `eos.turn_complete` confirms the same
125
+ * transcript, regenerate when it differs, discard on `eos.retracted`.
126
+ * Parallelizes LLM TTFT with the endpoint-confirmation window. Opt-in:
127
+ * unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
128
+ * eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
129
+ * `runStore` resume stays confirmed-turn-only.
130
+ */
131
+ speculative?: boolean;
98
132
  } = {},
99
133
  ) {
100
134
  if (this.opts.onResumeConflict === "replay") {
@@ -104,6 +138,21 @@ export class ReasoningBridge implements VoicePlugin {
104
138
 
105
139
  async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
106
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
+ });
107
156
  this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
108
157
  this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
109
158
  this.retryConfig = readRetryConfig(config);
@@ -125,6 +174,26 @@ export class ReasoningBridge implements VoicePlugin {
125
174
  // still-in-flight generation (see below).
126
175
  bus.on("eos.turn_complete", async (pkt: unknown) => {
127
176
  const eos = pkt as { text: string; contextId: string };
177
+ // R2: a draft for this exact transcript is already generating (or done) —
178
+ // promote it instead of paying a second LLM call. Flux guarantees the
179
+ // EndOfTurn transcript matches the preceding EagerEndOfTurn when no
180
+ // TurnResumed intervened, so commit-as-is is safe.
181
+ const draft = this.speculativeDraft;
182
+ if (
183
+ draft &&
184
+ draft.contextId === eos.contextId &&
185
+ draft.userText === eos.text &&
186
+ !draft.controller.signal.aborted &&
187
+ this.iuLedger.get(draft.id)?.state === "hypothesized"
188
+ ) {
189
+ this.iuLedger.commit(draft.id);
190
+ this.speculativeDraft = null;
191
+ for (const flush of draft.hold.buffered.splice(0)) flush();
192
+ return;
193
+ }
194
+ // Stale, mismatched, or failed draft: its speculation was wrong — drop it
195
+ // and answer the confirmed transcript fresh.
196
+ this.discardDraft();
128
197
  await this.processTurn(eos.text, eos.contextId);
129
198
  }, { concurrent: true }),
130
199
 
@@ -159,6 +228,7 @@ export class ReasoningBridge implements VoicePlugin {
159
228
  // said words the user never heard (nor amnesiac about the exchange).
160
229
  bus.on("interrupt.llm", (pkt: unknown) => {
161
230
  const contextId = (pkt as { contextId: string }).contextId;
231
+ if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
162
232
  if (this.activeGeneration?.contextId === contextId) {
163
233
  this.activeGeneration.controller.abort();
164
234
  this.activeGeneration = null;
@@ -169,9 +239,70 @@ export class ReasoningBridge implements VoicePlugin {
169
239
  }
170
240
  }),
171
241
  );
242
+
243
+ if (this.opts.speculative) {
244
+ this.disposers.push(
245
+ bus.on("eos.interim", async (pkt: unknown) => {
246
+ const interim = pkt as { text?: string; contextId: string };
247
+ const text = (interim.text ?? "").trim();
248
+ if (!text) return;
249
+ await this.runDraft(text, interim.contextId);
250
+ }, { concurrent: true }),
251
+ bus.on("eos.retracted", (pkt: unknown) => {
252
+ const contextId = (pkt as { contextId: string }).contextId;
253
+ if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
254
+ }),
255
+ );
256
+ }
172
257
  }
173
258
 
174
- private async processTurn(userText: string, contextId: string): Promise<void> {
259
+ /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
260
+ private async runDraft(userText: string, contextId: string): Promise<void> {
261
+ this.discardDraft();
262
+ const id = this.iuIdFor(contextId);
263
+ this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
264
+ const controller = new AbortController();
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 };
286
+ }
287
+
288
+ private discardDraft(): void {
289
+ const draft = this.speculativeDraft;
290
+ if (!draft) return;
291
+ this.speculativeDraft = null;
292
+ const committed = this.iuLedger.get(draft.id)?.state === "committed";
293
+ if (!committed) {
294
+ this.iuLedger.revoke(draft.id);
295
+ draft.controller.abort();
296
+ }
297
+ }
298
+
299
+ private async processTurn(
300
+ userText: string,
301
+ contextId: string,
302
+ hold?: SpeculativeHold,
303
+ presetController?: AbortController,
304
+ iuId?: IncrementalUnitId,
305
+ ): Promise<void> {
175
306
  if (!this.bus) return;
176
307
 
177
308
  this.turnUserText.set(contextId, userText);
@@ -179,10 +310,32 @@ export class ReasoningBridge implements VoicePlugin {
179
310
  // Handlers are concurrent, so a new turn can begin while a prior generation is
180
311
  // still in flight. Supersede it: abort the previous controller before starting.
181
312
  this.activeGeneration?.controller.abort();
182
- const controller = new AbortController();
313
+ const controller = presetController ?? new AbortController();
183
314
  this.activeGeneration = { contextId, controller };
315
+ const aid = this.assistantIuIdFor(contextId);
316
+ this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
184
317
  const signal = controller.signal;
185
318
 
319
+ // R2: while a speculative hold is unpromoted, every push/mutation buffers.
320
+ // Packets are constructed eagerly (their timestamps are event time); only
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";
326
+ const push = <T extends Parameters<PipelineBus["push"]>[1]>(route: Route, packet: T): void => {
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));
330
+ return;
331
+ }
332
+ this.bus?.push(route, packet);
333
+ };
334
+ const defer = (fn: () => void): void => {
335
+ if (isBuffering()) hold!.buffered.push(fn);
336
+ else fn();
337
+ };
338
+
186
339
  let reply = "";
187
340
  let emittedDelta = false;
188
341
  let committed = false;
@@ -192,7 +345,7 @@ export class ReasoningBridge implements VoicePlugin {
192
345
  // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
193
346
  // front-model tool call, so toolId/toolName are absent.
194
347
  const queryStartedMs = Date.now();
195
- this.bus.push(Route.Background, {
348
+ push(Route.Background, {
196
349
  kind: "delegate.query",
197
350
  contextId,
198
351
  timestampMs: queryStartedMs,
@@ -203,7 +356,9 @@ export class ReasoningBridge implements VoicePlugin {
203
356
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
204
357
  grounded = false;
205
358
  try {
206
- const pending = this.opts.runStore
359
+ // Drafts never consume a suspended-run pointer: takePending mutates the
360
+ // store, and a retracted draft would silently lose the resume.
361
+ const pending = this.opts.runStore && !hold
207
362
  ? await Promise.resolve(this.opts.runStore.takePending(contextId))
208
363
  : null;
209
364
  const resuming = pending !== null;
@@ -219,7 +374,7 @@ export class ReasoningBridge implements VoicePlugin {
219
374
  case "text-delta":
220
375
  reply += part.text;
221
376
  emittedDelta = true;
222
- this.bus.push(Route.Main, {
377
+ push(Route.Main, {
223
378
  kind: "llm.delta",
224
379
  contextId,
225
380
  timestampMs: Date.now(),
@@ -227,7 +382,7 @@ export class ReasoningBridge implements VoicePlugin {
227
382
  });
228
383
  break;
229
384
  case "tool-call":
230
- this.bus.push(Route.Main, {
385
+ push(Route.Main, {
231
386
  kind: "llm.tool_call",
232
387
  contextId,
233
388
  timestampMs: Date.now(),
@@ -238,7 +393,7 @@ export class ReasoningBridge implements VoicePlugin {
238
393
  break;
239
394
  case "tool-result":
240
395
  grounded = true;
241
- this.bus.push(Route.Main, {
396
+ push(Route.Main, {
242
397
  kind: "llm.tool_result",
243
398
  contextId,
244
399
  timestampMs: Date.now(),
@@ -250,12 +405,18 @@ export class ReasoningBridge implements VoicePlugin {
250
405
  case "error":
251
406
  throw part.cause;
252
407
  case "finish":
253
- this.recordFinishReason(contextId, "llm.finish_reason", part.reason);
408
+ push(Route.Background, {
409
+ kind: "metric.conversation",
410
+ contextId,
411
+ timestampMs: Date.now(),
412
+ name: "llm.finish_reason",
413
+ value: part.reason,
414
+ });
254
415
  finishReason = part.reason;
255
416
  break;
256
417
  case "suspended": {
257
418
  if (part.prompt && !emittedDelta) {
258
- this.bus.push(Route.Main, {
419
+ push(Route.Main, {
259
420
  kind: "llm.delta",
260
421
  contextId,
261
422
  timestampMs: Date.now(),
@@ -264,14 +425,14 @@ export class ReasoningBridge implements VoicePlugin {
264
425
  reply += part.prompt;
265
426
  }
266
427
  if (signal.aborted) return;
267
- this.bus.push(Route.Main, {
428
+ push(Route.Main, {
268
429
  kind: "llm.done",
269
430
  contextId,
270
431
  timestampMs: Date.now(),
271
432
  text: reply,
272
433
  });
273
- this.rememberTurn(userText, reply, contextId);
274
- this.bus.push(Route.Background, {
434
+ defer(() => this.rememberTurn(userText, reply, contextId));
435
+ push(Route.Background, {
275
436
  kind: "reasoning.suspended",
276
437
  contextId,
277
438
  timestampMs: Date.now(),
@@ -280,7 +441,13 @@ export class ReasoningBridge implements VoicePlugin {
280
441
  payload: part.payload,
281
442
  });
282
443
  if (this.opts.runStore) {
283
- await Promise.resolve(this.opts.runStore.save(contextId, part.runId));
444
+ const store = this.opts.runStore;
445
+ const runId = part.runId;
446
+ if (isBuffering()) {
447
+ hold!.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
448
+ } else {
449
+ await Promise.resolve(store.save(contextId, runId));
450
+ }
284
451
  }
285
452
  committed = true;
286
453
  return;
@@ -296,7 +463,7 @@ export class ReasoningBridge implements VoicePlugin {
296
463
  // recoverably — the caller hears the graceful fallback, the call stays up.
297
464
  if (finishReason !== "stop" && finishReason !== "length") {
298
465
  if (signal.aborted) return;
299
- this.bus.push(Route.Critical, {
466
+ push(Route.Critical, {
300
467
  kind: "llm.error",
301
468
  contextId,
302
469
  timestampMs: Date.now(),
@@ -308,7 +475,7 @@ export class ReasoningBridge implements VoicePlugin {
308
475
  return;
309
476
  }
310
477
  if (finishReason === "length") {
311
- this.bus.push(Route.Background, {
478
+ push(Route.Background, {
312
479
  kind: "metric.conversation",
313
480
  contextId,
314
481
  timestampMs: Date.now(),
@@ -322,14 +489,14 @@ export class ReasoningBridge implements VoicePlugin {
322
489
  if (signal.aborted) return;
323
490
 
324
491
  const answeredMs = Date.now();
325
- this.bus.push(Route.Main, {
492
+ push(Route.Main, {
326
493
  kind: "llm.done",
327
494
  contextId,
328
495
  timestampMs: answeredMs,
329
496
  text: reply,
330
497
  });
331
498
  // G2 observability: the reasoner produced the turn's final answer.
332
- this.bus.push(Route.Background, {
499
+ push(Route.Background, {
333
500
  kind: "delegate.result",
334
501
  contextId,
335
502
  timestampMs: answeredMs,
@@ -338,7 +505,7 @@ export class ReasoningBridge implements VoicePlugin {
338
505
  durationMs: answeredMs - queryStartedMs,
339
506
  grounded,
340
507
  });
341
- this.rememberTurn(userText, reply, contextId);
508
+ defer(() => this.rememberTurn(userText, reply, contextId));
342
509
  if (this.opts.runStore && resuming) {
343
510
  await Promise.resolve(this.opts.runStore.discard(contextId));
344
511
  }
@@ -349,7 +516,7 @@ export class ReasoningBridge implements VoicePlugin {
349
516
  const category = categorizeLlmError(err);
350
517
  const recoverable = isRecoverable(category);
351
518
  if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
352
- this.bus.push(Route.Critical, {
519
+ push(Route.Critical, {
353
520
  kind: "llm.error",
354
521
  contextId,
355
522
  timestampMs: Date.now(),
@@ -361,7 +528,7 @@ export class ReasoningBridge implements VoicePlugin {
361
528
  return;
362
529
  }
363
530
 
364
- this.bus.push(Route.Background, {
531
+ push(Route.Background, {
365
532
  kind: "metric.conversation",
366
533
  contextId,
367
534
  timestampMs: Date.now(),
@@ -379,21 +546,8 @@ export class ReasoningBridge implements VoicePlugin {
379
546
  }
380
547
  }
381
548
 
382
- private recordFinishReason(
383
- contextId: string,
384
- name: string,
385
- finishReason: "stop" | "tool" | "length",
386
- ): void {
387
- this.bus?.push(Route.Background, {
388
- kind: "metric.conversation",
389
- contextId,
390
- timestampMs: Date.now(),
391
- name,
392
- value: finishReason,
393
- });
394
- }
395
-
396
549
  async close(): Promise<void> {
550
+ this.discardDraft();
397
551
  this.activeGeneration?.controller.abort();
398
552
  this.activeGeneration = null;
399
553
  for (const dispose of this.disposers.splice(0)) dispose();
@@ -409,6 +563,7 @@ export class ReasoningBridge implements VoicePlugin {
409
563
  const assistantMsg = { role: "assistant" as const, content: assistantText };
410
564
  this.history.push({ role: "user", content: userText }, assistantMsg);
411
565
  this.assistantMsgByContext.set(contextId, assistantMsg);
566
+ this.iuLedger.commit(this.assistantIuIdFor(contextId));
412
567
  this.trimHistory();
413
568
  this.persistHistory();
414
569
  }
@@ -454,6 +609,15 @@ export class ReasoningBridge implements VoicePlugin {
454
609
  */
455
610
  private commitInterruptedHistory(contextId: string): void {
456
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
+ }
457
621
  const existing = this.assistantMsgByContext.get(contextId);
458
622
  if (existing) {
459
623
  if (spoken) {
@@ -493,6 +657,10 @@ export class ReasoningBridge implements VoicePlugin {
493
657
  }
494
658
 
495
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
+ }
496
664
  this.spokenByContext.delete(contextId);
497
665
  this.turnUserText.delete(contextId);
498
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
+ });