@yeaft/webchat-agent 1.0.212 → 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.
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;