@kuralle-syrinx/aisdk 4.1.0 → 4.3.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,20 +1,38 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/aisdk",
3
- "version": "4.1.0",
3
+ "version": "4.3.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.3.0"
14
32
  },
15
33
  "devDependencies": {
16
34
  "typescript": "^5.7.0",
17
- "vitest": "^2.1.0"
35
+ "vitest": "^3.2.6"
18
36
  },
19
37
  "scripts": {
20
38
  "typecheck": "tsc --noEmit",
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  streamText,
10
10
  type FinishReason,
11
+ type LanguageModelUsage,
11
12
  type ModelMessage,
12
13
  type TextStreamPart,
13
14
  type ToolChoice,
@@ -19,6 +20,7 @@ import {
19
20
  type Reasoner,
20
21
  type ReasonerMessage,
21
22
  type ReasonerTurn,
23
+ type ReasonerUsage,
22
24
  type ReasoningPart,
23
25
  } from "@kuralle-syrinx/core";
24
26
  import type { AISDKStreamFactory } from "./index.js";
@@ -79,7 +81,7 @@ async function* streamFromStreamText(config: StreamTextConfig, turn: ReasonerTur
79
81
  messages,
80
82
  abortSignal: turn.signal,
81
83
  });
82
- yield* mapTextStreamParts(result.fullStream);
84
+ yield* mapTextStreamParts(result.fullStream, modelIdentity(config.model));
83
85
  }
84
86
 
85
87
  async function* streamFromFactory(factory: AISDKStreamFactory, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
@@ -112,6 +114,7 @@ function mapMessages(messages: readonly ReasonerMessage[]): ModelMessage[] {
112
114
 
113
115
  async function* mapTextStreamParts(
114
116
  source: AsyncIterable<TextStreamPart<ToolSet>>,
117
+ identity: { provider?: string; model?: string } = {},
115
118
  ): AsyncGenerator<ReasoningPart> {
116
119
  let accumulatedText = "";
117
120
  let sawFinish = false;
@@ -150,7 +153,12 @@ async function* mapTextStreamParts(
150
153
  return;
151
154
  }
152
155
  case "abort": {
156
+ // An abort is a benign cancellation (barge-in aborted the reasoner), NOT an error.
157
+ // Carry the web/Node standard `name === "AbortError"` so downstream `isAbortError`
158
+ // guards (realtime-bridge runDelegate, ReasoningBridge) swallow it instead of
159
+ // surfacing a fatal `bridge.error/internal_fault`.
153
160
  const cause = new Error(part.reason ?? "AI SDK stream aborted");
161
+ cause.name = "AbortError";
154
162
  yield toErrorPart(cause);
155
163
  return;
156
164
  }
@@ -171,6 +179,11 @@ async function* mapTextStreamParts(
171
179
  type: "finish",
172
180
  reason: mapFinishReason(part.finishReason),
173
181
  text: accumulatedText,
182
+ // The AI SDK finish part carries totalUsage; forward it so the bridge can
183
+ // record cost. Omit entirely when the provider reported nothing.
184
+ ...(part.totalUsage
185
+ ? { usage: { ...identity, ...toReasonerUsage(part.totalUsage) } }
186
+ : {}),
174
187
  };
175
188
  return;
176
189
  }
@@ -198,6 +211,32 @@ async function* mapTextStreamParts(
198
211
  }
199
212
  }
200
213
 
214
+ /**
215
+ * Extract provider/model for cost attribution. The AI SDK model is either a bare id
216
+ * string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
217
+ * Without this, usage counters are tagged with empty provider/model and spend cannot be
218
+ * attributed to a model — the whole point of the low-cardinality tags.
219
+ */
220
+ export function modelIdentity(model: StreamTextConfig["model"]): { provider?: string; model?: string } {
221
+ if (typeof model === "string") return { model };
222
+ const m = model as { provider?: unknown; modelId?: unknown };
223
+ return {
224
+ ...(typeof m.provider === "string" ? { provider: m.provider } : {}),
225
+ ...(typeof m.modelId === "string" ? { model: m.modelId } : {}),
226
+ };
227
+ }
228
+
229
+ /** Copy only the token fields the SDK actually populated (all are `number | undefined`). */
230
+ function toReasonerUsage(u: LanguageModelUsage): ReasonerUsage {
231
+ return {
232
+ ...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
233
+ ...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
234
+ ...(u.totalTokens !== undefined ? { totalTokens: u.totalTokens } : {}),
235
+ ...(u.cachedInputTokens !== undefined ? { cachedInputTokens: u.cachedInputTokens } : {}),
236
+ ...(u.reasoningTokens !== undefined ? { reasoningTokens: u.reasoningTokens } : {}),
237
+ };
238
+ }
239
+
201
240
  function mapFinishReason(finishReason: FinishReason): "stop" | "tool" | "length" {
202
241
  if (finishReason === "tool-calls") return "tool";
203
242
  if (finishReason === "length") return "length";
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
 
@@ -70,6 +70,10 @@ export class ReasoningBridge implements VoicePlugin {
70
70
  private timeoutMs: number = 30_000;
71
71
  private maxHistoryTurns: number = 12;
72
72
  private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
73
+ private readonly transientContextMessages = new Set<{
74
+ role: "system";
75
+ content: string;
76
+ }>();
73
77
  private activeGeneration: { contextId: string; controller: AbortController } | null = null;
74
78
  // At most one speculative draft at a time; `hold` gates its side effects.
75
79
  private speculativeDraft: {
@@ -77,7 +81,11 @@ export class ReasoningBridge implements VoicePlugin {
77
81
  userText: string;
78
82
  controller: AbortController;
79
83
  hold: SpeculativeHold;
84
+ id: IncrementalUnitId;
80
85
  } | null = null;
86
+ private iuLedger!: InMemoryIuLedger;
87
+ private readonly epochByContext = new Map<string, number>();
88
+ private turnEpochCounter = 0;
81
89
  private retryConfig: RetryConfig = readRetryConfig({});
82
90
  private disposers: Array<() => void> = [];
83
91
  // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
@@ -123,6 +131,21 @@ export class ReasoningBridge implements VoicePlugin {
123
131
  * unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
124
132
  * eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
125
133
  * `runStore` resume stays confirmed-turn-only.
134
+ *
135
+ * **Only enable this with a confidence-gated eager endpointer (Deepgram Flux).**
136
+ * Promotion requires `draft.userText === eos.text` — exact equality. Flux
137
+ * guarantees the EndOfTurn transcript matches the preceding EagerEndOfTurn when
138
+ * no TurnResumed intervened, so drafts promote. `PipecatEOSPlugin` instead pushes
139
+ * `eos.interim` on EVERY non-empty STT interim, and each interim discards the prior
140
+ * draft and starts a new call, so the surviving draft is built on an interim
141
+ * transcript that rarely matches the final one.
142
+ *
143
+ * Measured live on smart-turn, one turn, university fixture:
144
+ * ON — started 13, discarded 13, promoted 0; ttfa 1724ms, llmTtft 1269ms
145
+ * OFF — started 0, discarded 0, promoted 0; ttfa 1302ms, llmTtft 1025ms
146
+ *
147
+ * Thirteen wasted LLM calls, zero promotions, and latency got *worse*. The lever
148
+ * is Flux-specific; on a per-interim endpointer it is pure cost.
126
149
  */
127
150
  speculative?: boolean;
128
151
  } = {},
@@ -132,8 +155,29 @@ export class ReasoningBridge implements VoicePlugin {
132
155
  }
133
156
  }
134
157
 
158
+ injectContext(text: string): void {
159
+ const message = { role: "system" as const, content: text };
160
+ this.history.push(message);
161
+ this.transientContextMessages.add(message);
162
+ }
163
+
135
164
  async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
136
165
  this.bus = bus;
166
+ this.iuLedger = new InMemoryIuLedger((a) => {
167
+ const detail =
168
+ a.kind === "terminal_op"
169
+ ? `${a.op} on ${a.state} IU`
170
+ : `${a.op} on unknown IU`;
171
+ this.bus?.push(Route.Background, {
172
+ kind: "llm.error",
173
+ contextId: a.id.contextId,
174
+ timestampMs: Date.now(),
175
+ component: "iu_ledger",
176
+ category: ErrorCategory.InternalFault,
177
+ cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
178
+ isRecoverable: true,
179
+ });
180
+ });
137
181
  this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
138
182
  this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
139
183
  this.retryConfig = readRetryConfig(config);
@@ -165,10 +209,11 @@ export class ReasoningBridge implements VoicePlugin {
165
209
  draft.contextId === eos.contextId &&
166
210
  draft.userText === eos.text &&
167
211
  !draft.controller.signal.aborted &&
168
- !draft.hold.failed
212
+ this.iuLedger.get(draft.id)?.state === "hypothesized"
169
213
  ) {
214
+ this.iuLedger.commit(draft.id);
170
215
  this.speculativeDraft = null;
171
- draft.hold.promoted = true;
216
+ this.metric(eos.contextId, "speculative.draft_promoted");
172
217
  for (const flush of draft.hold.buffered.splice(0)) flush();
173
218
  return;
174
219
  }
@@ -240,17 +285,53 @@ export class ReasoningBridge implements VoicePlugin {
240
285
  /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
241
286
  private async runDraft(userText: string, contextId: string): Promise<void> {
242
287
  this.discardDraft();
288
+ this.metric(contextId, "speculative.draft_started");
289
+ const id = this.iuIdFor(contextId);
290
+ this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
243
291
  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);
292
+ const hold: SpeculativeHold = { buffered: [] };
293
+ this.speculativeDraft = { contextId, userText, controller, hold, id };
294
+ await this.processTurn(userText, contextId, hold, controller, id);
295
+ }
296
+
297
+ private iuIdFor(contextId: string): IncrementalUnitId {
298
+ let epoch = this.epochByContext.get(contextId);
299
+ if (epoch === undefined) {
300
+ epoch = ++this.turnEpochCounter;
301
+ this.epochByContext.set(contextId, epoch);
302
+ }
303
+ return { contextId, iuId: contextId, epoch };
304
+ }
305
+
306
+ private assistantIuIdFor(contextId: string): IncrementalUnitId {
307
+ let epoch = this.epochByContext.get(contextId);
308
+ if (epoch === undefined) {
309
+ epoch = ++this.turnEpochCounter;
310
+ this.epochByContext.set(contextId, epoch);
311
+ }
312
+ return { contextId, iuId: `${contextId}#assistant`, epoch };
247
313
  }
248
314
 
249
315
  private discardDraft(): void {
250
316
  const draft = this.speculativeDraft;
251
317
  if (!draft) return;
252
318
  this.speculativeDraft = null;
253
- if (!draft.hold.promoted) draft.controller.abort();
319
+ const committed = this.iuLedger.get(draft.id)?.state === "committed";
320
+ if (!committed) {
321
+ this.metric(draft.contextId, "speculative.draft_discarded");
322
+ this.iuLedger.revoke(draft.id);
323
+ draft.controller.abort();
324
+ }
325
+ }
326
+
327
+ private metric(contextId: string, name: string, value = "1"): void {
328
+ this.bus?.push(Route.Background, {
329
+ kind: "metric.conversation",
330
+ contextId,
331
+ timestampMs: Date.now(),
332
+ name,
333
+ value,
334
+ });
254
335
  }
255
336
 
256
337
  private async processTurn(
@@ -258,6 +339,7 @@ export class ReasoningBridge implements VoicePlugin {
258
339
  contextId: string,
259
340
  hold?: SpeculativeHold,
260
341
  presetController?: AbortController,
342
+ iuId?: IncrementalUnitId,
261
343
  ): Promise<void> {
262
344
  if (!this.bus) return;
263
345
 
@@ -268,21 +350,27 @@ export class ReasoningBridge implements VoicePlugin {
268
350
  this.activeGeneration?.controller.abort();
269
351
  const controller = presetController ?? new AbortController();
270
352
  this.activeGeneration = { contextId, controller };
353
+ const aid = this.assistantIuIdFor(contextId);
354
+ this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
271
355
  const signal = controller.signal;
272
356
 
273
357
  // R2: while a speculative hold is unpromoted, every push/mutation buffers.
274
358
  // Packets are constructed eagerly (their timestamps are event time); only
275
359
  // delivery is deferred. Promotion replays in order, then later effects run live.
360
+ const isBuffering = (): boolean =>
361
+ hold !== undefined &&
362
+ iuId !== undefined &&
363
+ this.iuLedger.get(iuId)?.state !== "committed";
276
364
  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));
365
+ if (isBuffering()) {
366
+ if ((packet as { kind?: string }).kind === "llm.error" && iuId) this.iuLedger.revoke(iuId);
367
+ hold!.buffered.push(() => this.bus?.push(route, packet));
280
368
  return;
281
369
  }
282
370
  this.bus?.push(route, packet);
283
371
  };
284
372
  const defer = (fn: () => void): void => {
285
- if (hold && !hold.promoted) hold.buffered.push(fn);
373
+ if (isBuffering()) hold!.buffered.push(fn);
286
374
  else fn();
287
375
  };
288
376
 
@@ -290,6 +378,20 @@ export class ReasoningBridge implements VoicePlugin {
290
378
  let emittedDelta = false;
291
379
  let committed = false;
292
380
  let grounded = false;
381
+ let passStartedMs = 0;
382
+ let passTtftRecorded = false;
383
+ const recordPassTtft = (): void => {
384
+ if (passTtftRecorded) return;
385
+ passTtftRecorded = true;
386
+ const firstOutputMs = Date.now();
387
+ push(Route.Main, {
388
+ kind: "metric.conversation",
389
+ contextId,
390
+ timestampMs: firstOutputMs,
391
+ name: "llm.pass_ttft_ms",
392
+ value: String(firstOutputMs - passStartedMs),
393
+ });
394
+ };
293
395
 
294
396
  // G2 observability: the turn's query is on its way to the reasoner (Background
295
397
  // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
@@ -305,6 +407,15 @@ export class ReasoningBridge implements VoicePlugin {
305
407
  try {
306
408
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
307
409
  grounded = false;
410
+ passStartedMs = Date.now();
411
+ passTtftRecorded = false;
412
+ push(Route.Main, {
413
+ kind: "metric.conversation",
414
+ contextId,
415
+ timestampMs: passStartedMs,
416
+ name: "llm.call_started",
417
+ value: "1",
418
+ });
308
419
  try {
309
420
  // Drafts never consume a suspended-run pointer: takePending mutates the
310
421
  // store, and a retracted draft would silently lose the resume.
@@ -324,6 +435,7 @@ export class ReasoningBridge implements VoicePlugin {
324
435
  case "text-delta":
325
436
  reply += part.text;
326
437
  emittedDelta = true;
438
+ recordPassTtft();
327
439
  push(Route.Main, {
328
440
  kind: "llm.delta",
329
441
  contextId,
@@ -332,6 +444,7 @@ export class ReasoningBridge implements VoicePlugin {
332
444
  });
333
445
  break;
334
446
  case "tool-call":
447
+ recordPassTtft();
335
448
  push(Route.Main, {
336
449
  kind: "llm.tool_call",
337
450
  contextId,
@@ -351,7 +464,64 @@ export class ReasoningBridge implements VoicePlugin {
351
464
  toolName: part.toolName,
352
465
  result: part.result,
353
466
  });
467
+ passStartedMs = Date.now();
468
+ passTtftRecorded = false;
469
+ push(Route.Main, {
470
+ kind: "metric.conversation",
471
+ contextId,
472
+ timestampMs: passStartedMs,
473
+ name: "llm.call_started",
474
+ value: "1",
475
+ });
354
476
  break;
477
+ case "control":
478
+ push(Route.Background, {
479
+ kind: "delegate.result",
480
+ contextId,
481
+ timestampMs: Date.now(),
482
+ query: userText,
483
+ answer: reply,
484
+ durationMs: Date.now() - queryStartedMs,
485
+ grounded,
486
+ control: {
487
+ name: part.name,
488
+ payload: part.payload,
489
+ },
490
+ });
491
+ break;
492
+ case "blocked": {
493
+ if (signal.aborted) return;
494
+ const safeMessage = part.userFacingMessage;
495
+ const blockedMs = Date.now();
496
+ push(Route.Main, {
497
+ kind: "llm.delta",
498
+ contextId,
499
+ timestampMs: blockedMs,
500
+ text: safeMessage,
501
+ });
502
+ push(Route.Main, {
503
+ kind: "llm.done",
504
+ contextId,
505
+ timestampMs: blockedMs,
506
+ text: safeMessage,
507
+ });
508
+ push(Route.Background, {
509
+ kind: "delegate.result",
510
+ contextId,
511
+ timestampMs: blockedMs,
512
+ query: userText,
513
+ answer: safeMessage,
514
+ durationMs: blockedMs - queryStartedMs,
515
+ grounded,
516
+ blocked: {
517
+ userFacingMessage: safeMessage,
518
+ payload: part.payload,
519
+ },
520
+ });
521
+ defer(() => this.rememberTurn(userText, safeMessage, contextId));
522
+ committed = true;
523
+ return;
524
+ }
355
525
  case "error":
356
526
  throw part.cause;
357
527
  case "finish":
@@ -362,6 +532,18 @@ export class ReasoningBridge implements VoicePlugin {
362
532
  name: "llm.finish_reason",
363
533
  value: part.reason,
364
534
  });
535
+ // Record billable token usage — the field the bridge used to drop.
536
+ // A turn with tool calls produces several finish parts; the session
537
+ // accumulator sums them, so emit per-finish rather than once per turn.
538
+ if (part.usage) {
539
+ push(Route.Background, {
540
+ kind: "usage.recorded",
541
+ contextId,
542
+ timestampMs: Date.now(),
543
+ stage: "llm",
544
+ ...part.usage,
545
+ });
546
+ }
365
547
  finishReason = part.reason;
366
548
  break;
367
549
  case "suspended": {
@@ -393,8 +575,8 @@ export class ReasoningBridge implements VoicePlugin {
393
575
  if (this.opts.runStore) {
394
576
  const store = this.opts.runStore;
395
577
  const runId = part.runId;
396
- if (hold && !hold.promoted) {
397
- hold.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
578
+ if (isBuffering()) {
579
+ hold!.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
398
580
  } else {
399
581
  await Promise.resolve(store.save(contextId, runId));
400
582
  }
@@ -506,6 +688,7 @@ export class ReasoningBridge implements VoicePlugin {
506
688
  this.assistantMsgByContext.clear();
507
689
  this.wordTimestampsByContext.clear();
508
690
  this.playedOutMsByContext.clear();
691
+ this.transientContextMessages.clear();
509
692
  this.bus = null;
510
693
  }
511
694
 
@@ -513,6 +696,7 @@ export class ReasoningBridge implements VoicePlugin {
513
696
  const assistantMsg = { role: "assistant" as const, content: assistantText };
514
697
  this.history.push({ role: "user", content: userText }, assistantMsg);
515
698
  this.assistantMsgByContext.set(contextId, assistantMsg);
699
+ this.iuLedger.commit(this.assistantIuIdFor(contextId));
516
700
  this.trimHistory();
517
701
  this.persistHistory();
518
702
  }
@@ -523,7 +707,14 @@ export class ReasoningBridge implements VoicePlugin {
523
707
  const sessionId = this.opts.sessionId;
524
708
  if (!store || !sessionId) return;
525
709
  try {
526
- void Promise.resolve(store.save(sessionId, this.history.map((message) => ({ ...message })))).catch(
710
+ void Promise.resolve(
711
+ store.save(
712
+ sessionId,
713
+ this.history
714
+ .filter((message) => !this.transientContextMessages.has(message as { role: "system"; content: string }))
715
+ .map((message) => ({ ...message })),
716
+ ),
717
+ ).catch(
527
718
  () => undefined,
528
719
  );
529
720
  } catch {
@@ -558,6 +749,15 @@ export class ReasoningBridge implements VoicePlugin {
558
749
  */
559
750
  private commitInterruptedHistory(contextId: string): void {
560
751
  const spoken = this.computeSpokenPrefix(contextId);
752
+ const aid = this.assistantIuIdFor(contextId);
753
+ const ms = this.playedOutMsByContext.get(contextId);
754
+ const prefix = { chars: spoken.length, ms };
755
+ const assistantIu = this.iuLedger.get(aid);
756
+ if (assistantIu?.state === "hypothesized") {
757
+ this.iuLedger.commit(aid, prefix);
758
+ } else if (assistantIu?.state === "committed") {
759
+ assistantIu.committedPrefix = prefix;
760
+ }
561
761
  const existing = this.assistantMsgByContext.get(contextId);
562
762
  if (existing) {
563
763
  if (spoken) {
@@ -594,9 +794,16 @@ export class ReasoningBridge implements VoicePlugin {
594
794
  for (const [ctx, msg] of this.assistantMsgByContext) {
595
795
  if (!this.history.includes(msg)) this.clearTurnState(ctx);
596
796
  }
797
+ for (const message of this.transientContextMessages) {
798
+ if (!this.history.includes(message)) this.transientContextMessages.delete(message);
799
+ }
597
800
  }
598
801
 
599
802
  private clearTurnState(contextId: string): void {
803
+ const aid = this.assistantIuIdFor(contextId);
804
+ if (this.iuLedger.get(aid)?.state === "hypothesized") {
805
+ this.iuLedger.revoke(aid);
806
+ }
600
807
  this.spokenByContext.delete(contextId);
601
808
  this.turnUserText.delete(contextId);
602
809
  this.assistantMsgByContext.delete(contextId);