@corenel/tools-web 0.8.0 → 0.9.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/dist/runtime.js CHANGED
@@ -11,15 +11,16 @@ import { sessionSystemPrompt } from './sessionSystemPrompt';
11
11
  import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
12
12
  import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
13
13
  import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
14
- import { turnsFromSessionMessages } from '@corenel/harness/conversation';
14
+ import { turnsFromSessionMessages, foldPoint, isFoldedTurn } from '@corenel/harness/conversation';
15
15
  import { allTools } from '@corenel/harness/tools/builtins';
16
16
  import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
17
17
  import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
18
18
  import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
19
19
  import { getSystemParts } from '@corenel/harness/systemParts';
20
- import { CHECKPOINT_MARKER } from '@corenel/harness/core/compactHistory';
20
+ import { CHECKPOINT_MARKER, estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
21
21
  import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
22
22
  import { enabledSkillCatalog } from '@corenel/harness/skills';
23
+ import { alog } from '@corenel/harness/log';
23
24
  // The agent's model is the user's picker selection (getModel) — a real
24
25
  // catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
25
26
  const WATCHDOG_MS = 45000;
@@ -34,7 +35,8 @@ function emptyInternal() {
34
35
  pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
35
36
  pendingCommand: null, runId: null,
36
37
  recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
37
- offloadTick: 0, policyNotes: [], degradedModel: null,
38
+ offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null, persistedLen: null,
39
+ saveBlocked: null, loadMissed: false, mergeTick: 0,
38
40
  };
39
41
  }
40
42
  /** Build the user message — a plain string, or multimodal parts when images are attached. */
@@ -62,6 +64,35 @@ function userImages(m) {
62
64
  return [];
63
65
  return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
64
66
  }
67
+ /** What a send hands the run: the raw included transcript, or -- once a run
68
+ * has folded -- the newest LIVE seam's folded view plus the included turns
69
+ * after the user turn that produced it. Turns at or before that turn are
70
+ * already in the view; sending them raw again re-folded the whole history
71
+ * every send. A view whose user turn is gone is skipped for the one before it,
72
+ * and with no live view at all the transcript goes raw (the next run re-folds). */
73
+ function outgoingMessages(turns) {
74
+ const fp = foldPoint(turns);
75
+ if (fp)
76
+ return [...fp.view, ...turns.slice(fp.throughIndex + 1).filter((t) => t.included).flatMap((t) => t.messages)];
77
+ return turns.filter((t) => t.included).flatMap((t) => t.messages);
78
+ }
79
+ /** Stamp a run's folded view on its newest seam, and keep exactly ONE older
80
+ * view: each is a full copy of the window and persist rewrites every turn on
81
+ * every send, so views left on every seam grow without bound in a chat that
82
+ * never ends. The one kept is what `retry` of the folding turn falls back to
83
+ * (retry removes the newest seam with its user turn). */
84
+ function stampFoldedView(turns, seamId, view, through) {
85
+ const older = turns.filter((t) => t.foldedView && t.id !== seamId).map((t) => t.id);
86
+ const keep = older[older.length - 1];
87
+ return turns.map((t) => {
88
+ if (t.id === seamId)
89
+ return { ...t, foldedView: view, foldedThrough: through };
90
+ if (!t.foldedView || t.id === keep)
91
+ return t;
92
+ const { foldedView: _view, foldedThrough: _through, ...rest } = t;
93
+ return rest;
94
+ });
95
+ }
65
96
  /** Merge a form's freshly-emitted envelope into a per-run override, against the
66
97
  * currently saved default. The form only emits the keys it still wants set — a
67
98
  * cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
@@ -167,6 +198,34 @@ export class AgentRuntime {
167
198
  writable: true,
168
199
  value: null
169
200
  });
201
+ /* The chat's estimated size in tokens, keyed by the turns array: turns are
202
+ * replaced, never mutated, so the same array means the same size. The picker
203
+ * asks canHold for every row on every render; this keeps that one flatten
204
+ * per transcript, not one per row. */
205
+ Object.defineProperty(this, "fill", {
206
+ enumerable: true,
207
+ configurable: true,
208
+ writable: true,
209
+ value: new WeakMap()
210
+ });
211
+ /* ---- run ---- */
212
+ /* One store write at a time per session: every read-modify-write of the
213
+ * stored chat (persist, the first-send retitle) goes through here. Each
214
+ * re-reads the session first; two overlapping would let one read the other's
215
+ * write before persistedLen caught up and take it for another writer, or
216
+ * write back a list the other had just shortened (a retitle restoring a
217
+ * cleared chat, R37). */
218
+ Object.defineProperty(this, "writing", {
219
+ enumerable: true,
220
+ configurable: true,
221
+ writable: true,
222
+ value: new Map()
223
+ });
224
+ // Availability and the fallback are read live, so every open chat's
225
+ // notice must re-render when either moves.
226
+ if (host.perChatModel)
227
+ host.onModelsChange?.(() => { for (const id of this.subs.keys())
228
+ this.notify(id); });
170
229
  }
171
230
  ws() {
172
231
  if (!this._ws)
@@ -244,7 +303,13 @@ export class AgentRuntime {
244
303
  return () => { set.delete(cb); };
245
304
  }
246
305
  subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
247
- getState(id) { return this.map.get(id) ?? emptyInternal(); }
306
+ getState(id) {
307
+ const s = this.map.get(id);
308
+ if (!s)
309
+ return emptyInternal();
310
+ s.modelNotice = this.modelNotice(id, s);
311
+ return s;
312
+ }
248
313
  status(id) {
249
314
  const s = this.map.get(id);
250
315
  if (!s)
@@ -271,8 +336,31 @@ export class AgentRuntime {
271
336
  const s = this.ensure(id);
272
337
  if (s.loaded)
273
338
  return;
274
- const ses = await this.host.sessions.get(id);
275
- s.turns = turnsFromSessionMessages(ses?.messages);
339
+ /* A read that THROWS is a miss, not an error for the caller: send awaits
340
+ * this, and a rejected load would drop the send outright while the next
341
+ * attempt fails the same way. As a miss it goes down the path that is
342
+ * covered: the exchange runs, saveBlocked says it is not kept, and Retry
343
+ * (retryLoad) reads again. */
344
+ let ses;
345
+ try {
346
+ ses = await this.host.sessions.get(id);
347
+ }
348
+ catch {
349
+ ses = null;
350
+ }
351
+ // A concurrent load (the focus effect beside a send) landed first, and its
352
+ // merge already holds everything; merging again would double the chat.
353
+ if (s.loaded)
354
+ return;
355
+ /* MERGE, never replace: anything this page added before the stored chat
356
+ * arrived -- a /model notice, say -- comes after it rather than vanishing.
357
+ * A miss leaves those turns alone too. */
358
+ const pre = s.turns;
359
+ if (ses) {
360
+ s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
361
+ s.persistedLen = ses.messages.length;
362
+ }
363
+ s.model = ses?.model ?? null;
276
364
  /* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
277
365
  * it permanent: the session could arrive a moment later and this would
278
366
  * never look again, because the guard above returns on `loaded`.
@@ -290,6 +378,11 @@ export class AgentRuntime {
290
378
  * -- not per render -- so a genuinely absent session does not spin. */
291
379
  if (ses)
292
380
  s.loaded = true;
381
+ s.loadMissed = !ses;
382
+ // Loaded with nothing of this page's to save: nothing is unsaved any more.
383
+ // With something, the persist below clears the flag once it lands.
384
+ if (ses && !pre.length)
385
+ s.saveBlocked = null;
293
386
  /* MIGRATION. A conversation from before the snapshot existed has none, and
294
387
  * would otherwise keep following the live Settings default forever. Opening
295
388
  * it freezes it at what it would have used at this moment, so nothing
@@ -298,6 +391,37 @@ export class AgentRuntime {
298
391
  if (!sessionSystemConfig(id))
299
392
  this.freezeSystemConfig(id);
300
393
  this.notify(id);
394
+ // What was added before the load could not be saved then (see persist).
395
+ if (ses && pre.length)
396
+ void this.persist(id);
397
+ }
398
+ /** Try the stored chat again after a load failed (the unsaved-chat notice's
399
+ * Retry): load it -- merging what this page added meanwhile after it, as
400
+ * `load` always does -- then save the merged list. True once it is saved;
401
+ * false when the load missed again (or threw), and `saveBlocked` stays. */
402
+ async retryLoad(id) {
403
+ await this.load(id);
404
+ const s = this.map.get(id);
405
+ if (!s?.loaded)
406
+ return false;
407
+ // persistNow swallows its own failures; this catch keeps the promise a
408
+ // Retry button awaits from ever rejecting.
409
+ await this.persist(id).catch(() => { });
410
+ /* GLOBAL, because the session list is. While the load kept missing, the
411
+ * list the page shows (and the header's title, read from it) could drop
412
+ * this chat; it is back now, and only a global change makes the page
413
+ * re-read the list. Without it the recovered chat stayed missing from the
414
+ * list, titled "New chat", until a reload. */
415
+ this.notifyGlobal();
416
+ return s.saveBlocked === null;
417
+ }
418
+ /** Raise the unsaved-chat flag when `id` is still not loaded after a load
419
+ * was tried, so the page says its new messages are not being kept. */
420
+ flagIfUnsaved(id) {
421
+ const s = this.map.get(id);
422
+ if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
423
+ this.patch(id, { saveBlocked: 'load-failed' });
424
+ }
301
425
  }
302
426
  async openInitial() {
303
427
  if (this.initInFlight)
@@ -310,7 +434,11 @@ export class AgentRuntime {
310
434
  // the traces, to be pruned with them.
311
435
  const empty = list.find((s) => !s.parent && (!s.messages || s.messages.length === 0));
312
436
  if (empty) {
313
- this.ensure(empty.id).loaded = true;
437
+ const s = this.ensure(empty.id);
438
+ s.loaded = true;
439
+ s.persistedLen = 0;
440
+ // A model picked on this empty chat before the reload is still its model.
441
+ s.model = empty.model ?? null;
314
442
  return empty.id;
315
443
  }
316
444
  return this.newSession();
@@ -334,6 +462,7 @@ export class AgentRuntime {
334
462
  const ses = await this.host.sessions.create('New chat', this.surface);
335
463
  const s = this.ensure(ses.id);
336
464
  s.loaded = true;
465
+ s.persistedLen = 0;
337
466
  // The system prompt is the head of the cached prefix, so this conversation's
338
467
  // config is decided HERE and read from the snapshot after -- see
339
468
  // sessionSystemConfig. Settings decides what the next one starts with.
@@ -352,6 +481,77 @@ export class AgentRuntime {
352
481
  deleteDocsForSession(id);
353
482
  this.notifyGlobal();
354
483
  }
484
+ /* ---- model per chat ---- */
485
+ available(model) {
486
+ return this.host.isModelAvailable ? this.host.isModelAvailable(model) : true;
487
+ }
488
+ /** Is the chat's STORED model still usable? Trusted until the catalog has
489
+ * been consulted: before that, "not in the catalog" means "not looked yet",
490
+ * and acting on it would announce a fallback and fold for nothing. */
491
+ storedAvailable(model) {
492
+ if (this.host.modelsSettled && !this.host.modelsSettled())
493
+ return true;
494
+ return this.available(model);
495
+ }
496
+ /** The stored model when it is gone and the chat would run on a fallback. */
497
+ lostModel(s) {
498
+ if (!this.host.perChatModel || !s.model)
499
+ return null;
500
+ return this.storedAvailable(s.model) ? null : s.model;
501
+ }
502
+ modelNotice(id, s) {
503
+ const lost = this.lostModel(s);
504
+ if (!lost)
505
+ return null;
506
+ const fallback = this.getModel();
507
+ return `This chat used ${lost}, which is not available. It will run on ${fallback}`
508
+ + (this.canHold(id, fallback) ? '.' : ', and older history will be folded to fit.');
509
+ }
510
+ /** The model this chat runs on: its own if still available, else the
511
+ * last-used pick. Without `perChatModel`, always the last-used pick. */
512
+ chatModel(id) {
513
+ const s = this.map.get(id);
514
+ if (!this.host.perChatModel || !s?.model || this.lostModel(s))
515
+ return this.getModel();
516
+ return s.model;
517
+ }
518
+ fillTokens(turns) {
519
+ let n = this.fill.get(turns);
520
+ if (n === undefined) {
521
+ n = estimateTokens(outgoingMessages(turns));
522
+ this.fill.set(turns, n);
523
+ }
524
+ return n;
525
+ }
526
+ /** Can `model`'s window hold this chat as it stands (below the fold line)?
527
+ * Measured over what a send would actually hand the run -- the folded view
528
+ * plus the turns after it -- not the raw transcript, which overstates a chat
529
+ * that has already folded. */
530
+ canHold(id, model) {
531
+ const s = this.map.get(id);
532
+ if (!s)
533
+ return true;
534
+ const window = this.host.modelInfo(model).contextWindow ?? 128000;
535
+ if (window <= 0)
536
+ return true;
537
+ return this.fillTokens(s.turns) / window < COMPACT_AT_FILL;
538
+ }
539
+ /** Switch this chat (only) to `model`; null when it switched. Refused, with
540
+ * nothing changed: before the chat has loaded (there is nothing to measure,
541
+ * and persisting would write an empty transcript over the stored one); when
542
+ * the model is not available (it would silently run on the fallback); and
543
+ * when it cannot hold the chat (switching never folds, spec 3.5). */
544
+ setChatModel(id, model) {
545
+ if (!this.map.get(id)?.loaded)
546
+ return 'not-loaded';
547
+ if (!this.available(model))
548
+ return 'unavailable';
549
+ if (!this.canHold(id, model))
550
+ return 'too-large';
551
+ this.patch(id, { model });
552
+ void this.persist(id);
553
+ return null;
554
+ }
355
555
  /* ---- view mutators ---- */
356
556
  setInput(id, v) { this.patch(id, { input: v }); }
357
557
  setMode(id, m) { this.patch(id, { mode: m }); }
@@ -367,12 +567,15 @@ export class AgentRuntime {
367
567
  void this.persist(id);
368
568
  }
369
569
  /** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
370
- * sent to the model, just shown in the transcript. */
570
+ * sent to the model, just shown in the transcript. Returns the turn's id. */
371
571
  note(id, text, extra) {
372
572
  const s = this.ensure(id);
373
- const turn = { id: `n${Date.now().toString(36)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
573
+ // The random suffix: two notes in one millisecond (a fold beside a budget
574
+ // warning) must not share an id, or a later update by id lands on both.
575
+ const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
374
576
  this.patch(id, { turns: [...s.turns, turn] });
375
577
  void this.persist(id);
578
+ return turn.id;
376
579
  }
377
580
  /* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
378
581
  * event's contract: compaction is lossy, and a person who cannot see that it
@@ -392,7 +595,7 @@ export class AgentRuntime {
392
595
  * The body rides along so the seam can be READ. It was dropped here before
393
596
  * -- the event carries it, and this took only the count -- which left the
394
597
  * one question a seam raises with no answer anywhere in the UI. */
395
- this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
598
+ return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
396
599
  }
397
600
  /** Show a message in the transcript as YOUR turn, without adding it to what
398
601
  * gets sent. Used for injected messages: the real copy is already in the
@@ -408,8 +611,13 @@ export class AgentRuntime {
408
611
  setAttachments(id, fn) {
409
612
  this.patch(id, { attachments: fn(this.ensure(id).attachments) });
410
613
  }
614
+ /** Flip whether turn `turnId` is sent. A FOLDED turn (see isFoldedTurn) is
615
+ * refused -- no state change, no persist: the folded view stands for it, so
616
+ * including or dropping it would change nothing the next request carries. */
411
617
  toggleInclude(id, turnId) {
412
618
  const s = this.ensure(id);
619
+ if (isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
620
+ return;
413
621
  this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
414
622
  void this.persist(id);
415
623
  }
@@ -548,16 +756,91 @@ export class AgentRuntime {
548
756
  this.getWorker().inject(s.runId, messages, hard);
549
757
  return true;
550
758
  }
551
- /* ---- run ---- */
552
- async persist(id) {
759
+ queueWrite(id, write) {
760
+ // Nothing queued: start now, at the same tick as before this queue existed.
761
+ // A failed write must not stop the ones queued behind it.
762
+ const prev = this.writing.get(id);
763
+ const next = prev ? prev.catch(() => { }).then(write) : write();
764
+ this.writing.set(id, next);
765
+ next.finally(() => { if (this.writing.get(id) === next)
766
+ this.writing.delete(id); }).catch(() => { });
767
+ return next;
768
+ }
769
+ persist(id) {
770
+ return this.queueWrite(id, () => this.persistNow(id));
771
+ }
772
+ async persistNow(id) {
553
773
  const s = this.map.get(id);
554
- if (!s)
774
+ /* Never before the stored transcript has loaded (ruling R34). Until then
775
+ * `turns` holds only what this page added -- a /model notice, say -- and
776
+ * saving it would write that over the whole stored chat. Such notes stay
777
+ * in memory -- and once a load has actually failed, say so. */
778
+ if (!s?.loaded) {
779
+ this.flagIfUnsaved(id);
780
+ return;
781
+ }
782
+ // A read that throws is a save that did not happen: persistedLen and
783
+ // saveBlocked stay as they are, and the next write tries again.
784
+ let ses;
785
+ try {
786
+ ses = await this.host.sessions.get(id);
787
+ }
788
+ catch {
555
789
  return;
556
- const ses = await this.host.sessions.get(id);
790
+ }
557
791
  if (!ses)
558
792
  return;
559
- ses.messages = s.turns;
560
- await this.host.sessions.save(ses).catch(() => { });
793
+ /* Shorten the stored chat (retry, /clear) only when it is the length this
794
+ * page last saw (ruling R36). Otherwise another writer appended since, and
795
+ * this page's shorter list would delete that writer's newer turns. */
796
+ const known = ses.messages.length === s.persistedLen;
797
+ let turns = s.turns;
798
+ if (!known) {
799
+ /* Another writer changed it since. Saving this page's list as it stands
800
+ * would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
801
+ * 1, over the other tab's q1 -- so merge by turn id instead, over what
802
+ * was just read: stored order first, this page's version of any turn
803
+ * both hold (last writer wins per turn), then this page's turns the
804
+ * store does not have, in this page's order. A flat (daemon) chat's
805
+ * turns get the same `stored-<i>` ids here as at load, so a turn this
806
+ * page loaded from it matches and is not added twice.
807
+ *
808
+ * The page then shows the merged chat. Nothing it holds is dropped, and
809
+ * the store loses nothing: the merge holds every stored turn, so the
810
+ * save may rewrite freely (allowTruncate below), which a flat store's
811
+ * conversion to turns needs. */
812
+ const stored = turnsFromSessionMessages(ses.messages);
813
+ const mine = new Map(s.turns.map((t) => [t.id, t]));
814
+ const storedIds = new Set(stored.map((t) => t.id));
815
+ /* Only the FIRST stored turn with an id takes this page's version. A
816
+ * second one with the same id is another turn that collided (ids are
817
+ * meant to be unique; turnsFromSessionMessages keeps its synthetic ones
818
+ * so), and replacing it too would write one turn twice and lose the
819
+ * other on this rewrite. */
820
+ const used = new Set();
821
+ const merged = stored.map((t) => {
822
+ const own = mine.get(t.id);
823
+ if (!own || used.has(t.id))
824
+ return t;
825
+ used.add(t.id);
826
+ return own;
827
+ });
828
+ turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
829
+ alog(`session ${id}: stored has ${ses.messages.length} messages, this page last saw ${s.persistedLen}; another writer changed it, so this save merges both by turn (${turns.length} turns)`);
830
+ this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
831
+ }
832
+ ses.messages = turns;
833
+ if (s.model)
834
+ ses.model = s.model;
835
+ try {
836
+ // Known: this page's list IS the chat, and may shorten it (R36). A
837
+ // merge: holds every stored turn, so rewriting cannot lose one.
838
+ await this.host.sessions.save(ses, { allowTruncate: true });
839
+ s.persistedLen = turns.length;
840
+ if (s.saveBlocked)
841
+ this.patch(id, { saveBlocked: null });
842
+ }
843
+ catch { /* a failed save leaves persistedLen as it was */ }
561
844
  }
562
845
  /** Filename of the system prompt this surface will run with next — what the
563
846
  * context drawer's active-prompt chip shows. Mirrors composeSystem's own
@@ -684,7 +967,21 @@ export class AgentRuntime {
684
967
  const s = this.ensure(id);
685
968
  if ((!text && images.length === 0) || s.running)
686
969
  return;
687
- const isFirst = s.turns.length === 0;
970
+ /* The stored chat first (ruling R35). Nothing stops the composer before it
971
+ * lands, and a send built on the empty pre-load list would run with no
972
+ * history, retitle the chat as if it were new, and then be wiped by the
973
+ * load or never saved. Also retries a load that missed. */
974
+ if (!s.loaded) {
975
+ await this.load(id);
976
+ // Another send started while this one waited (ruling R37): treat this
977
+ // one like a send during a run -- nothing happens, the draft stays.
978
+ if (s.running)
979
+ return;
980
+ }
981
+ // Only a LOADED empty chat is new. One whose load missed again is empty
982
+ // because it was never read, and retitling it would write this message's
983
+ // text over the stored chat's title while every other write is held back.
984
+ const isFirst = s.loaded && s.turns.length === 0;
688
985
  const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
689
986
  const nextTurns = [...s.turns, userTurn];
690
987
  s.toolMs = {};
@@ -704,6 +1001,10 @@ export class AgentRuntime {
704
1001
  turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
705
1002
  policyNotes: [], degradedModel: null, injectedIds: [], lastStopReason: null,
706
1003
  }, true);
1004
+ // Still not loaded after that retry: this exchange runs and shows, but will
1005
+ // not be saved. Flagged once the turn exists, so the notice is up for the
1006
+ // whole run rather than only after it.
1007
+ this.flagIfUnsaved(id);
707
1008
  // Feature guard — the runtime is the chokepoint for every /agent run (send
708
1009
  // and retry), so a quota/plan guard registered later covers this surface too.
709
1010
  const gate = await this.host.guard('llm-execution');
@@ -725,20 +1026,26 @@ export class AgentRuntime {
725
1026
  // Title an untitled session from its first message, so the switcher is readable.
726
1027
  // A hand-renamed session is locked, so its chosen title is left alone.
727
1028
  if (isFirst && text) {
728
- const ses = await this.host.sessions.get(id);
729
- if (ses && !ses.titleLocked) {
730
- ses.title = text.slice(0, 48);
731
- await this.host.sessions.save(ses).catch(() => { });
732
- this.notifyGlobal();
733
- }
1029
+ await this.queueWrite(id, async () => {
1030
+ const ses = await this.host.sessions.get(id);
1031
+ if (ses && !ses.titleLocked) {
1032
+ ses.title = text.slice(0, 48);
1033
+ await this.host.sessions.save(ses).catch(() => { });
1034
+ this.notifyGlobal();
1035
+ }
1036
+ }).catch(() => { });
734
1037
  }
735
- const outgoing = nextTurns.filter((t) => t.included).flatMap((t) => t.messages);
1038
+ const outgoing = outgoingMessages(nextTurns);
736
1039
  /* Transcript-only turns noteUser() added for messages injected mid-run.
737
1040
  * They exist so the pivot is visible WHILE the run is in flight; once the
738
1041
  * run returns, its own copy of each injected message arrives in `created`
739
1042
  * and folds into the user turn, so keeping these would render the text a
740
1043
  * second time below the final reply, where it reads as unanswered. */
741
1044
  const injectedTurnIds = [];
1045
+ /* The seams THIS run wrote. The last one carries the run's folded view
1046
+ * once the run returns, so the next send starts from the fold instead of
1047
+ * re-folding the raw transcript. */
1048
+ const seamIds = [];
742
1049
  let verification;
743
1050
  let auditIncomplete;
744
1051
  const ac = new AbortController();
@@ -779,7 +1086,14 @@ export class AgentRuntime {
779
1086
  this.composeSystem(id, opts.persona, opts.partsOverride),
780
1087
  enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
781
1088
  ]);
782
- const model = this.getModel();
1089
+ const model = this.chatModel(id);
1090
+ /* Decided now, from what is true now: the chat's own model is gone and
1091
+ * the one it falls back to cannot hold it. A model picked since load, or
1092
+ * a catalog that has caught up, changes the answer. */
1093
+ const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
1094
+ // The chat now runs on `model`; storing it is what clears the notice.
1095
+ if (this.host.perChatModel && s.model !== model)
1096
+ this.patch(id, { model });
783
1097
  // Live model for onBreach pricing — reassigned when a degrade breach
784
1098
  // switches the run onto a cheaper model (see onBreach below).
785
1099
  let activeModel = model;
@@ -846,6 +1160,7 @@ export class AgentRuntime {
846
1160
  : (getPolicy(opts.policyName) ?? STANDARD_POLICY),
847
1161
  ...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
848
1162
  compression: { mode: this.host.compression(), contextWindow },
1163
+ ...(forceFold ? { forceFold: true } : {}),
849
1164
  signal: ac.signal,
850
1165
  sessionId: id,
851
1166
  toolNames,
@@ -997,7 +1312,7 @@ export class AgentRuntime {
997
1312
  return;
998
1313
  }
999
1314
  if (e.type === 'checkpoint') {
1000
- this.noteCheckpoint(id, e.collapsed, e.checkpoint);
1315
+ seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
1001
1316
  return;
1002
1317
  }
1003
1318
  if (e.type === 'tools-loaded') {
@@ -1085,11 +1400,13 @@ export class AgentRuntime {
1085
1400
  ...(res.calls ? { calls: res.calls } : {}),
1086
1401
  }
1087
1402
  : undefined;
1403
+ const lastSeam = seamIds[seamIds.length - 1];
1404
+ const stitched = s.turns
1405
+ .filter((t) => !injectedTurnIds.includes(t.id))
1406
+ .map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
1088
1407
  this.patch(id, {
1089
1408
  lastStopReason: res.stopReason,
1090
- turns: s.turns
1091
- .filter((t) => !injectedTurnIds.includes(t.id))
1092
- .map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t)),
1409
+ turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
1093
1410
  });
1094
1411
  void this.persist(id);
1095
1412
  if (stats) {
@@ -1158,12 +1475,21 @@ export class AgentRuntime {
1158
1475
  if (!userText || s.running)
1159
1476
  return;
1160
1477
  this.ws();
1478
+ // As in send: never build on a transcript that has not loaded, and never
1479
+ // start a second turn that began while the load was pending.
1480
+ if (!s.loaded) {
1481
+ await this.load(id);
1482
+ if (s.running)
1483
+ return;
1484
+ }
1161
1485
  const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
1162
1486
  const ac = new AbortController();
1163
1487
  s.abort = ac;
1164
1488
  // Optimistic, for the same reason as `send` above: the guard is a network
1165
1489
  // call, and a host-driven turn that waits on it looks like nothing happened.
1166
1490
  this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
1491
+ // As in send: a load that missed again leaves this turn unsaved; say so now.
1492
+ this.flagIfUnsaved(id);
1167
1493
  const gate = await this.host.guard('llm-execution');
1168
1494
  if (!gate.allowed) {
1169
1495
  s.abort = null;
@@ -1192,13 +1518,16 @@ export class AgentRuntime {
1192
1518
  * with the full content — the context drawer's per-item Restore, transplanted
1193
1519
  * from the harness/conversation.ts singleton (same marker-matching and
1194
1520
  * identity-swap semantics; see that module for the rationale). Returns false
1195
- * (state untouched, no persist) when the turn or the matching stub is absent. */
1521
+ * (state untouched, no persist) when the turn or the matching stub is absent,
1522
+ * or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
1523
+ * it, so content restored into it would never be sent. */
1196
1524
  restoreToolResult(id, turnId, blobId, content) {
1197
1525
  const marker = `recall_result({ id: "${blobId}" })`;
1198
1526
  const s = this.ensure(id);
1199
- const turn = s.turns.find((t) => t.id === turnId);
1200
- if (!turn)
1527
+ const at = s.turns.findIndex((t) => t.id === turnId);
1528
+ if (at < 0 || isFoldedTurn(s.turns, at))
1201
1529
  return false;
1530
+ const turn = s.turns[at];
1202
1531
  const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
1203
1532
  if (idx < 0)
1204
1533
  return false;