@matatbread/matbot-core 0.4.18 → 0.4.20

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.4.18",
7
+ "version": "0.4.20",
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.4.18"
38
+ "@matatbread/matbot-plugin-api": "^0.4.20"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/assemble.ts CHANGED
@@ -69,8 +69,6 @@ export interface AssembleOptions {
69
69
  providers: ProviderRegistryImpl;
70
70
  gate: HostGate;
71
71
  defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined;
72
- /** Registry values present from boot, which an unregister reverts to rather than deleting. */
73
- seed?: Partial<MatbotServices>;
74
72
  builtinTools?: Tool[];
75
73
  /** Resolve one `${NAME}` reference; the browser prompts for a missing secret. Defaults to the vault. */
76
74
  resolveSecret?(ref: string, vault: Vault): Promise<string>;
@@ -183,6 +181,9 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
183
181
  // registers its own (forwarding to its parent) over it — and unloading that reverts to refusing, which
184
182
  // is the honest answer for a job with no channel. Late-bound, as `services` is built below. It writes
185
183
  // through the shared runner, the one writer of the sessions in `services.sessions`.
184
+ // Not a host-supplied set: an embedder with a service of its own registers it on the returned
185
+ // `services` before `loaded()`, which is the same moment and one mechanism rather than two. A `seed`
186
+ // option existed for the node `FunctionRunner`, and went with it when that became a plugin.
186
187
  const seed: Partial<MatbotServices> = {
187
188
  MediaStore: fileStore,
188
189
  SessionAppender: createSessionAppender({
@@ -191,7 +192,6 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
191
192
  notifier: () => services.Notifier,
192
193
  isSubAgent: () => services.isSubAgent(),
193
194
  }),
194
- ...opts.seed,
195
195
  };
196
196
  const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
197
197
  // The validator lookup is late-bound and read per call: one is registered by a plugin long after the
@@ -21,12 +21,6 @@ export interface MatbotConfig {
21
21
  * config rather than data. A stored value always wins; `delete` reverts to the default here.
22
22
  */
23
23
  defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
24
- /**
25
- * Longest a `tool_function` body may run synchronously — without awaiting — before it is stopped
26
- * (`function_timeout_ms`). Absent ⇒ the host's default. `0` ⇒ no limit at all, for testing: bodies run
27
- * directly, and a loop that never awaits freezes the process.
28
- */
29
- functionTimeoutMs?: number;
30
24
  }
31
25
 
32
26
  function asString(v: YamlValue | undefined, label: string): string {
@@ -144,17 +138,6 @@ export function parseConfig(
144
138
  }
145
139
  }
146
140
 
147
- const functionTimeoutRaw = doc['function_timeout_ms'];
148
- let functionTimeoutMs: number | undefined;
149
- if (functionTimeoutRaw !== undefined && functionTimeoutRaw !== null) {
150
- functionTimeoutMs = asNumber(functionTimeoutRaw, 'function_timeout_ms');
151
- // Refused rather than clamped, like maxRounds: a negative or fractional limit is a typo, and `0` already
152
- // means something of its own.
153
- if (!Number.isInteger(functionTimeoutMs) || functionTimeoutMs < 0) {
154
- throw new Error(`Config: "function_timeout_ms" must be a non-negative integer (milliseconds; 0 removes the limit), got ${functionTimeoutMs}`);
155
- }
156
- }
157
-
158
141
  const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
159
142
  const ephemeral = doc['ephemeral'] === true ? true : undefined;
160
143
  const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
@@ -168,7 +151,6 @@ export function parseConfig(
168
151
  ...(defaultProvider !== undefined ? { defaultProvider } : {}),
169
152
  ...(principal !== undefined ? { principal } : {}),
170
153
  ...(defaultSettings.size > 0 ? { defaultSettings } : {}),
171
- ...(functionTimeoutMs !== undefined ? { functionTimeoutMs } : {}),
172
154
  };
173
155
  }
174
156
 
package/src/index.ts CHANGED
@@ -18,12 +18,12 @@ export type {
18
18
  MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
19
19
  MediaRejectedError, InvalidSecretNameError, MatbotErrorKind,
20
20
  } from '@matatbread/matbot-plugin-api';
21
- export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
21
+ export { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
22
22
  // The `provider update` patch policy, alongside the vault's: written once in plugin-api so the hosts
23
23
  // that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
24
24
  // plugin-api dependency to get it.
25
25
  export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
26
- export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
26
+ export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT, permitted, refused, refusalMessage } from '@matatbread/matbot-plugin-api';
27
27
  // The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
28
28
  // PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
29
29
  export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
@@ -1 +1,2 @@
1
1
  export { parseSSE } from './sse.js';
2
+ export { textDocument } from './text-document.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A text-typed `document` arm, rendered as a plain text block.
3
+ *
4
+ * Every adapter needs this and none should use its protocol's document block for it. A document block
5
+ * buys a title and citation support on the one endpoint that natively implements it, and is DISCARDED
6
+ * by the Anthropic- and OpenAI-compatible shims most vendors front their models with: DeepSeek
7
+ * substitutes the literal "[Unsupported Document]" server-side, so the model is told a file is attached
8
+ * and shown nothing — strictly worse than either sending the bytes or refusing them at the boundary.
9
+ * A text block is the one content shape every endpoint implements, and for text it is lossless: the
10
+ * model reads the same characters either way.
11
+ *
12
+ * The name is folded into the framing because the alternative is a bare CSV dump with nothing saying
13
+ * it is a file, which is the other half of what went wrong — a model that cannot tell content from
14
+ * attachment goes looking for the attachment.
15
+ */
16
+
17
+ /**
18
+ * Structured-text types that are text without saying `text/`. A `+json`/`+xml` suffix (RFC 6839) is
19
+ * matched too, so `application/vnd.api+json` needs no entry.
20
+ *
21
+ * An unlisted type is NOT guessed at, `application/octet-stream` above all: the browser hands that over
22
+ * for any extension the OS could not place (`.yaml` on some installs), and sniffing it would mean
23
+ * deciding that binary is text on the strength of it happening to decode.
24
+ */
25
+ const TEXTUAL: ReadonlySet<string> = new Set([
26
+ 'application/json', 'application/ld+json', 'application/x-ndjson',
27
+ 'application/xml', 'application/yaml', 'application/x-yaml',
28
+ 'application/toml', 'application/csv', 'application/sql',
29
+ 'application/javascript', 'application/x-javascript', 'application/ecmascript',
30
+ 'application/x-sh', 'application/x-httpd-php', 'application/graphql',
31
+ ]);
32
+
33
+ function isTextual(mimeType: string): boolean {
34
+ const base = mimeType.split(';')[0]!.trim().toLowerCase();
35
+ return base.startsWith('text/') || base.endsWith('+json') || base.endsWith('+xml') || TEXTUAL.has(base);
36
+ }
37
+
38
+ /**
39
+ * The text of a document arm that should be inlined as text, or null to leave the adapter's own
40
+ * degradation in place (a PDF, an unrecognised type, or bytes that are not valid UTF-8 — a mislabelled
41
+ * binary must not reach a prompt as mojibake, so the decode is `fatal`).
42
+ *
43
+ * `atob` yields one byte per char, so re-widen through TextDecoder rather than trusting it for anything
44
+ * outside ASCII.
45
+ */
46
+ export function textDocument(doc: { mimeType: string; data: string; name?: string | undefined }): string | null {
47
+ if (!isTextual(doc.mimeType)) return null;
48
+ let text: string;
49
+ try {
50
+ text = new TextDecoder('utf-8', { fatal: true })
51
+ .decode(Uint8Array.from(atob(doc.data), ch => ch.charCodeAt(0)));
52
+ } catch { return null; }
53
+ const label = doc.name ?? doc.mimeType;
54
+ return `--- ${label} ---\n${text}\n--- end of ${label} ---`;
55
+ }
package/src/registry.ts CHANGED
@@ -60,7 +60,7 @@ async function resolveToolCollision(
60
60
  ): Promise<boolean> {
61
61
  const owner = existingOwner !== undefined ? `"${existingOwner}"` : 'a built-in';
62
62
  const label = `Tool \`"${toolName}"\` is already registered by **${owner}**. Overwrite it with the one from **"${incomingOwner}"**?`;
63
- const allowed = await (services.PermissionGate ?? askPermissionGate).decide({
63
+ const decision = await (services.PermissionGate ?? askPermissionGate).decide({
64
64
  gate: 'tools.overwrite',
65
65
  subject: toolName,
66
66
  // The incoming OWNER rides in the label rather than in a field of its own: a single `subject`
@@ -73,9 +73,10 @@ async function resolveToolCollision(
73
73
  // changed hands" is exactly the report this line answers. A policy that overwrites after ASKING
74
74
  // needs no warning — the user just saw the question.
75
75
  if (prompt === undefined) {
76
- console.warn(`[matbot] ${label} — non-interactive, ${allowed ? 'overwriting' : 'keeping the existing tool'}.`);
76
+ console.warn(`[matbot] ${label} — non-interactive (${decision.source}), `
77
+ + `${decision.allowed ? 'overwriting' : 'keeping the existing tool'}.`);
77
78
  }
78
- return allowed;
79
+ return decision.allowed;
79
80
  }
80
81
 
81
82
  // ── Version check ─────────────────────────────────────────────────────────────
package/src/runner.ts CHANGED
@@ -5,7 +5,7 @@ import type {
5
5
  PermissionGate,
6
6
  } from './types.js';
7
7
  import type { MatbotPlugin } from './plugin.js';
8
- import type { ToolPresenter, SessionAppender } from '@matatbread/matbot-plugin-api';
8
+ import type { ToolPresenter, SessionAppender, TurnToolSource } from '@matatbread/matbot-plugin-api';
9
9
  import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
10
10
  import { HookRegistry } from './hooks.js';
11
11
  import { appendMessage, createMessage } from './session.js';
@@ -83,6 +83,38 @@ const INTERRUPTED_TOOL_RESULT = {
83
83
  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.',
84
84
  } as const;
85
85
 
86
+ /**
87
+ * The turn tools `screen` offered (see {@link TurnToolSource}), as they stand for this round. Asked every
88
+ * round because the turn itself changes the session they are derived from. A name the registry (or the
89
+ * turn-start snapshot) already holds is dropped: the registered tool keeps it, since shadowing one is a
90
+ * gated decision and a hook must not be a way round it. `warned` keeps that to one line per name a turn.
91
+ * A source that throws is skipped, like a throwing hook — it must not take the turn down with it.
92
+ */
93
+ async function resolveTurnTools(
94
+ sources: readonly TurnToolSource[],
95
+ session: Session,
96
+ taken: (name: string) => boolean,
97
+ warned: Set<string>,
98
+ ): Promise<Map<string, Tool>> {
99
+ const out = new Map<string, Tool>();
100
+ for (const source of sources) {
101
+ let offered: readonly Tool[];
102
+ try { offered = await source(session); }
103
+ catch (e) { console.warn(`[runner] a turn tool source failed: ${e instanceof Error ? e.message : String(e)}`); continue; }
104
+ for (const tool of offered) {
105
+ if (taken(tool.name)) {
106
+ if (!warned.has(tool.name)) {
107
+ warned.add(tool.name);
108
+ console.warn(`[runner] turn tool "${tool.name}" dropped: a registered tool has that name.`);
109
+ }
110
+ continue;
111
+ }
112
+ if (!out.has(tool.name)) out.set(tool.name, tool);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
86
118
  export interface RunSessionOpts {
87
119
  session: Session;
88
120
  config: RunConfig;
@@ -160,6 +192,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
160
192
  async createSecret() { throw new Error('No vault configured'); },
161
193
  async writeSecret() { throw new Error('No vault configured'); },
162
194
  hasKey() { return false; },
195
+ async readSecret() { return undefined; },
163
196
  async resolve(ref: string) { return ref; },
164
197
  scrub(text: string) { return text; },
165
198
  };
@@ -238,6 +271,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
238
271
  // Returns whether anything was claimed. What the caller does with that is the part that legitimately
239
272
  // differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
240
273
  const deferred = screen.deferred;
274
+
275
+ // Re-resolved at the top of every round (below), so a tool defined in one round is callable in the next.
276
+ const turnToolSources = screen.tools;
277
+ const turnToolWarned = new Set<string>();
278
+ let turnTools = new Map<string, Tool>();
241
279
  async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
242
280
  if (deferred.length === 0) return false;
243
281
  const ephemeralC: MessageContent[] = [];
@@ -325,6 +363,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
325
363
  // discarded. This is the no-waste path — the verdict landed before we spent any tokens.
326
364
  yield* claimVerdicts();
327
365
 
366
+ if (turnToolSources.length > 0) {
367
+ turnTools = await resolveTurnTools(turnToolSources, session,
368
+ name => tools.has(name) || (opts.toolRegistry?.resolve(name) ?? null) !== null, turnToolWarned);
369
+ }
370
+
328
371
  const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
329
372
  truncated?: { bytes: number; stopReason?: string } }> = [];
330
373
  const assistantParts: MessageContent[] = [];
@@ -381,10 +424,13 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
381
424
  // tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
382
425
  // Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
383
426
  // that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
384
- const advertised = opts.toolPresenter
427
+ // Turn tools go AFTER the presenter, never through it: they are few, made for this session, and
428
+ // absent from the registry a presenter's own search reads — one it windowed out could not come back.
429
+ const presented = opts.toolPresenter
385
430
  ? await withUsageSite({ kind: 'round', round },
386
431
  () => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
387
432
  : [...tools.values()];
433
+ const advertised = turnTools.size > 0 ? [...presented, ...turnTools.values()] : presented;
388
434
  // Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
389
435
  // to cancel the in-flight request and stop backend generation, without aborting the whole turn.
390
436
  const callAc = new AbortController();
@@ -404,6 +450,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
404
450
  case 'thinking':
405
451
  yield { type: 'thinking', delta: ev.delta, traceId };
406
452
  break;
453
+ case 'progress':
454
+ yield { type: 'progress', pct: ev.pct, ...(ev.message !== undefined ? { message: ev.message } : {}), traceId };
455
+ break;
407
456
  case 'thinking-block':
408
457
  assistantParts.push({ type: 'thinking', thinking: ev.thinking, signature: ev.signature });
409
458
  break;
@@ -525,7 +574,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
525
574
  for (const [callIdx, tc] of pendingCalls.entries()) {
526
575
  yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
527
576
 
528
- const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name);
577
+ const tool = (opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name))
578
+ ?? turnTools.get(tc.name);
529
579
  if (!tool) {
530
580
  const err = { error: `Unknown tool: ${tc.name}` };
531
581
  toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
@@ -622,11 +672,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
622
672
  // default, so a gate handed it could never tell "no human is reachable" from "a human answered
623
673
  // with the default" — and would get the fallback back one layer too late to reason about.
624
674
  // `ToolContext.prompt` keeps the stand-in exactly as before; only the gate sees the truth.
625
- ...bindGate(opts.permissionGate, tc.name, opts.prompt),
675
+ ...bindGate(opts.permissionGate, tool, opts.prompt, opts.toolRegistry ?? { resolve: n => tools.get(n) ?? null }),
626
676
  ...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
627
677
  ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
628
678
  ...(opts.files !== undefined ? { files: opts.files } : {}),
629
679
  ...(opts.appender !== undefined ? { appender: opts.appender } : {}),
680
+ ...(turnTools.size > 0 ? { turnTools } : {}),
630
681
  };
631
682
 
632
683
  // Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
@@ -1,7 +1,6 @@
1
1
  import type { Vault } from '@matatbread/matbot-plugin-api';
2
- import { missingSecretError, applyCreateSecret, assertStorableKey, unreferenceableKey } from '@matatbread/matbot-plugin-api';
2
+ import { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey } from '@matatbread/matbot-plugin-api';
3
3
 
4
- const REF_RE = /\$\{([^}]+)\}/g;
5
4
 
6
5
  /**
7
6
  * Default vault implementation: a single flat namespace of secrets keyed by name.
@@ -58,19 +57,12 @@ export class VaultImpl implements Vault {
58
57
  return undefined;
59
58
  }
60
59
 
61
- async resolve(ref: string): Promise<string> {
62
- const errors: string[] = [];
63
-
64
- const result = ref.replace(REF_RE, (_, name: string) => {
65
- const value = this.store.get(name);
66
- if (value === undefined) { errors.push(name); return ''; }
67
- return value;
68
- });
60
+ async readSecret(name: string): Promise<string | undefined> {
61
+ return this.store.get(name);
62
+ }
69
63
 
70
- if (errors.length > 0) {
71
- throw missingSecretError(errors);
72
- }
73
- return result;
64
+ resolve(ref: string): Promise<string> {
65
+ return applyResolve(this, ref);
74
66
  }
75
67
 
76
68
  scrub(text: string): string {
@@ -108,7 +108,7 @@ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
108
108
  // "how's it going?" mid-task sees a history that ends a turn early and answers as though nothing were
109
109
  // running. Names the running request so the reply can be about it, and says what happens to the reply.
110
110
  const PARALLEL_FRAMING_CAP = 2_000;
111
- const parallelFraming = (running: string): UserContent => ({
111
+ const parallelFraming = (running: string): Extract<UserContent, { type: 'text' }> => ({
112
112
  type: 'text',
113
113
  origin: 'robo',
114
114
  text: 'A separate turn of this conversation is still working on the request below, and you cannot see its '
@@ -118,11 +118,32 @@ const parallelFraming = (running: string): UserContent => ({
118
118
  + '\n</running-request>',
119
119
  });
120
120
 
121
- // A parallel turn's write-back: its submission and final reply, ready to place. Built once, when the
122
- // parallel turn settles; placed by whoever holds the session at that moment (see `merges`).
121
+ // A parallel turn's span as it can be placed into shared history: trimmed back to the last point at
122
+ // which every tool-call in it has its result. An abort (or a failure mid-round) leaves a trailing
123
+ // tool-call with nothing answering it, and an unpaired call is not valid wire for any provider — it
124
+ // would break not just this placement but every later turn of the session.
125
+ const closedSpan = (msgs: Message[]): Message[] => {
126
+ const open = new Set<string>();
127
+ let closed = 0;
128
+ for (const [i, m] of msgs.entries()) {
129
+ for (const c of m.content) {
130
+ if (c.type === 'tool-call') open.add(c.id);
131
+ else if (c.type === 'tool-result') open.delete(c.id);
132
+ }
133
+ if (open.size === 0) closed = i + 1;
134
+ }
135
+ return msgs.slice(0, closed);
136
+ };
137
+
138
+ // A parallel turn's write-back: its submission and everything the turn produced, ready to place. Built
139
+ // once, when the parallel turn settles; placed by whoever holds the session at that moment (see
140
+ // `merges`).
123
141
  interface ParallelReply {
124
142
  traceId: string;
125
- messages: [Message, Message];
143
+ /** The turn's head first, then its whole span — thinking, tool calls, their results, its markers and
144
+ * its final reply. Non-empty by construction, which is what keeps `messages[0]` the head: every
145
+ * placement path dedupes on that id. */
146
+ messages: [Message, ...Message[]];
126
147
  /** The id of the message this reply's copy was cut AT — the running turn's head when it was submitted,
127
148
  * captured then. The pair belongs immediately ahead of it, because that is where the history the reply
128
149
  * was generated against ended. Absent ⇒ the copy was cut at the end of the session (nothing was
@@ -881,12 +902,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
881
902
 
882
903
  /**
883
904
  * A parallel turn: the submission runs at once on a nested runner over a private copy of the session's
884
- * completed turns, and only it and its final reply come back (`ParallelReply`). The copy ends where the
905
+ * completed turns, and its submission plus everything it produced come back (`ParallelReply`). The copy ends where the
885
906
  * running turn's own messages begin (`head`), plus any replies already placed into that turn but not
886
907
  * yet committed (`interjected`) — both captured at submit, before any await could outlive the turn.
887
908
  *
888
- * Its events are forwarded under its own traceId as progress, never persisted: its tool calls belong to
889
- * the copy, which is dropped here. A nested runner rather than `runSession` so the turn is an ordinary
909
+ * Its events are forwarded under its own traceId as progress; what persists is the span the write-back
910
+ * carries, read from the copy once the turn has settled. A nested runner rather than `runSession` so the turn is an ordinary
890
911
  * one — screen, followup, accounting, media — with nothing re-implemented.
891
912
  */
892
913
  const runParallel = async (
@@ -910,6 +931,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
910
931
  const have = new Set(prefix.map(m => m.id));
911
932
  const copy = { ...stored, messages: [...prefix, ...p.interjected.filter(m => !have.has(m.id))] };
912
933
  const running = p.head?.request ?? '';
934
+ const framing = running ? parallelFraming(running) : undefined;
913
935
 
914
936
  const store = new MemoryStore<Session>();
915
937
  await store.set(id, copy);
@@ -941,10 +963,21 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
941
963
  const msgs = final?.messages ?? [];
942
964
  const hIdx = msgs.findIndex(m => m.role === 'user' && m.traceId === p.traceId);
943
965
  const qHead = msgs[hIdx];
944
- // Only the reply's text crosses back: its tool calls stay in the copy (an unpaired one would break
945
- // every later turn), and its thinking was signed against a history that included those tool rounds.
946
- const last = hIdx >= 0 ? msgs.slice(hIdx + 1).findLast(m => m.role === 'assistant') : undefined;
947
- const answer = (last?.content ?? []).filter(c => c.type === 'text');
966
+ // The turn's WHOLE span crosses back, not just its final text: thinking, tool calls, their results
967
+ // and any markers. A history holding only the answer makes everything a parallel turn actually did
968
+ // unrecoverable the moment the live stream is gone — `determine_provenance` reads a real tool result
969
+ // as fabricated, and so does a person scrolling back. The span is placed exactly where the copy was
970
+ // cut, so it sits in the history it was generated against, and `closedSpan` is what keeps it valid
971
+ // wire. Thinking rides along persisted but unsubmitted (every adapter elides a historical block),
972
+ // and a foreign round-trip token is the adapters' to drop by `providerName`, which these messages
973
+ // carry — so nothing here depends on which provider ran the turn.
974
+ const span = hIdx >= 0 ? closedSpan(msgs.slice(hIdx + 1)) : [];
975
+ // Answered iff the span ENDS with assistant text. Not "contains some": a turn aborted after
976
+ // "let me check that" has said nothing, and a span ending on a tool result needs the note after it
977
+ // to close the turn on an assistant message.
978
+ const tail = span[span.length - 1];
979
+ const answer = tail?.role === 'assistant'
980
+ ? tail.content.filter(c => c.type === 'text' && c.text.trim() !== '') : [];
948
981
  const ended = rec.replay.findLast(e => e.type === 'done' || e.type === 'aborted' || e.type === 'error' || e.type === 'cancelled');
949
982
  const reason = ended?.type === 'aborted' ? `was stopped (${ended.reason})`
950
983
  : ended?.type === 'cancelled' ? 'was stopped before it started'
@@ -954,14 +987,19 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
954
987
  traceId: p.traceId,
955
988
  ...(p.head !== undefined ? { head: p.head.id } : {}),
956
989
  messages: [
957
- // The copy's own head, carrying its accounting, but with the content as the person submitted it.
990
+ // The copy's own head, carrying its accounting, with the framing block taken back OFF — that
991
+ // block is this turn's own context and not something the person wrote. Subtracted rather than
992
+ // replaced with `p.content` wholesale: a `contextual` trigger folds its correction DURABLY onto
993
+ // the head, and overwriting the content dropped exactly that, so the correction informed the
994
+ // answer and then vanished from the history the answer sits in.
958
995
  qHead !== undefined
959
- ? { ...qHead, content: p.content }
996
+ ? { ...qHead, content: qHead.content.filter(c => !(framing !== undefined && c.type === 'text'
997
+ && c.origin === framing.origin && c.text === framing.text)) }
960
998
  : createMessage({ role: 'user', content: p.content, traceId: p.traceId, providerName: p.provider }),
961
- last !== undefined && answer.length > 0
962
- ? { ...last, content: answer }
963
- : createMessage({ role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
964
- traceId: p.traceId, providerName: p.provider }),
999
+ ...span,
1000
+ ...(answer.length > 0 ? [] : [createMessage({
1001
+ role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
1002
+ traceId: p.traceId, providerName: p.provider })]),
965
1003
  ],
966
1004
  };
967
1005
  } catch (e) {