@yeaft/webchat-agent 1.0.213 → 1.0.214

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.
@@ -1 +1 @@
1
- {"version":"1.0.213"}
1
+ {"version":"1.0.214"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.213",
3
+ "version": "1.0.214",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
21
21
  import { join, basename } from 'path';
22
22
  import { isPermissionError } from '../init.js';
23
+ import { writeAtomic } from '../storage/atomic.js';
23
24
  import { pairSanitize } from '../pair-sanitize.js';
24
25
  import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
25
26
  import { isHiddenConversationRow } from './internal-control.js';
@@ -76,6 +77,7 @@ function emptySegmentIndex() {
76
77
  lastMessageId: null,
77
78
  activeSegment: SEGMENT_FIRST_NAME,
78
79
  segments: [],
80
+ foldedMessageIds: [],
79
81
  };
80
82
  }
81
83
 
@@ -91,6 +93,26 @@ function normalizeSegmentRecord(msg) {
91
93
  return out;
92
94
  }
93
95
 
96
+ function foldedMessageIdsFrom(rows) {
97
+ const ids = new Set();
98
+ for (const row of rows || []) {
99
+ if (!row?._reflection || !Array.isArray(row.foldedMessageIds)) continue;
100
+ for (const id of row.foldedMessageIds) {
101
+ if (typeof id === 'string' && id) ids.add(id);
102
+ }
103
+ }
104
+ return ids;
105
+ }
106
+
107
+ function applyFoldedMessageTombstones(rows, additionalIds = []) {
108
+ const foldedIds = foldedMessageIdsFrom(rows);
109
+ for (const id of additionalIds || []) {
110
+ if (typeof id === 'string' && id) foldedIds.add(id);
111
+ }
112
+ if (foldedIds.size === 0) return rows;
113
+ return rows.filter(row => !foldedIds.has(row?.id));
114
+ }
115
+
94
116
  function segmentNameForNumber(n) {
95
117
  return `${String(n).padStart(6, '0')}.jsonl`;
96
118
  }
@@ -363,6 +385,8 @@ function serializeMessage(msg) {
363
385
  if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
364
386
  if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
365
387
  if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
388
+ if (msg.incomplete) fm.push('incomplete: true');
389
+ if (msg.stopReason) fm.push(`stopReason: ${msg.stopReason}`);
366
390
  // Session attribution: when a VP authors an assistant turn (either
367
391
  // its own reply or a route_forward injection from another VP), stamp
368
392
  // the speaker so the UI can render the message on the correct VP track.
@@ -492,6 +516,8 @@ export function parseMessage(raw) {
492
516
  case 'sessionId': msg.sessionId = value; break;
493
517
  case 'chatId': msg.chatId = value; break;
494
518
  case 'clientMessageId': msg.clientMessageId = value; break;
519
+ case 'incomplete': msg.incomplete = value === 'true'; break;
520
+ case 'stopReason': msg.stopReason = value; break;
495
521
  case 'speakerVpId': msg.speakerVpId = value; break;
496
522
  case 'attachmentsB64':
497
523
  try {
@@ -621,8 +647,9 @@ class SegmentStore {
621
647
  idx = null;
622
648
  }
623
649
  }
624
- this.index = this.#normalizeIndex(idx || this.#rebuildIndex());
625
- if (!existsSync(this.indexPath) && this.hasData()) this.saveIndex();
650
+ const indexWasStale = idx && !this.#indexMatchesDisk(idx);
651
+ this.index = this.#normalizeIndex(!idx || indexWasStale ? this.#rebuildIndex() : idx);
652
+ if ((!existsSync(this.indexPath) || indexWasStale) && this.hasData()) this.saveIndex();
626
653
  return this.index;
627
654
  }
628
655
 
@@ -659,6 +686,12 @@ class SegmentStore {
659
686
  idx.totalMessages = (idx.totalMessages || 0) + 1;
660
687
  idx.lastMessageId = msg.id || null;
661
688
  idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
689
+ if (msg._reflection && Array.isArray(msg.foldedMessageIds)) {
690
+ idx.foldedMessageIds = Array.from(new Set([
691
+ ...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
692
+ ...msg.foldedMessageIds.filter(id => typeof id === 'string' && id),
693
+ ]));
694
+ }
662
695
  this.saveIndex();
663
696
  }
664
697
 
@@ -672,9 +705,11 @@ class SegmentStore {
672
705
  const out = [];
673
706
  for (const seg of segments) {
674
707
  const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
675
- out.push(...rows);
708
+ out.push(...applyFoldedMessageTombstones(rows, idx.foldedMessageIds));
676
709
  }
677
- return desc ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id)) : out.sort(compareMessagesBySeq);
710
+ return desc
711
+ ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id))
712
+ : out.sort(compareMessagesBySeq);
678
713
  }
679
714
 
680
715
  *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
@@ -685,7 +720,8 @@ class SegmentStore {
685
720
  .slice()
686
721
  .sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
687
722
  for (const seg of segments) {
688
- for (const row of this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold })) yield row;
723
+ const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
724
+ yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
689
725
  }
690
726
  }
691
727
 
@@ -705,18 +741,39 @@ class SegmentStore {
705
741
  for (const msg of (rows || []).filter(Boolean).sort(compareMessagesBySeq)) this.append(msg);
706
742
  }
707
743
 
744
+ updateById(id, updater) {
745
+ if (!id || typeof updater !== 'function' || !this.hasData()) return null;
746
+ const targetSeq = parseSeqFromId(id);
747
+ const idx = this.loadIndex();
748
+ const segment = (idx.segments || []).find((candidate) => {
749
+ const first = Number(candidate.firstSeq);
750
+ const last = Number(candidate.lastSeq);
751
+ return Number.isFinite(targetSeq)
752
+ && Number.isFinite(first)
753
+ && Number.isFinite(last)
754
+ && targetSeq >= first
755
+ && targetSeq <= last;
756
+ });
757
+ if (!segment?.file) return null;
758
+
759
+ const path = join(this.segmentDir, segment.file);
760
+ const rows = this.#readSegment(segment.file, { includeCold: true });
761
+ const rowIndex = rows.findIndex(row => row?.id === id);
762
+ if (rowIndex < 0) return null;
763
+ const next = updater({ ...rows[rowIndex] });
764
+ if (!next || typeof next !== 'object') return null;
765
+ const updated = { ...next, id: rows[rowIndex].id };
766
+ rows[rowIndex] = updated;
767
+
768
+ const body = rows.map(row => JSON.stringify(row)).join('\n') + (rows.length > 0 ? '\n' : '');
769
+ writeAtomic(path, body);
770
+ segment.bytes = Buffer.byteLength(body);
771
+ this.saveIndex();
772
+ return updated;
773
+ }
774
+
708
775
  markCold(id) {
709
- if (!id || !this.hasData()) return 0;
710
- const rows = this.readAll({ includeCold: true });
711
- let changed = 0;
712
- for (const msg of rows) {
713
- if (msg?.id === id && msg.cold !== true) {
714
- msg.cold = true;
715
- changed += 1;
716
- }
717
- }
718
- if (changed > 0) this.replaceAll(rows);
719
- return changed;
776
+ return this.updateById(id, msg => ({ ...msg, cold: true })) ? 1 : 0;
720
777
  }
721
778
 
722
779
  clear() {
@@ -729,6 +786,19 @@ class SegmentStore {
729
786
  this.index = emptySegmentIndex();
730
787
  }
731
788
 
789
+ #indexMatchesDisk(idx) {
790
+ if (!existsSync(this.segmentDir)) return !(idx?.segments?.length > 0);
791
+ const diskFiles = readdirSync(this.segmentDir).filter(file => file.endsWith('.jsonl')).sort();
792
+ const indexedSegments = Array.isArray(idx?.segments) ? idx.segments : [];
793
+ const indexedFiles = indexedSegments.map(segment => segment?.file).filter(Boolean).sort();
794
+ if (diskFiles.length !== indexedFiles.length
795
+ || diskFiles.some((file, index) => file !== indexedFiles[index])) return false;
796
+ return indexedSegments.every(segment => {
797
+ const path = join(this.segmentDir, segment.file);
798
+ return existsSync(path) && statSync(path).size === Number(segment.bytes);
799
+ });
800
+ }
801
+
732
802
  #normalizeIndex(idx) {
733
803
  const out = { ...emptySegmentIndex(), ...(idx || {}) };
734
804
  out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
@@ -737,6 +807,9 @@ class SegmentStore {
737
807
  out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
738
808
  out.activeSegment = out.activeSegment || out.segments[out.segments.length - 1]?.file || SEGMENT_FIRST_NAME;
739
809
  out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
810
+ out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
811
+ ? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
812
+ : [];
740
813
  return out;
741
814
  }
742
815
 
@@ -758,10 +831,12 @@ class SegmentStore {
758
831
  };
759
832
  idx.segments.push(seg);
760
833
  idx.totalMessages += rows.length;
834
+ idx.foldedMessageIds.push(...foldedMessageIdsFrom(rows));
761
835
  idx.lastMessageId = rows[rows.length - 1]?.id || idx.lastMessageId;
762
836
  idx.nextSeq = Math.max(idx.nextSeq, seg.lastSeq + 1);
763
837
  idx.activeSegment = file;
764
838
  }
839
+ idx.foldedMessageIds = Array.from(new Set(idx.foldedMessageIds));
765
840
  return idx;
766
841
  }
767
842
 
@@ -931,6 +1006,51 @@ export class ConversationStore {
931
1006
  return messages.map(m => this.append(m));
932
1007
  }
933
1008
 
1009
+ /**
1010
+ * Replace fields on one persisted message while preserving its id/order.
1011
+ * Rewrites only the owning conversation segment set; used for async tool
1012
+ * results that complete after their initial tool row was appended.
1013
+ *
1014
+ * @param {object} message — persisted row returned by append()
1015
+ * @param {object} patch — fields to merge into the row
1016
+ * @returns {object|null}
1017
+ */
1018
+ update(message, patch) {
1019
+ if (!message?.id || !patch || typeof patch !== 'object') return null;
1020
+ try {
1021
+ const store = this.#segmentStoreFor(message, { create: false });
1022
+ return store.updateById(message.id, current => ({ ...current, ...patch }));
1023
+ } catch (err) {
1024
+ if (isPermissionError(err)) {
1025
+ if (!_permissionWarned) {
1026
+ console.warn(`[Yeaft] Cannot update message ${message.id}: ${err.code}`);
1027
+ _permissionWarned = true;
1028
+ }
1029
+ return null;
1030
+ }
1031
+ throw err;
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Atomically publish a logical range replacement for tool folding.
1037
+ *
1038
+ * The original rows stay append-only on disk. A single reflection row owns
1039
+ * their ids as tombstones, so readers either observe the complete old arc or
1040
+ * the complete reflection — never a half-rewritten tool pair.
1041
+ *
1042
+ * @param {object[]} messages — persisted rows being folded
1043
+ * @param {object} reflection — synthetic `_reflection` user row
1044
+ * @returns {object|null}
1045
+ */
1046
+ foldMessages(messages, reflection) {
1047
+ const foldedMessageIds = Array.from(new Set(
1048
+ (messages || []).map(message => message?.id).filter(id => typeof id === 'string' && id),
1049
+ ));
1050
+ if (foldedMessageIds.length === 0 || !reflection || reflection._reflection !== true) return null;
1051
+ return this.append({ ...reflection, foldedMessageIds });
1052
+ }
1053
+
934
1054
  /**
935
1055
  * Move a message from hot (messages/) to cold (cold/).
936
1056
  *
@@ -1264,11 +1384,12 @@ export class ConversationStore {
1264
1384
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
1265
1385
  * @returns {object[]}
1266
1386
  */
1267
- loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
1387
+ loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false } = {}) {
1268
1388
  if (!sessionId) return [];
1269
1389
  if (turnsLimit === Infinity || turnsLimit < 0) {
1270
1390
  const all = this.#loadSessionMessages(sessionId);
1271
- const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
1391
+ const filtered = all.filter(m => m && m.sessionId === sessionId
1392
+ && (!isHiddenConversationRow(m) || (includeReflections && m._reflection === true)));
1272
1393
  return pairSanitize(filtered);
1273
1394
  }
1274
1395
  if (!(turnsLimit > 0)) return [];
@@ -1276,6 +1397,7 @@ export class ConversationStore {
1276
1397
  const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1277
1398
  roles: null,
1278
1399
  stripAssistantToolCalls: false,
1400
+ includeReflections,
1279
1401
  });
1280
1402
  if (truncated) {
1281
1403
  const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
@@ -1324,9 +1446,14 @@ export class ConversationStore {
1324
1446
  loadSessionHistoryForVp(sessionId, vpId) {
1325
1447
  if (!sessionId || !vpId) return [];
1326
1448
  const all = this.#loadSessionMessages(sessionId);
1449
+ const foldedIds = foldedMessageIdsFrom(all);
1327
1450
  const out = [];
1328
1451
  for (const m of all) {
1329
- if (!m || m.sessionId !== sessionId) continue;
1452
+ if (!m || m.sessionId !== sessionId || foldedIds.has(m.id)) continue;
1453
+ if (m._reflection === true) {
1454
+ out.push(m);
1455
+ continue;
1456
+ }
1330
1457
  if (isHiddenConversationRow(m)) continue;
1331
1458
  if (m.role === 'user') {
1332
1459
  out.push(m);
@@ -2320,7 +2447,12 @@ export class ConversationStore {
2320
2447
  return parseMessage(readFileSync(path, 'utf8'));
2321
2448
  }
2322
2449
 
2323
- #loadRecentSessionWindow(sessionId, turnsLimit, { beforeSeq = Infinity, roles = null, stripAssistantToolCalls = false } = {}) {
2450
+ #loadRecentSessionWindow(sessionId, turnsLimit, {
2451
+ beforeSeq = Infinity,
2452
+ roles = null,
2453
+ stripAssistantToolCalls = false,
2454
+ includeReflections = false,
2455
+ } = {}) {
2324
2456
  const kept = [];
2325
2457
  const pendingBoundaryRows = [];
2326
2458
  let turnsFromEnd = 0;
@@ -2366,7 +2498,7 @@ export class ConversationStore {
2366
2498
  if (!m || m.sessionId !== sessionId) continue;
2367
2499
 
2368
2500
  const boundaryComplete = turnsFromEnd >= turnsLimit;
2369
- if (isHiddenConversationRow(m)) {
2501
+ if (isHiddenConversationRow(m) && !(includeReflections && m._reflection === true)) {
2370
2502
  if (boundaryComplete) {
2371
2503
  truncated = true;
2372
2504
  break;
package/yeaft/engine.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3. Call adapter.stream()
8
8
  * 4. Collect text + tool_calls from stream events
9
9
  * 5. If tool_calls → execute tools → append results → goto 3
10
- * 6. If end_turn persist messages check consolidation done
10
+ * 6. Persist each completed message at its durability boundary; end_turn runs maintenance
11
11
  * 7. If max_tokens → auto-continue (up to maxContinueTurns)
12
12
  * 8. On LLMContextError → force compact → retry
13
13
  * 9. On retryable error with fallbackModel → switch model → retry
@@ -34,7 +34,6 @@ import { readSummary as readScopeSummary } from './memory/store.js';
34
34
  import { runAdjust } from './memory/adjust.js';
35
35
  import { cleanMemoryPromptText, isMemoryPromptRelevant } from './memory/prompt-cleanup.js';
36
36
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
37
- import { runStopHooks } from './stop-hooks.js';
38
37
  import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
39
38
  // Default thread marker for legacy / non-group flows. Group VP runtime may
40
39
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
@@ -512,7 +511,8 @@ export class Engine {
512
511
  * LLMAbortError (or a synthetic abort check) and yields exactly one pair
513
512
  * of events — `{type:'aborted', reason}` followed by
514
513
  * `{type:'turn_end', stopReason:'aborted'}` — then returns without
515
- * persisting partial tool calls, consolidation, or stop-hook side-effects.
514
+ * running consolidation or other terminal maintenance. Any assistant text
515
+ * already streamed is durably recorded as an incomplete response.
516
516
  *
517
517
  * @type {AbortController|null}
518
518
  */
@@ -586,6 +586,9 @@ export class Engine {
586
586
  /** Task results already spliced into conversationMessages for the next request. */
587
587
  #pendingAsyncTaskConfirmIds = new Set();
588
588
 
589
+ /** Persisted tool rows that may receive a same-turn background-task update. */
590
+ #persistedToolMessages = new Map();
591
+
589
592
  /** Reject new same-turn deliveries once the current query starts closing. */
590
593
  #asyncTaskDeliveryClosed = true;
591
594
 
@@ -1377,57 +1380,57 @@ export class Engine {
1377
1380
  return this.#conversationStore.readCompactSummary();
1378
1381
  }
1379
1382
 
1380
- /**
1381
- * Persist user message and assistant response to conversation store.
1382
- * Skipped in read-only mode (config._readOnly).
1383
- *
1384
- * Multi-VP fan-out (Bug 1): when several engines run the same user
1385
- * prompt in parallel, we must NOT each write our own copy of the user
1386
- * message — `coord.ingest`/the orchestrator already wrote it once. Pass
1387
- * `userAlreadyPersisted: true` from the caller to skip the user-row
1388
- * append while still persisting the assistant + tool rows.
1389
- *
1390
- * @param {string} userContent
1391
- * @param {string} assistantContent
1392
- * @param {object[]} [toolCalls]
1393
- * @param {string} [sessionId]
1394
- * @param {boolean} [userAlreadyPersisted]
1395
- */
1396
- #persistMessages(userContent, assistantContent, toolCalls, sessionId, userAlreadyPersisted = false) {
1397
- if (!this.#conversationStore) return;
1398
- if (this.#config._readOnly) return;
1399
-
1400
- // Persist with the active runtime thread. Legacy / non-group flows use
1401
- // MAIN_THREAD_ID; group VP flows pass their classified threadId.
1402
- const threadId = this.#currentThreadId || MAIN_THREAD_ID;
1403
-
1404
- // Persist user message — unless an upstream caller (e.g. the group
1405
- // coordinator) has already done so for this turn.
1406
- if (!userAlreadyPersisted) {
1407
- this.#conversationStore.append({
1408
- role: 'user',
1409
- content: userContent,
1410
- threadId,
1411
- // Bug 6: stamp sessionId/chatId so history replay can route by container.
1412
- ...(sessionId ? { sessionId } : {}),
1413
- ...(this.#chatId ? { chatId: this.#chatId } : {}),
1414
- });
1415
- }
1383
+ #canPersistConversation() {
1384
+ return Boolean(this.#conversationStore) && !this.#config._readOnly;
1385
+ }
1416
1386
 
1417
- // Persist assistant message
1418
- const assistantMsg = {
1419
- role: 'assistant',
1420
- content: assistantContent,
1421
- model: this.#config.model,
1422
- threadId,
1387
+ #conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null } = {}) {
1388
+ const record = {
1389
+ role: message.role,
1390
+ content: typeof message.content === 'string'
1391
+ ? message.content
1392
+ : JSON.stringify(message.content ?? ''),
1393
+ model: model || this.#config.model,
1394
+ threadId: this.#currentThreadId || MAIN_THREAD_ID,
1423
1395
  ...(sessionId ? { sessionId } : {}),
1424
1396
  ...(this.#chatId ? { chatId: this.#chatId } : {}),
1425
- ...(this.#vpId ? { speakerVpId: this.#vpId } : {}),
1426
1397
  };
1427
- if (toolCalls && toolCalls.length > 0) {
1428
- assistantMsg.toolCalls = toolCalls;
1398
+ if (message.toolCallId) record.toolCallId = message.toolCallId;
1399
+ if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) record.toolCalls = message.toolCalls;
1400
+ if (Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0) record.thinkingBlocks = message.thinkingBlocks;
1401
+ if (message.isError) record.isError = true;
1402
+ if (message.imageAssetAnchor) record.imageAssetAnchor = true;
1403
+ if (message._reflection) record._reflection = true;
1404
+ if (Array.isArray(message.foldedMessageIds) && message.foldedMessageIds.length > 0) {
1405
+ record.foldedMessageIds = [...message.foldedMessageIds];
1429
1406
  }
1430
- this.#conversationStore.append(assistantMsg);
1407
+ if (turnId && (message.role === 'assistant' || message.role === 'tool')) record.turnId = turnId;
1408
+ if (this.#vpId && (message.role === 'assistant' || message.role === 'tool')) record.speakerVpId = this.#vpId;
1409
+ if (incomplete) record.incomplete = true;
1410
+ if (stopReason) record.stopReason = stopReason;
1411
+ return record;
1412
+ }
1413
+
1414
+ #persistConversationMessage(message, context = {}) {
1415
+ if (!this.#canPersistConversation() || !message?.role) return null;
1416
+ const hasContent = typeof message.content === 'string'
1417
+ ? message.content.length > 0
1418
+ : message.content != null;
1419
+ const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0;
1420
+ const hasThinking = Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0;
1421
+ if (!hasContent && !hasToolCalls && !hasThinking && message.role !== 'tool') return null;
1422
+ return this.#conversationStore.append(this.#conversationRecord(message, context));
1423
+ }
1424
+
1425
+ #persistFoldedRange(messages, startIdx, endIdx, reflection, context = {}) {
1426
+ if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
1427
+ const persistedRows = (messages || []).slice(startIdx, endIdx + 1)
1428
+ .map(message => message?._persistedMessageId || message?.id)
1429
+ .filter(id => typeof id === 'string' && id)
1430
+ .map(id => ({ id }));
1431
+ if (persistedRows.length === 0) return null;
1432
+ const record = this.#conversationRecord(reflection, context);
1433
+ return this.#conversationStore.foldMessages(persistedRows, record);
1431
1434
  }
1432
1435
 
1433
1436
  /**
@@ -1652,6 +1655,15 @@ export class Engine {
1652
1655
  ? toolMsg.content
1653
1656
  : this.#formatTaskResultUpdateContent(toolMsg.content);
1654
1657
  toolMsg.content = `${prior}\n\n${appendText}`;
1658
+ const persistedTool = this.#persistedToolMessages.get(update.toolCallId);
1659
+ if (persistedTool && typeof this.#conversationStore?.update === 'function') {
1660
+ const durablePrior = typeof persistedTool.content === 'string'
1661
+ ? persistedTool.content
1662
+ : this.#formatTaskResultUpdateContent(persistedTool.content);
1663
+ const durableContent = `${durablePrior}\n\n${appendText}`;
1664
+ const updated = this.#conversationStore.update(persistedTool, { content: durableContent });
1665
+ if (updated) this.#persistedToolMessages.set(update.toolCallId, updated);
1666
+ }
1655
1667
  applied.push(update);
1656
1668
  if (this.#acceptedAsyncTaskResults.has(update.taskId)) {
1657
1669
  this.#pendingAsyncTaskConfirmIds.add(update.taskId);
@@ -1688,6 +1700,12 @@ export class Engine {
1688
1700
  return deliveries.length;
1689
1701
  }
1690
1702
 
1703
+ #persistAppendedUserMessage(item, sessionId) {
1704
+ if (!item || item.persisted || item.internal) return;
1705
+ this.#persistConversationMessage({ role: 'user', content: item.content }, { sessionId });
1706
+ item.persisted = true;
1707
+ }
1708
+
1691
1709
  #drainPendingUserMessages(drainPendingUserMessages) {
1692
1710
  const pending = [];
1693
1711
  this.#externalUserWakePending = false;
@@ -1726,6 +1744,7 @@ export class Engine {
1726
1744
  content,
1727
1745
  preview,
1728
1746
  internal: Boolean(item.internal),
1747
+ persisted: Boolean(item.persisted),
1729
1748
  taskId,
1730
1749
  };
1731
1750
  })
@@ -1850,6 +1869,7 @@ export class Engine {
1850
1869
  this.#asyncTaskToolMeta.clear();
1851
1870
  this.#pendingTaskResultMessages.length = 0;
1852
1871
  this.#pendingTaskResultUpdates.length = 0;
1872
+ this.#persistedToolMessages.clear();
1853
1873
  // Release any parked waiters so they don't pin a microtask after
1854
1874
  // query() returns. The loop has already exited so they're harmless,
1855
1875
  // but cleanup keeps the promise graph tight.
@@ -1879,6 +1899,24 @@ export class Engine {
1879
1899
  const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
1880
1900
  ? threadId.trim()
1881
1901
  : MAIN_THREAD_ID;
1902
+ const queryTurnId = randomUUID();
1903
+ const queryStartedAt = Date.now();
1904
+ const userQuestionPreview = String(prompt || '').slice(0, 200);
1905
+ const queryVpId = vpPersona && typeof vpPersona === 'object'
1906
+ && typeof vpPersona.vpId === 'string'
1907
+ ? vpPersona.vpId
1908
+ : (typeof senderVpId === 'string' ? senderVpId : null);
1909
+
1910
+ // Durability boundary: a valid user turn must exist on disk before any
1911
+ // memory pre-flow or provider request can fail. The Web Session bridge
1912
+ // already writes one shared user row before multi-VP fan-out, so those
1913
+ // callers set userAlreadyPersisted and every VP skips this append.
1914
+ if (!userAlreadyPersisted) {
1915
+ this.#persistConversationMessage({ role: 'user', content: prompt }, {
1916
+ sessionId: runtimeSessionId,
1917
+ });
1918
+ }
1919
+
1882
1920
  const perfTraceId = typeof inboundEnvelope?._perfTraceId === 'string' && inboundEnvelope._perfTraceId.trim()
1883
1921
  ? inboundEnvelope._perfTraceId.trim()
1884
1922
  : (typeof inboundEnvelope?.perfTraceId === 'string' && inboundEnvelope.perfTraceId.trim() ? inboundEnvelope.perfTraceId.trim() : null);
@@ -2159,7 +2197,10 @@ export class Engine {
2159
2197
  // reflection; only high context pressure (>=80% of model window)
2160
2198
  // enables the carry-forward rewrite.
2161
2199
  if (groupReflectionAllowed) {
2162
- yield* this.#applyPendingT2Reflections(conversationMessages, prompt);
2200
+ yield* this.#applyPendingT2Reflections(conversationMessages, prompt, {
2201
+ sessionId: runtimeSessionId,
2202
+ model: this.#config.model,
2203
+ });
2163
2204
  }
2164
2205
 
2165
2206
  // PR-L: track this query()'s tool-arc for reflection.
@@ -2194,13 +2235,6 @@ export class Engine {
2194
2235
  // `queryTurnId` is the wire-level turn identifier; every event emitted
2195
2236
  // during this query() carries it as `turnId`. Each LLM call inside
2196
2237
  // the loop is a `loopNumber` (was wire field `turnNumber`).
2197
- const queryTurnId = randomUUID();
2198
- const queryStartedAt = Date.now();
2199
- const userQuestionPreview = String(prompt || '').slice(0, 200);
2200
- const queryVpId = vpPersona && typeof vpPersona === 'object'
2201
- && typeof vpPersona.vpId === 'string'
2202
- ? vpPersona.vpId
2203
- : (typeof senderVpId === 'string' ? senderVpId : null);
2204
2238
 
2205
2239
  yield {
2206
2240
  type: 'turn_open',
@@ -2250,7 +2284,8 @@ export class Engine {
2250
2284
  let continueTurns = 0; // auto-continue counter
2251
2285
  let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
2252
2286
  let fullResponseText = '';
2253
- let hasDisplayImageAnchor = false;
2287
+ let displayImageAnchorMessage = null;
2288
+ let lastPersistedAssistantMessage = null;
2254
2289
  let currentModel = this.#config.model;
2255
2290
  let cumulativeInputTokens = 0;
2256
2291
  let cumulativeOutputTokens = 0;
@@ -2323,6 +2358,18 @@ export class Engine {
2323
2358
  });
2324
2359
  let ttfbMs = null; // Time to first token
2325
2360
  let responseText = '';
2361
+ let incompleteAssistantPersisted = false;
2362
+ const persistIncompleteAssistantOnce = (reason) => {
2363
+ if (incompleteAssistantPersisted || !responseText) return null;
2364
+ incompleteAssistantPersisted = true;
2365
+ return this.#persistConversationMessage({ role: 'assistant', content: responseText }, {
2366
+ sessionId: runtimeSessionId,
2367
+ turnId: vpTurnId || queryTurnId,
2368
+ model: currentModel,
2369
+ incomplete: true,
2370
+ stopReason: reason,
2371
+ });
2372
+ };
2326
2373
  const toolCalls = [];
2327
2374
  const thinkingBlocks = []; // task-327d: collected from adapter for round-trip
2328
2375
  let stopReason = 'end_turn';
@@ -2351,6 +2398,7 @@ export class Engine {
2351
2398
  const appendedBeforeStream = this.#drainPendingUserMessages(drainPendingUserMessages);
2352
2399
  if (appendedBeforeStream.length > 0) {
2353
2400
  for (const item of appendedBeforeStream) {
2401
+ this.#persistAppendedUserMessage(item, runtimeSessionId);
2354
2402
  conversationMessages.push({ role: 'user', content: item.content });
2355
2403
  yield {
2356
2404
  type: 'user_append',
@@ -2671,6 +2719,7 @@ export class Engine {
2671
2719
  || err?.name === 'LLMAbortError'
2672
2720
  || (signal?.aborted && /abort/i.test(err?.message || ''));
2673
2721
  if (earlyIsAbort || signal?.aborted) {
2722
+ persistIncompleteAssistantOnce('aborted');
2674
2723
  traceRequest('llm.request_abort', {
2675
2724
  durationMs: perfNowMs() - requestPerfStart,
2676
2725
  ok: false,
@@ -2741,6 +2790,7 @@ export class Engine {
2741
2790
  };
2742
2791
  const slept = await sleepWithAbort(delayMs, signal);
2743
2792
  if (!slept || signal?.aborted) {
2793
+ persistIncompleteAssistantOnce('aborted');
2744
2794
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2745
2795
  yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2746
2796
  break;
@@ -2761,6 +2811,8 @@ export class Engine {
2761
2811
  continue;
2762
2812
  }
2763
2813
 
2814
+ persistIncompleteAssistantOnce('error');
2815
+
2764
2816
  this.#trace.endTurn(turnId, {
2765
2817
  model: currentModel,
2766
2818
  inputTokens: totalUsage.inputTokens,
@@ -2873,6 +2925,67 @@ export class Engine {
2873
2925
  rawResponse,
2874
2926
  });
2875
2927
 
2928
+ // Build and durably append this completed provider response before
2929
+ // yielding any post-stream diagnostics. A consumer may stop iterating at
2930
+ // any yield; persistence therefore cannot wait for turn_end or even the
2931
+ // debug `loop` event below.
2932
+ const assistantMsg = { role: 'assistant', content: responseText };
2933
+ if (toolCalls.length > 0) {
2934
+ assistantMsg.toolCalls = toolCalls.map(tc => ({
2935
+ id: tc.id,
2936
+ name: tc.name,
2937
+ input: tc.input,
2938
+ }));
2939
+ }
2940
+ if (thinkingBlocks.length > 0) {
2941
+ assistantMsg.thinkingBlocks = thinkingBlocks.map(tb => (
2942
+ tb.redacted
2943
+ ? { redacted: true, data: tb.data, signature: tb.signature }
2944
+ : { thinking: tb.thinking, signature: tb.signature }
2945
+ ));
2946
+ }
2947
+ if (vpPersona && vpPersona.vpId) {
2948
+ const planForThisVp = (vpPlan && typeof vpPlan === 'object'
2949
+ && typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId)
2950
+ ? vpPlan
2951
+ : null;
2952
+ attachRouterPlan(assistantMsg, {
2953
+ vpId: vpPersona.vpId,
2954
+ forwardQuery: planForThisVp && planForThisVp.forwardQuery
2955
+ ? planForThisVp.forwardQuery
2956
+ : { userOriginal: prompt || '', intent: '' },
2957
+ preselect: planForThisVp && planForThisVp.preselect
2958
+ ? planForThisVp.preselect
2959
+ : undefined,
2960
+ thinking: planForThisVp && (planForThisVp.thinking === 'high' || planForThisVp.thinking === 'max')
2961
+ ? planForThisVp.thinking
2962
+ : null,
2963
+ thinkingReason: planForThisVp && typeof planForThisVp.thinkingReason === 'string'
2964
+ ? planForThisVp.thinkingReason
2965
+ : '',
2966
+ });
2967
+ }
2968
+ const previousImageAnchorMessage = displayImageAnchorMessage;
2969
+ if (previousImageAnchorMessage && typeof this.#conversationStore?.update === 'function') {
2970
+ const cleared = this.#conversationStore.update(previousImageAnchorMessage, { imageAssetAnchor: false });
2971
+ if (cleared) displayImageAnchorMessage = null;
2972
+ }
2973
+ if (previousImageAnchorMessage && displayImageAnchorMessage === null) assistantMsg.imageAssetAnchor = true;
2974
+ const persistedAssistantMessage = this.#persistConversationMessage(assistantMsg, {
2975
+ sessionId: runtimeSessionId,
2976
+ turnId: vpTurnId || queryTurnId,
2977
+ model: currentModel,
2978
+ });
2979
+ if (persistedAssistantMessage) {
2980
+ assistantMsg._persistedMessageId = persistedAssistantMessage.id;
2981
+ if (assistantMsg.imageAssetAnchor) displayImageAnchorMessage = persistedAssistantMessage;
2982
+ lastPersistedAssistantMessage = persistedAssistantMessage;
2983
+ }
2984
+ if (previousImageAnchorMessage && displayImageAnchorMessage === null && !persistedAssistantMessage) {
2985
+ const restored = this.#conversationStore.update(previousImageAnchorMessage, { imageAssetAnchor: true });
2986
+ displayImageAnchorMessage = restored || null;
2987
+ }
2988
+
2876
2989
  // Emit `loop` event for the debug panel.
2877
2990
  // feat-6af5f9f1 PR B: a Loop is one LLM call inside a Turn. The wire
2878
2991
  // event was historically named `debug_turn` and carried `turnNumber`,
@@ -2913,63 +3026,20 @@ export class Engine {
2913
3026
  rawResponse,
2914
3027
  };
2915
3028
 
2916
- // Append assistant message to conversation
2917
- const assistantMsg = { role: 'assistant', content: responseText };
2918
- if (toolCalls.length > 0) {
2919
- assistantMsg.toolCalls = toolCalls.map(tc => ({
2920
- id: tc.id,
2921
- name: tc.name,
2922
- input: tc.input,
2923
- }));
2924
- }
2925
- // task-327d: persist thinking blocks for the next turn's replay.
2926
- // Anthropic requires assistant.thinking blocks to be echoed back
2927
- // verbatim (text + signature) when the previous turn used extended
2928
- // thinking — see translateMessages in anthropic.js.
2929
- if (thinkingBlocks.length > 0) {
2930
- assistantMsg.thinkingBlocks = thinkingBlocks.map(tb => (
2931
- tb.redacted
2932
- ? { redacted: true, data: tb.data, signature: tb.signature }
2933
- : { thinking: tb.thinking, signature: tb.signature }
2934
- ));
2935
- }
2936
- // Phase 8 (DESIGN.md §9.15): carry the router plan back on the
2937
- // assistant message that produced it. Stripped at the wire by
2938
- // stripMetaForWire — pure bookkeeping for priorPlan continuity.
2939
- if (vpPersona && vpPersona.vpId) {
2940
- // PR-I: when the dispatcher hands us a per-VP plan whose vpId matches
2941
- // the active persona, persist its `forwardQuery`, `preselect`, and
2942
- // `thinking` on the assistant message so the next turn's
2943
- // priorPlan continuity (DESIGN.md §9.15) sees the live router's
2944
- // decision — not a synthetic stub.
2945
- const planForThisVp = (vpPlan && typeof vpPlan === 'object'
2946
- && typeof vpPlan.vpId === 'string' && vpPlan.vpId === vpPersona.vpId)
2947
- ? vpPlan
2948
- : null;
2949
- attachRouterPlan(assistantMsg, {
2950
- vpId: vpPersona.vpId,
2951
- forwardQuery: planForThisVp && planForThisVp.forwardQuery
2952
- ? planForThisVp.forwardQuery
2953
- : { userOriginal: prompt || '', intent: '' },
2954
- preselect: planForThisVp && planForThisVp.preselect
2955
- ? planForThisVp.preselect
2956
- : undefined,
2957
- thinking: planForThisVp && (planForThisVp.thinking === 'high' || planForThisVp.thinking === 'max')
2958
- ? planForThisVp.thinking
2959
- : null,
2960
- thinkingReason: planForThisVp && typeof planForThisVp.thinkingReason === 'string'
2961
- ? planForThisVp.thinkingReason
2962
- : '',
2963
- });
2964
- }
3029
+ // Keep the same durable assistant object in the live model history.
3030
+ // Private router metadata is stripped only at the next wire boundary.
2965
3031
  conversationMessages.push(assistantMsg);
2966
3032
  fullResponseText += responseText;
2967
3033
 
2968
3034
  // ─── Handle max_tokens → auto-continue ────────────
2969
3035
  if (stopReason === 'max_tokens' && continueTurns < MAX_CONTINUE_TURNS) {
2970
3036
  continueTurns++;
2971
- // Append a "Continue" user message
2972
- conversationMessages.push({ role: 'user', content: 'Continue' });
3037
+ // This synthetic continuation is part of the model-visible protocol.
3038
+ // Persist it before the next provider request so a crash does not leave
3039
+ // the completed assistant row without its following user boundary.
3040
+ const continueMessage = { role: 'user', content: 'Continue' };
3041
+ this.#persistConversationMessage(continueMessage, { sessionId: runtimeSessionId });
3042
+ conversationMessages.push(continueMessage);
2973
3043
  yield { type: 'turn_end', turnNumber, stopReason: 'max_tokens_continue', threadId };
2974
3044
  continue; // loop back to call adapter again
2975
3045
  }
@@ -2981,6 +3051,7 @@ export class Engine {
2981
3051
  const appendedAfterAssistant = this.#drainPendingUserMessages(drainPendingUserMessages);
2982
3052
  if (appendedAfterAssistant.length > 0) {
2983
3053
  for (const item of appendedAfterAssistant) {
3054
+ this.#persistAppendedUserMessage(item, runtimeSessionId);
2984
3055
  conversationMessages.push({ role: 'user', content: item.content });
2985
3056
  yield {
2986
3057
  type: 'user_append',
@@ -3072,6 +3143,7 @@ export class Engine {
3072
3143
  const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
3073
3144
  if (appendedAfterAsyncWait.length > 0) {
3074
3145
  for (const item of appendedAfterAsyncWait) {
3146
+ this.#persistAppendedUserMessage(item, runtimeSessionId);
3075
3147
  conversationMessages.push({ role: 'user', content: item.content });
3076
3148
  yield {
3077
3149
  type: 'user_append',
@@ -3102,6 +3174,7 @@ export class Engine {
3102
3174
  throw new Error('Could not close pending user input for terminal completion');
3103
3175
  }
3104
3176
  for (const item of appendedBeforeClose) {
3177
+ this.#persistAppendedUserMessage(item, runtimeSessionId);
3105
3178
  conversationMessages.push({ role: 'user', content: item.content });
3106
3179
  yield {
3107
3180
  type: 'user_append',
@@ -3120,57 +3193,12 @@ export class Engine {
3120
3193
  }
3121
3194
  yield { type: 'turn_end', turnNumber, stopReason, threadId, terminal: true };
3122
3195
 
3123
- // ─── Post-query: StopHooks or Legacy ─────────────
3124
- if (this.#config._readOnly) {
3125
- // Read-only mode: skip all persistence operations
3126
- } else if (this.#yeaftDir && this.#conversationStore) {
3127
- // Full pipeline: persist + consolidate + dream gate
3128
- // Note: stopHooks uses fastConfig for consolidation/dream (cheaper internal tasks)
3129
- // but receives both configs — messages are persisted with primary model name
3130
- const hookResult = await runStopHooks({
3131
- yeaftDir: this.#yeaftDir,
3132
- conversationStore: this.#conversationStore,
3133
- adapter: this.#adapter,
3134
- config: this.#fastConfig,
3135
- primaryModel: this.#config.model,
3136
- messages: conversationMessages,
3137
- // Reflect-persist fix: tell stop-hooks the EXACT turn boundary
3138
- // instead of letting it heuristically scan back to the last
3139
- // role:'user'. With T1/T2 reflection collapse, the last
3140
- // role:'user' is the synthetic reflection message — not the
3141
- // original user prompt — so the heuristic was dropping
3142
- // earlier reflection messages and the original prompt off
3143
- // the persistence window. `turnStartIdx` is the index of
3144
- // the original user prompt (set at query() entry); slicing
3145
- // from there persists the full collapsed turn including all
3146
- // reflection messages and the trailing assistant response.
3147
- turnStartIdx,
3148
- trace: this.#trace,
3149
- // Bug 6: tag persisted messages with the originating group so
3150
- // history replay can re-stamp them on reload.
3151
- sessionId,
3152
- threadId,
3153
- turnId: vpTurnId || queryTurnId,
3154
- vpId: this.#vpId,
3155
- // Multi-VP fan-out (history-dedup): skip the user-row append
3156
- // in stop-hooks when the orchestrator already wrote it once
3157
- // for this turn. The hook still persists assistant + tool
3158
- // rows for THIS VP's contribution.
3159
- userAlreadyPersisted,
3160
- hasDisplayImageAnchor,
3161
- });
3162
-
3163
- if (hookResult.consolidated) {
3164
- yield { type: 'consolidate', archivedCount: 0, extractedCount: 0 };
3165
- }
3166
- } else {
3167
- // Legacy path (no yeaftDir → use old behavior)
3168
- this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, sessionId, userAlreadyPersisted);
3169
-
3170
- const consolidated = await this.#maybeConsolidate();
3171
- if (consolidated && consolidated.archivedCount > 0) {
3172
- yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
3173
- }
3196
+ // Message durability is handled incrementally before this terminal
3197
+ // branch. End-of-turn owns maintenance only; re-appending the whole
3198
+ // turn here would duplicate rows and reintroduce the crash window.
3199
+ const consolidated = await this.#maybeConsolidate();
3200
+ if (consolidated && consolidated.archivedCount > 0) {
3201
+ yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
3174
3202
  }
3175
3203
 
3176
3204
  // ─── Post-turn AMS adjust ────────────────────────────────
@@ -3385,7 +3413,35 @@ export class Engine {
3385
3413
  }
3386
3414
  isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
3387
3415
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
3388
- if (displayImages.some(image => image.deliveryQueued === true)) hasDisplayImageAnchor = true;
3416
+ if (displayImages.some(image => image.deliveryQueued === true)
3417
+ && lastPersistedAssistantMessage
3418
+ && typeof this.#conversationStore?.update === 'function') {
3419
+ const priorAnchor = displayImageAnchorMessage;
3420
+ if (priorAnchor && priorAnchor.id !== lastPersistedAssistantMessage.id) {
3421
+ const cleared = this.#conversationStore.update(priorAnchor, { imageAssetAnchor: false });
3422
+ if (cleared) {
3423
+ const anchored = this.#conversationStore.update(lastPersistedAssistantMessage, {
3424
+ imageAssetAnchor: true,
3425
+ });
3426
+ if (anchored) {
3427
+ displayImageAnchorMessage = anchored;
3428
+ lastPersistedAssistantMessage = anchored;
3429
+ } else {
3430
+ displayImageAnchorMessage = this.#conversationStore.update(priorAnchor, {
3431
+ imageAssetAnchor: true,
3432
+ }) || null;
3433
+ }
3434
+ }
3435
+ } else if (!priorAnchor) {
3436
+ const anchored = this.#conversationStore.update(lastPersistedAssistantMessage, {
3437
+ imageAssetAnchor: true,
3438
+ });
3439
+ if (anchored) {
3440
+ displayImageAnchorMessage = anchored;
3441
+ lastPersistedAssistantMessage = anchored;
3442
+ }
3443
+ }
3444
+ }
3389
3445
  } catch (err) {
3390
3446
  output = `Error: ${err.message}`;
3391
3447
  isError = true;
@@ -3444,12 +3500,24 @@ export class Engine {
3444
3500
  toolName: tc.name,
3445
3501
  language: this.#config?.language,
3446
3502
  });
3447
- conversationMessages.push({
3503
+ const toolMessage = {
3448
3504
  role: 'tool',
3449
3505
  toolCallId: tc.id,
3450
3506
  content: contextOutput,
3451
3507
  isError,
3508
+ };
3509
+ conversationMessages.push(toolMessage);
3510
+ // Model context may use a bounded copy, but durable conversation
3511
+ // history keeps the raw normalized tool output for recovery/debug.
3512
+ const persistedToolMessage = this.#persistConversationMessage({ ...toolMessage, content: output }, {
3513
+ sessionId: runtimeSessionId,
3514
+ turnId: vpTurnId || queryTurnId,
3515
+ model: currentModel,
3452
3516
  });
3517
+ if (persistedToolMessage) {
3518
+ toolMessage._persistedMessageId = persistedToolMessage.id;
3519
+ this.#persistedToolMessages.set(tc.id, persistedToolMessage);
3520
+ }
3453
3521
 
3454
3522
  // PR-L: persist this execution to the exec-log for fallback-stub
3455
3523
  // and duplicate-call detection. Best-effort — disk failures are
@@ -3563,6 +3631,20 @@ export class Engine {
3563
3631
  const next = collapseRangeToReflection(
3564
3632
  conversationMessages, batchStart, batchEnd, content,
3565
3633
  );
3634
+ const reflectionMessage = next[batchStart];
3635
+ const durableRowsInRange = conversationMessages
3636
+ .slice(batchStart, batchEnd + 1)
3637
+ .some(message => message?._persistedMessageId || message?.id);
3638
+ const persistedReflection = this.#persistFoldedRange(
3639
+ conversationMessages,
3640
+ batchStart,
3641
+ batchEnd,
3642
+ reflectionMessage,
3643
+ { sessionId: runtimeSessionId, model: currentModel },
3644
+ );
3645
+ if (durableRowsInRange && !persistedReflection) {
3646
+ throw new Error('T1 reflection could not publish its durable range replacement');
3647
+ }
3566
3648
  conversationMessages.length = 0;
3567
3649
  for (const m of next) conversationMessages.push(m);
3568
3650
  // After collapse: the just-inserted reflection lives at
@@ -3696,8 +3778,9 @@ export class Engine {
3696
3778
  *
3697
3779
  * @param {Array} conversationMessages
3698
3780
  * @param {string} originalUserMsg
3781
+ * @param {{sessionId?: string, model?: string}} context
3699
3782
  */
3700
- async *#applyPendingT2Reflections(conversationMessages, originalUserMsg) {
3783
+ async *#applyPendingT2Reflections(conversationMessages, originalUserMsg, context = {}) {
3701
3784
  if (this.#pendingT2.size === 0) return;
3702
3785
  // Drain in insertion order (Map preserves it). We process all entries
3703
3786
  // because the user could send multiple prompts back-to-back before
@@ -3739,8 +3822,20 @@ export class Engine {
3739
3822
  continue;
3740
3823
  }
3741
3824
 
3742
- // Rewrite history.
3825
+ // Rewrite history and publish the same logical replacement to disk.
3743
3826
  const next = collapseRangeToReflection(conversationMessages, startIdx, endIdx, content);
3827
+ const reflectionMessage = next[startIdx];
3828
+ const durableRowsInRange = conversationMessages
3829
+ .slice(startIdx, endIdx + 1)
3830
+ .some(message => message?._persistedMessageId || message?.id);
3831
+ const persistedReflection = this.#persistFoldedRange(
3832
+ conversationMessages,
3833
+ startIdx,
3834
+ endIdx,
3835
+ reflectionMessage,
3836
+ context,
3837
+ );
3838
+ if (durableRowsInRange && !persistedReflection) continue;
3744
3839
  // Mutate in place so caller's reference stays valid.
3745
3840
  conversationMessages.length = 0;
3746
3841
  for (const m of next) conversationMessages.push(m);
@@ -98,9 +98,9 @@ export function stripMetaForWire(messages) {
98
98
  let mutated = false;
99
99
  const out = messages.map(m => {
100
100
  if (m && typeof m === 'object'
101
- && ('_meta' in m || '_runtimeTurnId' in m || '_partialTurn' in m)) {
101
+ && ('_meta' in m || '_runtimeTurnId' in m || '_partialTurn' in m || '_persistedMessageId' in m)) {
102
102
  mutated = true;
103
- const { _meta, _runtimeTurnId, _partialTurn, ...rest } = m;
103
+ const { _meta, _runtimeTurnId, _partialTurn, _persistedMessageId, ...rest } = m;
104
104
  return rest;
105
105
  }
106
106
  return m;
@@ -1176,12 +1176,15 @@ function isPersistedInternalMessage(m) {
1176
1176
  * @param {object} m — record from conversationStore.loadRecent*()
1177
1177
  * @returns {object|null} history-shape entry, or null to skip
1178
1178
  */
1179
- function projectPersistedToHistoryEntry(m) {
1179
+ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {}) {
1180
1180
  if (!m) return null;
1181
1181
  if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
1182
- if (isPersistedInternalMessage(m)) return null;
1182
+ if (isPersistedInternalMessage(m) && !(includeReflections && m._reflection === true)) return null;
1183
1183
  const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
1184
- if (m.id) entry.id = m.id;
1184
+ if (m.id) {
1185
+ entry.id = m.id;
1186
+ entry._persistedMessageId = m.id;
1187
+ }
1185
1188
  entry.threadId = m.threadId || m.turnId || 'main';
1186
1189
  if (m.turnId) entry.turnId = m.turnId;
1187
1190
  if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
@@ -1441,14 +1444,18 @@ function hydrateGroupHistory(sessionId) {
1441
1444
  if (!session?.conversationStore || !sessionId) return [];
1442
1445
  let recent;
1443
1446
  try {
1444
- recent = session.conversationStore.loadRecentBySession(sessionId);
1447
+ recent = session.conversationStore.loadRecentBySession(
1448
+ sessionId,
1449
+ undefined,
1450
+ { includeReflections: true },
1451
+ );
1445
1452
  } catch (err) {
1446
1453
  console.warn('[Yeaft] hydrateGroupHistory failed (sessionId=%s):', sessionId, err?.message || err);
1447
1454
  return [];
1448
1455
  }
1449
1456
  const out = [];
1450
1457
  for (const m of recent || []) {
1451
- const entry = projectPersistedToHistoryEntry(m);
1458
+ const entry = projectPersistedToHistoryEntry(m, { includeReflections: true });
1452
1459
  if (entry) out.push(entry);
1453
1460
  }
1454
1461
  return out;
@@ -5079,7 +5086,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5079
5086
  vpTurnId: turnId,
5080
5087
  drainPendingUserMessages: () => {
5081
5088
  if (!thread || !Array.isArray(thread.pendingQueries) || thread.pendingQueries.length === 0) return [];
5082
- return thread.pendingQueries.splice(0);
5089
+ return thread.pendingQueries.splice(0).map(item => ({ ...item, persisted: true }));
5083
5090
  },
5084
5091
  ...queryOpts,
5085
5092
  })) {