@matatbread/matbot-core 0.4.15 → 0.4.17

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.
@@ -165,10 +165,16 @@ function createSink(dispose: () => void): Sink {
165
165
  };
166
166
  }
167
167
 
168
- export function wireDescription(description: string, wc: { params: string; result: string } | undefined) {
169
- return wc
170
- ? `${description}\n\nTypeScript params:\n\`\`\`\n${wc.params}\n\`\`\`\n\nTypeScript result:\`\`\`${wc.result}\n\`\`\``
171
- : description;
168
+ /**
169
+ * A tool's description with its wire contract appended — the text the model actually reads for every
170
+ * tool, every turn. Both blocks are fenced identically; the result block used to omit the newline after
171
+ * its label and before its fence, so the type opened inline against "TypeScript result:```" and the
172
+ * block never closed where a reader expects it.
173
+ */
174
+ export function wireDescription(description: string, wc: { params: string; result: string } | undefined): string {
175
+ if (wc === undefined) return description;
176
+ const block = (label: string, type: string): string => `${label}:\n\`\`\`\n${type}\n\`\`\``;
177
+ return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
172
178
  }
173
179
 
174
180
  export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
@@ -280,295 +286,288 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
280
286
  // site, and spelling it here made it something the next caller of `machineBusy` could omit without
281
287
  // any symptom — so it moved inside the hold it guards.
282
288
  return machineBusy(async () => {
283
- try {
284
- while (s.queue.length > 0) {
285
- // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
286
- // the following submissions while they too are concat, stopping at the first non-concat (a turn
287
- // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
288
- // while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
289
- const head = s.queue.shift()!;
290
- const batch = [head];
291
- if (head.concatQueue) {
292
- while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
293
- }
294
- const content = batch.flatMap(i => i.content);
295
- s.runningTraceId = head.traceId;
296
- s.replay = [];
297
- // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
298
- // message persisted just below. notify() (in open()) reaches only subscribers that are live at
299
- // enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
300
- // common case when the submit POST wins the race against the events stream — would otherwise
301
- // find an empty queue and a cleared replay, and never render the user bubble. One merged event
302
- // (not one per batch item) matches both stored history on reload and the live fold, so a late
303
- // subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
304
- // content), so it seeds nothing — its retraction marker already went out at enqueue time.
305
- if (head.redo === undefined) {
306
- s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
307
- }
289
+ try {
290
+ while (s.queue.length > 0) {
291
+ // Per-submission concat: the head always runs; if the head is a concat submission it absorbs
292
+ // the following submissions while they too are concat, stopping at the first non-concat (a turn
293
+ // boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
294
+ // while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
295
+ const head = s.queue.shift()!;
296
+ const batch = [head];
297
+ if (head.concatQueue) {
298
+ while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
299
+ }
300
+ const content = batch.flatMap(i => i.content);
301
+ s.runningTraceId = head.traceId;
302
+ s.replay = [];
303
+ // Seed replay with the running turn's user message as a single merged `queued`, mirroring the
304
+ // message persisted just below. notify() (in open()) reaches only subscribers that are live at
305
+ // enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
306
+ // common case when the submit POST wins the race against the events stream — would otherwise
307
+ // find an empty queue and a cleared replay, and never render the user bubble. One merged event
308
+ // (not one per batch item) matches both stored history on reload and the live fold, so a late
309
+ // subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
310
+ // content), so it seeds nothing — its retraction marker already went out at enqueue time.
311
+ if (head.redo === undefined) {
312
+ s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
313
+ }
308
314
 
309
- let session = await deps.store.get(id);
310
- if (session === null) {
311
- emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
312
- continue;
313
- }
315
+ let session = await deps.store.get(id);
316
+ if (session === null) {
317
+ emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
318
+ continue;
319
+ }
320
+
321
+ // A redo re-runs the existing committed user turn — no title derivation, no new user message.
322
+ if (head.redo === undefined) {
323
+ if (!session.title && !session.messages.some(m => m.role === 'user')) {
324
+ // Falls back to the attachment names when the turn has no words of its own: an image-only
325
+ // first turn is a real submission, and got no title at all — which reads in the session
326
+ // list as a session that failed to start.
327
+ const text = content
328
+ .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
329
+ .map(c => c.text).join(' ').trim()
330
+ || content
331
+ .filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
332
+ .map(c => c.name).join(', ');
333
+ if (text) {
334
+ const words = text.split(/\s+/).slice(0, 8).join(' ');
335
+ session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
336
+ }
337
+ }
314
338
 
315
- // A redo re-runs the existing committed user turn — no title derivation, no new user message.
316
- if (head.redo === undefined) {
317
- if (!session.title && !session.messages.some(m => m.role === 'user')) {
318
- // Falls back to the attachment names when the turn has no words of its own: an image-only
319
- // first turn is a real submission, and got no title at all — which reads in the session
320
- // list as a session that failed to start.
321
- const text = content
322
- .filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
323
- .map(c => c.text).join(' ').trim()
324
- || content
325
- .filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
326
- .map(c => c.name).join(', ');
327
- if (text) {
328
- const words = text.split(/\s+/).slice(0, 8).join(' ');
329
- session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
339
+ // Persist-at-turn-start: the user message only hits the store when its turn begins, never
340
+ // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
341
+ // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
342
+ session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
343
+ try {
344
+ await deps.store.set(session.id, session);
345
+ } catch (e) {
346
+ // A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
347
+ // from another profile's partition). That is a per-turn condition, not a host fault: surface
348
+ // it as a turn error and drop this submission rather than let it escape the detached pump and
349
+ // crash the process. Any other write failure stays fatal.
350
+ if (!isReadOnlyError(e)) throw e;
351
+ emit(s, { type: 'error', error: e.message, traceId: head.traceId });
352
+ continue;
330
353
  }
331
354
  }
332
355
 
333
- // Persist-at-turn-start: the user message only hits the store when its turn begins, never
334
- // while queued. That is what stops a mid-turn submit from clobbering session state. A robo
335
- // resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
336
- session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
337
- try {
338
- await deps.store.set(session.id, session);
339
- } catch (e) {
340
- // A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
341
- // from another profile's partition). That is a per-turn condition, not a host fault: surface
342
- // it as a turn error and drop this submission rather than let it escape the detached pump and
343
- // crash the process. Any other write failure stays fatal.
344
- if (!isReadOnlyError(e)) throw e;
345
- emit(s, { type: 'error', error: e.message, traceId: head.traceId });
356
+ const resolved = await deps.resolveProvider(head.provider);
357
+ if (resolved === null) {
358
+ emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
346
359
  continue;
347
360
  }
348
- }
349
-
350
- const resolved = await deps.resolveProvider(head.provider);
351
- if (resolved === null) {
352
- emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
353
- continue;
354
- }
355
361
 
356
- // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
357
- // two never coincide on one item, so either supplies runSession's injectedEphemeral.
358
- const inject = head.redo?.ephemeral ?? head.injectEphemeral;
359
-
360
- const ac = new AbortController();
361
- s.ac = ac;
362
- // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
363
- // (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
364
- // for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
365
- // Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
366
- // inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
367
- const wire = await deps.toolTypeIndex?.()?.wireContracts();
368
- const toolPresenter = deps.toolPresenter?.();
369
- const mediaStore = deps.mediaStore?.();
370
- const permissionGate = deps.permissionGate?.();
371
- const toolMap = deps.tools !== undefined
372
- ? new Map<string, Tool>(deps.tools.list().map(t => {
373
- const wc = wire?.[t.name];
374
- return [t.name, wc !== undefined
375
- ? { ...t, description: wireDescription(t.description, wc) }
376
- : t];
377
- }))
378
- : undefined;
379
-
380
- try {
381
- // Establish the submitter's principal for the whole turn here, not inside runSession:
382
- // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
383
- // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
384
- // access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
385
- // because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
386
- // runAs, not a context switch of its own: the pump holds the machine across the whole queue
387
- // (see above), so a turn declares only its owner. Each item carries its own submitter.
388
- // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
389
- // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
390
- // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
391
- // in both cases a followup hook would judge history with no completed answer in it — and a
392
- // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
393
- // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
394
- // signal-driven aborts and not the policy ones.
395
- let terminal: PipelineEvent['type'] | undefined;
396
- // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
397
- // definition and a detached classifier settles whenever it settles, so a scope that ended at
398
- // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
399
- // The entries array is registered for flush before any work starts, so a late arrival lands
400
- // in something already tracked and needs no coordination of its own.
401
- await withUsageScope(async usage => {
402
- s.pendingUsage.push(usage.entries);
403
- await runAs(head.principal, async () => {
404
- for await (const ev of runSession({
405
- session,
406
- config: { provider: head.provider, traceId: head.traceId },
407
- provider: resolved.adapter,
408
- providerConfig: resolved.config,
409
- store: deps.store,
410
- signal: ac.signal,
411
- loadPlugin: deps.loadPlugin,
412
- unloadPlugin: deps.unloadPlugin,
413
- ...(toolMap !== undefined ? { tools: toolMap } : {}),
414
- ...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
415
- ...(toolPresenter !== undefined ? { toolPresenter } : {}),
416
- ...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
417
- ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
418
- ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
419
- ...(deps.files !== undefined ? { files: deps.files } : {}),
420
- ...(mediaStore !== undefined ? { mediaStore } : {}),
421
- ...(permissionGate !== undefined ? { permissionGate } : {}),
422
- ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
423
- ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
424
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
425
- ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
426
- })) {
427
- if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
428
- emit(s, ev);
429
- }
430
- });
362
+ // A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
363
+ // two never coincide on one item, so either supplies runSession's injectedEphemeral.
364
+ const inject = head.redo?.ephemeral ?? head.injectEphemeral;
365
+
366
+ const ac = new AbortController();
367
+ s.ac = ac;
368
+ // Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
369
+ // (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
370
+ // for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
371
+ // Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
372
+ // inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
373
+ const wire = await deps.toolTypeIndex?.()?.wireContracts();
374
+ const toolPresenter = deps.toolPresenter?.();
375
+ const mediaStore = deps.mediaStore?.();
376
+ const permissionGate = deps.permissionGate?.();
377
+ const toolMap = deps.tools !== undefined
378
+ ? new Map<string, Tool>(deps.tools.list().map(t => {
379
+ const wc = wire?.[t.name];
380
+ return [t.name, wc !== undefined
381
+ ? { ...t, description: wireDescription(t.description, wc) }
382
+ : t];
383
+ }))
384
+ : undefined;
431
385
 
432
- // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
433
- // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
434
- // committed (see `terminal`), and under the submitter's principal because a reactor may itself
435
- // call complete() (a classifier).
436
- const hooks = deps.hooks;
437
- if (terminal === 'done' && hooks) {
438
- const committed = await deps.store.get(id);
439
- if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
440
- let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
386
+ try {
387
+ // Establish the submitter's principal for the whole turn here, not inside runSession:
388
+ // pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
389
+ // turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
390
+ // access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
391
+ // because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
392
+ // runAs, not a context switch of its own: the pump holds the machine across the whole queue
393
+ // (see above), so a turn declares only its owner. Each item carries its own submitter.
394
+ // The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
395
+ // commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
396
+ // refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
397
+ // in both cases a followup hook would judge history with no completed answer in it — and a
398
+ // `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
399
+ // budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
400
+ // signal-driven aborts and not the policy ones.
401
+ let terminal: PipelineEvent['type'] | undefined;
402
+ // One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
403
+ // definition and a detached classifier settles whenever it settles, so a scope that ended at
404
+ // the turn's commit would drop precisely the spend that is hardest to account for otherwise.
405
+ // The entries array is registered for flush before any work starts, so a late arrival lands
406
+ // in something already tracked and needs no coordination of its own.
407
+ await withUsageScope(async usage => {
408
+ s.pendingUsage.push(usage.entries);
441
409
  await runAs(head.principal, async () => {
442
- followup = await hooks.runFollowup({
443
- session: committed,
444
- resubmitDepth: head.resubmitDepth,
445
- config: { provider: head.provider, traceId: head.traceId },
446
- signal: ac.signal,
447
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
448
- });
410
+ for await (const ev of runSession({
411
+ session,
412
+ config: { provider: head.provider, traceId: head.traceId },
413
+ provider: resolved.adapter,
414
+ providerConfig: resolved.config,
415
+ store: deps.store,
416
+ signal: ac.signal,
417
+ loadPlugin: deps.loadPlugin,
418
+ unloadPlugin: deps.unloadPlugin,
419
+ ...(toolMap !== undefined ? { tools: toolMap } : {}),
420
+ ...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
421
+ ...(toolPresenter !== undefined ? { toolPresenter } : {}),
422
+ ...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
423
+ ...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
424
+ ...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
425
+ ...(deps.files !== undefined ? { files: deps.files } : {}),
426
+ ...(mediaStore !== undefined ? { mediaStore } : {}),
427
+ ...(permissionGate !== undefined ? { permissionGate } : {}),
428
+ ...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
429
+ ...(deps.vault !== undefined ? { vault: deps.vault } : {}),
430
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
431
+ ...(inject !== undefined ? { injectedEphemeral: inject } : {}),
432
+ })) {
433
+ if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
434
+ emit(s, ev);
435
+ }
449
436
  });
450
- const resubmits = followup.resubmits;
451
-
452
- // Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
453
- // what it did). Append-only to the just-committed session; the pump is the sole writer
454
- // post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
455
- // `queued` event) so a live draw matches a reload — the engine surfaces everything it
456
- // persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
457
- // the message tail, so these markers are folded into that single write instead (below)
458
- // to keep ordering sane (a separate append here would be sliced into the popped region).
459
- if (followup.markers.length > 0 && !followup.retract) {
460
- await deps.store.set(id, {
461
- ...committed,
462
- messages: [...committed.messages, {
463
- id: crypto.randomUUID(),
464
- role: 'marker',
465
- createdAt: new Date().toISOString(),
466
- traceId: head.traceId,
467
- content: followup.markers,
468
- }],
469
- });
470
- notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
471
- }
472
- // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
473
- // every block `origin: 'robo'` here — once — so it rides into both the live `queued`
474
- // event and the persisted user message the pump builds from this item.
475
- for (const raw of resubmits.reverse()) {
476
- const rt = crypto.randomUUID();
477
- // Every block, not just text: `origin` is authorship, orthogonal to what the block
478
- // carries, and a resubmission that attached an image was silently presenting it as the
479
- // user's own. The arms a stamp would be wrong on don't occur here — a hook returns
480
- // content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
481
- const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
482
- s.queue.unshift({
483
- traceId: rt,
484
- rootTraceId: head.rootTraceId,
485
- content,
486
- provider: head.provider,
487
- principal: head.principal,
488
- concatQueue: false,
489
- resubmitDepth: head.resubmitDepth + 1,
490
- });
491
- notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
492
- }
493
437
 
494
- // retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
495
- // message, stash the popped messages in a durable retraction marker (LLM-elided like any
496
- // marker — so the model never re-reads its superseded answer — but carried in `data` for
497
- // a strike-through render and audit), then re-enqueue a redo of that same user turn at the
498
- // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
499
- // sits at the very head (runs next) even if a resubmit was also queued above.
500
- if (followup.retract) {
501
- const lastUserIdx = lastUserIndex(committed);
502
- const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
503
- // `durable` correction (a contextual fire that landed post-commit) folds onto the user
504
- // message we keep — persisted, so the redo AND every later turn see it in history, the
505
- // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
506
- // The fold runs on the *truncated* session, whose last user message is the one we keep,
507
- // so it goes through the same helper as the screen and raced-verdict folds.
508
- const durable = followup.retract.durable ?? [];
509
- const truncated = lastUserIdx >= 0
510
- ? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
511
- : committed;
512
- const kept = foldOntoUserTurn(truncated, durable).messages;
513
- const retractionMsg: Message = {
514
- id: crypto.randomUUID(),
515
- role: 'marker',
516
- createdAt: new Date().toISOString(),
517
- traceId: head.traceId,
518
- // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
519
- // context fed to the redo — neither is otherwise persisted, so the pair fully traces
520
- // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
521
- content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
522
- };
523
- // Any followup markers ride along as a trailing marker message (not folded into the
524
- // retraction marker) so each creator's trace stays its own block.
525
- const trailing: Message[] = followup.markers.length > 0
526
- ? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
527
- : [];
528
- await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
529
- // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
530
- // marker, mirroring the screen-time durable path's ordering.
531
- if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
532
- notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
533
- if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
534
-
535
- const rt = crypto.randomUUID();
536
- s.queue.unshift({
537
- traceId: rt,
538
- rootTraceId: head.rootTraceId,
539
- content: [],
540
- provider: head.provider,
541
- principal: head.principal,
542
- concatQueue: false,
543
- resubmitDepth: head.resubmitDepth + 1,
544
- redo: { ephemeral: followup.retract.context ?? [] },
545
- ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
546
- });
438
+ // followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
439
+ // head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
440
+ // committed (see `terminal`), and under the submitter's principal because a reactor may itself
441
+ // call complete() (a classifier).
442
+ const hooks = deps.hooks;
443
+ if (terminal === 'done' && hooks) {
444
+ const committed = await deps.store.get(id);
445
+ if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
446
+ let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
447
+ await runAs(head.principal, async () => {
448
+ followup = await hooks.runFollowup({
449
+ session: committed,
450
+ resubmitDepth: head.resubmitDepth,
451
+ config: { provider: head.provider, traceId: head.traceId },
452
+ signal: ac.signal,
453
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
454
+ });
455
+ });
456
+ const resubmits = followup.resubmits;
457
+
458
+ // Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
459
+ // what it did). Append-only to the just-committed session; the pump is the sole writer
460
+ // post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
461
+ // `queued` event) so a live draw matches a reload — the engine surfaces everything it
462
+ // persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
463
+ // the message tail, so these markers are folded into that single write instead (below)
464
+ // to keep ordering sane (a separate append here would be sliced into the popped region).
465
+ if (followup.markers.length > 0 && !followup.retract) {
466
+ await deps.store.set(id, {
467
+ ...committed,
468
+ messages: [...committed.messages,
469
+ createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })],
470
+ });
471
+ notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
472
+ }
473
+ // unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
474
+ // every block `origin: 'robo'` here — once — so it rides into both the live `queued`
475
+ // event and the persisted user message the pump builds from this item.
476
+ for (const raw of resubmits.reverse()) {
477
+ const rt = crypto.randomUUID();
478
+ // Every block, not just text: `origin` is authorship, orthogonal to what the block
479
+ // carries, and a resubmission that attached an image was silently presenting it as the
480
+ // user's own. The arms a stamp would be wrong on don't occur here — a hook returns
481
+ // content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
482
+ const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
483
+ s.queue.unshift({
484
+ traceId: rt,
485
+ rootTraceId: head.rootTraceId,
486
+ content,
487
+ provider: head.provider,
488
+ principal: head.principal,
489
+ concatQueue: false,
490
+ resubmitDepth: head.resubmitDepth + 1,
491
+ });
492
+ notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
493
+ }
494
+
495
+ // retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
496
+ // message, stash the popped messages in a durable retraction marker (LLM-elided like any
497
+ // marker — so the model never re-reads its superseded answer — but carried in `data` for
498
+ // a strike-through render and audit), then re-enqueue a redo of that same user turn at the
499
+ // head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
500
+ // sits at the very head (runs next) even if a resubmit was also queued above.
501
+ if (followup.retract) {
502
+ const lastUserIdx = lastUserIndex(committed);
503
+ const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
504
+ // `durable` correction (a contextual fire that landed post-commit) folds onto the user
505
+ // message we keep — persisted, so the redo AND every later turn see it in history, the
506
+ // exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
507
+ // The fold runs on the *truncated* session, whose last user message is the one we keep,
508
+ // so it goes through the same helper as the screen and raced-verdict folds.
509
+ const durable = followup.retract.durable ?? [];
510
+ const truncated = lastUserIdx >= 0
511
+ ? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
512
+ : committed;
513
+ const kept = foldOntoUserTurn(truncated, durable).messages;
514
+ const retractionMsg: Message = createMessage({
515
+ role: 'marker',
516
+ traceId: head.traceId,
517
+ // `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
518
+ // context fed to the redo — neither is otherwise persisted, so the pair fully traces
519
+ // the swap for a post-mortem (and a frontend can render the strike-through + the cause).
520
+ content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
521
+ });
522
+ // Any followup markers ride along as a trailing marker message (not folded into the
523
+ // retraction marker) so each creator's trace stays its own block.
524
+ const trailing: Message[] = followup.markers.length > 0
525
+ ? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
526
+ : [];
527
+ await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
528
+ // Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
529
+ // marker, mirroring the screen-time durable path's ordering.
530
+ if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
531
+ notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
532
+ if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
533
+
534
+ const rt = crypto.randomUUID();
535
+ s.queue.unshift({
536
+ traceId: rt,
537
+ rootTraceId: head.rootTraceId,
538
+ content: [],
539
+ provider: head.provider,
540
+ principal: head.principal,
541
+ concatQueue: false,
542
+ resubmitDepth: head.resubmitDepth + 1,
543
+ redo: { ephemeral: followup.retract.context ?? [] },
544
+ ...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
545
+ });
546
+ }
547
+ }
547
548
  }
548
- }
549
+ }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
550
+ } catch (e) {
551
+ emit(s, { type: 'error', error: String(e), traceId: head.traceId });
552
+ } finally {
553
+ s.ac = undefined;
549
554
  }
550
- }, { traceId: head.traceId, rootTraceId: head.rootTraceId });
551
- } catch (e) {
552
- emit(s, { type: 'error', error: String(e), traceId: head.traceId });
553
- } finally {
554
- s.ac = undefined;
555
555
  }
556
+ } finally {
557
+ s.running = false;
558
+ s.runningTraceId = undefined;
559
+ s.replay = [];
560
+ // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
561
+ // is not a well-defined moment to total anything at: steers terminate and resume, a retract
562
+ // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
563
+ // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
564
+ await flushUsage(id, s);
565
+ // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
566
+ // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
567
+ // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
568
+ notify(s, { type: 'idle', sessionId: id });
569
+ maybeCleanup(id, s);
556
570
  }
557
- } finally {
558
- s.running = false;
559
- s.runningTraceId = undefined;
560
- s.replay = [];
561
- // Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
562
- // is not a well-defined moment to total anything at: steers terminate and resume, a retract
563
- // re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
564
- // settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
565
- await flushUsage(id, s);
566
- // Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
567
- // (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
568
- // the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
569
- notify(s, { type: 'idle', sessionId: id });
570
- maybeCleanup(id, s);
571
- }
572
571
  });
573
572
  };
574
573