@matatbread/matbot-core 0.3.4 → 0.3.5

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
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.4",
7
+ "version": "0.3.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.3.4"
38
+ "@matatbread/matbot-plugin-api": "^0.3.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/runner.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- Session, MessageContent, Usage, ProviderMeta,
2
+ Session, MessageContent, Usage, ProviderMeta, DeferredCorrection,
3
3
  PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
4
  Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
5
5
  } from './types.js';
@@ -10,6 +10,15 @@ import { HookRegistry } from './hooks.js';
10
10
  import { appendMessage, createMessage } from './session.js';
11
11
  import { addUsage } from './usage.js';
12
12
 
13
+ // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
14
+ // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
15
+ // internal token that reads as a real failure. A steer's continuation turn is the one place a model
16
+ // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
17
+ // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
18
+ const INTERRUPTED_TOOL_RESULT = {
19
+ error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
20
+ } as const;
21
+
13
22
  export interface RunSessionOpts {
14
23
  session: Session;
15
24
  config: RunConfig;
@@ -111,8 +120,42 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
111
120
  // drops non-result content from tool-role messages — a separate trailing message would break on
112
121
  // one or the other).
113
122
  // injectedEphemeral (a pump-supplied retract-redo's context) leads, then screen's own — both share
114
- // the identical tail-fold path below.
115
- const ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
123
+ // the identical tail-fold path below. Mutable: a raced screen verdict (below) folds its correction
124
+ // in here when it fires, and the loop re-runs with it.
125
+ let ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
126
+
127
+ // Raced screen verdicts (see DeferredScreen): polled — synchronously, never awaited — at each loop
128
+ // edge. `pollDeferred` claims any that have fired this poll (exactly once each) and merges their
129
+ // corrections; `applyCorrection` then folds them in and returns any durable blocks to emit live.
130
+ const deferred = screen.deferred;
131
+ const pollDeferred = (): DeferredCorrection | undefined => {
132
+ if (deferred.length === 0) return undefined;
133
+ const ephemeralC: MessageContent[] = [];
134
+ const durableC: MessageContent[] = [];
135
+ for (const d of deferred) {
136
+ const c = d.claim();
137
+ if (!c) continue;
138
+ if (c.ephemeral) ephemeralC.push(...c.ephemeral);
139
+ if (c.durable) durableC.push(...c.durable);
140
+ }
141
+ return (ephemeralC.length > 0 || durableC.length > 0) ? { ephemeral: ephemeralC, durable: durableC } : undefined;
142
+ };
143
+ // Apply a claimed correction: `ephemeral` leads the tail-fold for the re-run; `durable` is folded onto
144
+ // the turn's user message (persisted, re-run sees it in history) and RETURNED so the caller emits it
145
+ // live as a `robo-user` event — the durable twin, preserving a contextual fire's persistence even
146
+ // though the verdict landed mid-turn. Mutates `ephemeral`/`session` in place.
147
+ const applyCorrection = (c: DeferredCorrection): MessageContent[] => {
148
+ if (c.ephemeral && c.ephemeral.length > 0) ephemeral = [...c.ephemeral, ...ephemeral];
149
+ if (c.durable && c.durable.length > 0) {
150
+ const idx = session.messages.findLastIndex(m => m.role === 'user');
151
+ if (idx >= 0) {
152
+ session = { ...session, messages: session.messages.map((m, i) =>
153
+ i === idx ? { ...m, content: [...m.content, ...c.durable!] } : m) };
154
+ return c.durable;
155
+ }
156
+ }
157
+ return [];
158
+ };
116
159
 
117
160
  // ── 2. System context (built once per submit, never persisted) ─────────────
118
161
 
@@ -138,10 +181,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
138
181
  return;
139
182
  }
140
183
 
184
+ // A raced screen verdict that already fired (settled during setup, or while a previous tool round
185
+ // ran): fold its correction in before generating, so this call is informed directly rather than
186
+ // discarded. This is the no-waste path — the verdict landed before we spent any tokens.
187
+ const early = pollDeferred();
188
+ if (early) { const dur = applyCorrection(early); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; }
189
+
141
190
  const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta }> = [];
142
191
  const assistantParts: MessageContent[] = [];
143
192
  let textAcc = '';
144
193
  let turnUsage: Usage | undefined;
194
+ // Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
195
+ let restart = false;
145
196
 
146
197
  // One provider call. The outgoing array (system context + history, with screen's ephemeral
147
198
  // blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
@@ -164,8 +215,21 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
164
215
  const advertised = opts.toolPresenter
165
216
  ? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
166
217
  : [...tools.values()];
218
+ // Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
219
+ // to cancel the in-flight request and stop backend generation, without aborting the whole turn.
220
+ const callAc = new AbortController();
221
+ const callSignal = AbortSignal.any([signal, callAc.signal]);
167
222
  try {
168
- for await (const ev of provider.complete(outgoing, providerConfig, advertised, signal)) {
223
+ for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
224
+ // A raced verdict firing mid-generation changes the premise: discard this doomed response and
225
+ // restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
226
+ // classifier faster than time-to-first-token is caught before any token reaches the frontend.
227
+ const hit = pollDeferred();
228
+ if (hit) {
229
+ const dur = applyCorrection(hit);
230
+ if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId };
231
+ restart = true; callAc.abort('screen-restart'); break;
232
+ }
169
233
  switch (ev.type) {
170
234
  case 'text-delta':
171
235
  textAcc += ev.delta;
@@ -202,7 +266,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
202
266
  }
203
267
  }
204
268
  } catch (e: any) {
205
- if (signal.aborted) {
269
+ // A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
270
+ // through to the restart below rather than surfacing it as a turn abort or an error.
271
+ if (restart || (callAc.signal.aborted && !signal.aborted)) {
272
+ restart = true;
273
+ } else if (signal.aborted) {
206
274
  // Save whatever the LLM streamed before the abort hit.
207
275
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
208
276
  if (assistantParts.length > 0) {
@@ -214,11 +282,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
214
282
  await store.set(session.id, session);
215
283
  yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
216
284
  return;
285
+ } else {
286
+ yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
287
+ return;
217
288
  }
218
- yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
219
- return;
220
289
  }
221
290
 
291
+ // In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
292
+ // raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
293
+ // counterpart to a post-commit `followup` retract.
294
+ if (restart) continue;
295
+
296
+ // Pre-commit poll: the verdict may have settled exactly as the stream ended. Discard and re-run
297
+ // rather than commit-then-retract, keeping the correction on the cheaper in-situ path.
298
+ const late = pollDeferred();
299
+ if (late) { const dur = applyCorrection(late); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; continue; }
300
+
222
301
  // Build and store assistant message
223
302
  if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
224
303
  for (const tc of pendingCalls) {
@@ -318,6 +397,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
318
397
  isError = true;
319
398
  }
320
399
 
400
+ // A tool that errored under an aborted signal did so because it was cut off (a steer interrupt,
401
+ // a user cancel), not a genuine fault — reframe it before it is recorded, so the raw abort reason
402
+ // never reaches the model or the transcript. Covers both the throw and the yielded-`error` paths;
403
+ // a successful result is left untouched.
404
+ if (signal.aborted && isError) {
405
+ result = INTERRUPTED_TOOL_RESULT;
406
+ }
407
+
321
408
  // toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
322
409
  // or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
323
410
  result = await hookReg.runToolResult({
@@ -2,6 +2,7 @@ import type {
2
2
  Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
3
3
  Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
4
4
  ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
5
+ SteeringPolicy, SteeringDecision,
5
6
  } from './types.js';
6
7
  import type { MatbotPlugin } from './plugin.js';
7
8
  import type { HookRegistry } from './hooks.js';
@@ -22,6 +23,10 @@ export interface SessionRunnerDeps {
22
23
  // Resolved live (registers after boot): picks which tools are advertised to the model per provider
23
24
  // call. Absent ⇒ the whole turn snapshot is advertised. Threaded into runSession per turn.
24
25
  toolPresenter?: () => ToolPresenter | undefined;
26
+ // Resolved live (registers after boot): decides how a mid-turn submission under `mode: 'auto'` is
27
+ // disposed (queue vs interrupt) and supplies an interrupt's continuation nudge. Absent ⇒ the runner's
28
+ // own defaults (DEFAULT_STEERING_POLICY / DEFAULT_STEER_NUDGE).
29
+ steeringPolicy?: () => SteeringPolicy | undefined;
25
30
  hooks?: HookRegistry;
26
31
  systemContext?: SystemContextRegistry;
27
32
  vault?: Vault;
@@ -50,11 +55,30 @@ interface QueuedItem {
50
55
  // message already exists) and hands `ephemeral` (the trigger tool's output) to runSession, which
51
56
  // tail-folds it for this run only. `content` is empty for such an item — there is no new bubble.
52
57
  redo?: { ephemeral: MessageContent[] };
58
+ // Turn-scoped ephemeral context to fold onto this item's turn (tail-folded, never persisted) — used
59
+ // to carry an interrupt's "keep going, noting the above" nudge. Feeds runSession's injectedEphemeral,
60
+ // the same path `redo` uses; the two are mutually exclusive in practice (redo re-runs an existing
61
+ // turn with no new message, a steer introduces a new user message).
62
+ injectEphemeral?: MessageContent[];
53
63
  prompt?: PromptFn;
54
64
  }
55
65
 
56
66
  const MAX_RESUBMIT_DEPTH = 8;
57
67
 
68
+ // Disposition for a `mode: 'auto'` mid-turn submission when no SteeringPolicy is registered (or it
69
+ // declares no `classify`). Interrupt-by-default: a message sent while the agent works stops the
70
+ // running turn (its committed partial work is preserved) and runs next. Flip to 'queue' to make the
71
+ // conservative "wait for the turn boundary" the default — this const is the single knob.
72
+ const DEFAULT_STEERING_POLICY: SteeringDecision = 'interrupt';
73
+
74
+ // The ephemeral nudge folded onto an interrupt's continuation when no SteeringPolicy supplies one. It
75
+ // biases the model to continue the interrupted task while accounting for the steer, rather than pivot
76
+ // wholesale — the dial between "I'll get to that in a minute" and a full reprioritisation.
77
+ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
78
+ type: 'text',
79
+ text: 'The user sent the message above while you were working. Take it into account and keep going — continue the task you were on, incorporating this new input rather than discarding your progress.',
80
+ }];
81
+
58
82
  // Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
59
83
  // messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
60
84
  // because the pop is a pump operation, not a plugin's.
@@ -69,6 +93,10 @@ interface SessionState {
69
93
  // submissions interleaved among them (see the batch-building loop in pump). Concat and queued mix freely.
70
94
  queue: QueuedItem[];
71
95
  running: boolean;
96
+ // traceId of the turn currently running (set at pump loop top, cleared when the turn ends). Lets an
97
+ // interrupt name the turn it is yielding, so a frontend correlates the `steer` event's
98
+ // `interruptedTraceId` with the `aborted` (reason 'steer') it is about to see.
99
+ runningTraceId: string | undefined;
72
100
  ac: AbortController | undefined;
73
101
  subscribers: Set<Sink>;
74
102
  // Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
@@ -134,7 +162,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
134
162
  const stateFor = (id: string): SessionState => {
135
163
  let s = states.get(id);
136
164
  if (s === undefined) {
137
- s = { queue: [], running: false, ac: undefined, subscribers: new Set(), replay: [] };
165
+ s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
138
166
  states.set(id, s);
139
167
  }
140
168
  return s;
@@ -176,6 +204,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
176
204
  while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
177
205
  }
178
206
  const content = batch.flatMap(i => i.content);
207
+ s.runningTraceId = head.traceId;
179
208
  s.replay = [];
180
209
  // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
181
210
  // message persisted just below. notify() (in open()) reaches only subscribers that are live at
@@ -230,6 +259,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
230
259
  continue;
231
260
  }
232
261
 
262
+ // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
263
+ // two never coincide on one item, so either supplies runSession's injectedEphemeral.
264
+ const inject = head.redo?.ephemeral ?? head.injectEphemeral;
265
+
233
266
  const ac = new AbortController();
234
267
  s.ac = ac;
235
268
  // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
@@ -276,7 +309,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
276
309
  ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
277
310
  ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
278
311
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
279
- ...(head.redo !== undefined ? { injectedEphemeral: head.redo.ephemeral } : {}),
312
+ ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
280
313
  })) {
281
314
  emit(s, ev);
282
315
  }
@@ -289,7 +322,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
289
322
  if (!ac.signal.aborted && hooks) {
290
323
  const committed = await deps.store.get(id);
291
324
  if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
292
- let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } } = { resubmits: [], markers: [] };
325
+ let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
293
326
  await contextSwitch(head.principal, async () => {
294
327
  followup = await hooks.runFollowup({
295
328
  session: committed,
@@ -348,7 +381,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
348
381
  if (followup.retract) {
349
382
  const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
350
383
  const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
351
- const kept = lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages;
384
+ // `durable` correction (a contextual fire that landed post-commit) folds onto the user
385
+ // message we keep — persisted, so the redo AND every later turn see it in history, the
386
+ // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
387
+ const durable = followup.retract.durable ?? [];
388
+ const kept = (lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages)
389
+ .map((m, i) => (durable.length > 0 && i === lastUserIdx ? { ...m, content: [...m.content, ...durable] } : m));
352
390
  const retractionMsg: Message = {
353
391
  id: crypto.randomUUID(),
354
392
  role: 'marker',
@@ -357,7 +395,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
357
395
  // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
358
396
  // context fed to the redo — neither is otherwise persisted, so the pair fully traces
359
397
  // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
360
- content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context, traceId: head.traceId } }],
398
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
361
399
  };
362
400
  // Any followup markers ride along as a trailing marker message (not folded into the
363
401
  // retraction marker) so each creator's trace stays its own block.
@@ -365,6 +403,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
365
403
  ? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
366
404
  : [];
367
405
  await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
406
+ // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
407
+ // marker, mirroring the screen-time durable path's ordering.
408
+ if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
368
409
  notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
369
410
  if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
370
411
 
@@ -377,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
377
418
  principal: head.principal,
378
419
  concatQueue: false,
379
420
  resubmitDepth: head.resubmitDepth + 1,
380
- redo: { ephemeral: followup.retract.context },
421
+ redo: { ephemeral: followup.retract.context ?? [] },
381
422
  ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
382
423
  });
383
424
  }
@@ -391,6 +432,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
391
432
  }
392
433
  } finally {
393
434
  s.running = false;
435
+ s.runningTraceId = undefined;
394
436
  s.replay = [];
395
437
  // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
396
438
  // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
@@ -416,22 +458,75 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
416
458
  }
417
459
  s = stateFor(opts.sessionId);
418
460
  traceId = opts.traceId ?? crypto.randomUUID();
419
- const concatQueue = opts.concatQueue ?? false;
420
- s.queue.push({
421
- traceId,
422
- rootTraceId: traceId,
423
- content: opts.content,
424
- provider: opts.provider,
425
- principal: opts.principal,
426
- concatQueue,
427
- resubmitDepth: 0,
428
- ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
429
- });
430
- // Announce the submission on the stream as part of the live delta, before its turn's events.
431
- // If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
432
- // tells a frontend whether this submission will merge into the running batch (so it can fold the
433
- // bubble) or run as its own turn.
434
- notify(s, { type: 'queued', content: opts.content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
461
+ const { content, provider, principal } = opts;
462
+ const mode = opts.mode ?? 'queue';
463
+
464
+ // Decide interrupt vs queue for a submission arriving mid-turn. Steering is only meaningful
465
+ // while a turn is running; with nothing running it enqueues and runs next regardless of mode.
466
+ // The decision — and, on interrupt, the abort+unshift — happen here synchronously against
467
+ // s.running (re-checked after any classify/nudge await) so an interrupt can never land on the
468
+ // wrong (a later) turn: the fatal race of deciding across a wire is closed by deciding in-runner.
469
+ let interrupt = false;
470
+ if (mode !== 'queue' && s.running) {
471
+ const policy = deps.steeringPolicy?.();
472
+ // Fetch the committed session once (history up to the running turn's start) only when a policy
473
+ // member actually needs it: classify under `auto`, and nudge on any interrupt. The common
474
+ // no-policy interrupt path (e.g. the web default) does no extra read.
475
+ const wantClassify = mode === 'auto' && policy?.classify !== undefined;
476
+ const wantNudge = policy?.nudge !== undefined;
477
+ const committed = (wantClassify || wantNudge) ? await deps.store.get(opts.sessionId) : null;
478
+
479
+ const decision: SteeringDecision = mode === 'interrupt'
480
+ ? 'interrupt'
481
+ : policy?.classify !== undefined
482
+ ? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
483
+ : DEFAULT_STEERING_POLICY;
484
+
485
+ // Any await above may have outlived the running turn; only interrupt one still live.
486
+ if (decision === 'interrupt' && s.running) {
487
+ const nudge = policy?.nudge !== undefined && committed
488
+ ? policy.nudge({ session: committed, steer: content })
489
+ : DEFAULT_STEER_NUDGE;
490
+ const interruptedTraceId = s.runningTraceId ?? '';
491
+ // Head of the queue (ahead of anything else already queued): the steer runs immediately
492
+ // after the interrupted turn commits its partial work. Own turn (never concat).
493
+ s.queue.unshift({
494
+ traceId,
495
+ rootTraceId: traceId,
496
+ content,
497
+ provider,
498
+ principal,
499
+ concatQueue: false,
500
+ resubmitDepth: 0,
501
+ injectEphemeral: nudge,
502
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
503
+ });
504
+ // Announce the steer before aborting so a subscriber places the new bubble and reads the
505
+ // imminent `aborted` (reason 'steer') as a yield, not a dead-end.
506
+ notify(s, { type: 'steer', content, interruptedTraceId, traceId, rootTraceId: traceId });
507
+ s.ac?.abort('steer');
508
+ interrupt = true;
509
+ }
510
+ }
511
+
512
+ if (!interrupt) {
513
+ const concatQueue = opts.concatQueue ?? false;
514
+ s.queue.push({
515
+ traceId,
516
+ rootTraceId: traceId,
517
+ content,
518
+ provider,
519
+ principal,
520
+ concatQueue,
521
+ resubmitDepth: 0,
522
+ ...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
523
+ });
524
+ // Announce the submission on the stream as part of the live delta, before its turn's events.
525
+ // If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
526
+ // tells a frontend whether this submission will merge into the running batch (so it can fold the
527
+ // bubble) or run as its own turn.
528
+ notify(s, { type: 'queued', content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
529
+ }
435
530
  void pump(opts.sessionId, s);
436
531
  }
437
532