openclaw - 💡(How to fix) Fix [TUI] Final assistant message disappears on completion — loadHistory() clearAll() races server persistence (not a repaint bug; #86871 / #87423 does not fix it)

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

The last assistant message intermittently disappears from the TUI right as a run finishes streaming. /verbose off (or any history reload) brings it back. This was previously assumed to be a repaint problem (#86871, fixed by #87423 forcing requestRender(true)), but that fix does not resolve it. The real cause is a read-after-write race: on every final event for an external/gateway run, the TUI fires loadHistory(), which does chatLog.clearAll() and rebuilds the log from the server response. If the gateway hasn't persisted the just-finished message yet, the rebuilt log drops it.

This is a separate bug from #86871. Forcing a repaint cannot fix it because the local render was already correct — the reload replaces correct local content with stale server content.

Root Cause

On a normal final for a non-local run, handleChatEvent (src/tui/tui-event-handlers.ts, ~line 514) does:

maybeRefreshHistoryForRun(evt.runId);          // -> void loadHistory()  (async, not awaited)
const finalText = streamAssembler.finalize(...); // = streamed content (non-empty)
chatLog.finalizeAssistant(finalText, evt.runId); // renders the final message correctly
tui.requestRender(true);                         // user sees the response complete

loadHistory (src/tui/tui-session-actions.ts, ~line 297):

const history = await client.loadHistory({ sessionKey, limit });  // async network fetch
chatLog.clearAll();                                                // wipes ALL messages
chatLog.addSystem(`session ...`);
for (const entry of record.messages ?? []) {
  if (message.role === "assistant") {
    const text = extractTextFromMessage(message, { includeThinking: state.showThinking });
    if (text) chatLog.finalizeAssistant(text);
  }
  // ...
}

Fix Action

Fix / Workaround

The last assistant message intermittently disappears from the TUI right as a run finishes streaming. /verbose off (or any history reload) brings it back. This was previously assumed to be a repaint problem (#86871, fixed by #87423 forcing requestRender(true)), but that fix does not resolve it. The real cause is a read-after-write race: on every final event for an external/gateway run, the TUI fires loadHistory(), which does chatLog.clearAll() and rebuilds the log from the server response. If the gateway hasn't persisted the just-finished message yet, the rebuilt log drops it.

Code Example

maybeRefreshHistoryForRun(evt.runId);          // -> void loadHistory()  (async, not awaited)
const finalText = streamAssembler.finalize(...); // = streamed content (non-empty)
chatLog.finalizeAssistant(finalText, evt.runId); // renders the final message correctly
tui.requestRender(true);                         // user sees the response complete

---

const history = await client.loadHistory({ sessionKey, limit });  // async network fetch
chatLog.clearAll();                                                // wipes ALL messages
chatLog.addSystem(`session ...`);
for (const entry of record.messages ?? []) {
  if (message.role === "assistant") {
    const text = extractTextFromMessage(message, { includeThinking: state.showThinking });
    if (text) chatLog.finalizeAssistant(text);
  }
  // ...
}

---

if (isLocalRun) {
  // Local runs with displayable output do not need a history reload.
  if (!opts?.allowLocalWithoutDisplayableFinal) return;   // no reload
}

---

[..:..:49.474] fullRender: terminal width changed (prev=0, new=190, ...)   <- response present (190 lines)
[..:..:49.755] fullRender: clearOnShrink (maxLinesRendered=190) (prev=190, new=8, ...)  <- collapsed to 8 lines
RAW_BUFFERClick to expand / collapse

Summary

The last assistant message intermittently disappears from the TUI right as a run finishes streaming. /verbose off (or any history reload) brings it back. This was previously assumed to be a repaint problem (#86871, fixed by #87423 forcing requestRender(true)), but that fix does not resolve it. The real cause is a read-after-write race: on every final event for an external/gateway run, the TUI fires loadHistory(), which does chatLog.clearAll() and rebuilds the log from the server response. If the gateway hasn't persisted the just-finished message yet, the rebuilt log drops it.

This is a separate bug from #86871. Forcing a repaint cannot fix it because the local render was already correct — the reload replaces correct local content with stale server content.

Why #86871 / #87423 does not fix it

#87423 made the three final early-returns in handleChatEvent call requestRender(true). But the disappearing message has already been rendered correctly at finalize time. The content is then wiped by an async loadHistory() that resolves later. No repaint flag affects this — verified on an install whose bundle already forces requestRender(true) across all of handleChatEvent; the bug still reproduces.

Root cause

On a normal final for a non-local run, handleChatEvent (src/tui/tui-event-handlers.ts, ~line 514) does:

maybeRefreshHistoryForRun(evt.runId);          // -> void loadHistory()  (async, not awaited)
const finalText = streamAssembler.finalize(...); // = streamed content (non-empty)
chatLog.finalizeAssistant(finalText, evt.runId); // renders the final message correctly
tui.requestRender(true);                         // user sees the response complete

loadHistory (src/tui/tui-session-actions.ts, ~line 297):

const history = await client.loadHistory({ sessionKey, limit });  // async network fetch
chatLog.clearAll();                                                // wipes ALL messages
chatLog.addSystem(`session ...`);
for (const entry of record.messages ?? []) {
  if (message.role === "assistant") {
    const text = extractTextFromMessage(message, { includeThinking: state.showThinking });
    if (text) chatLog.finalizeAssistant(text);
  }
  // ...
}

Sequence

  1. final event → maybeRefreshHistoryForRun fires void loadHistory() (async fetch starts).
  2. Synchronously: finalizeAssistant(finalText) + requestRender(true) → message renders; user sees it.
  3. loadHistory fetch resolves → clearAll() wipes everything, rebuilds from record.messages.
  4. The rebuilt log comes back missing the just-finished assistant message → it vanishes. The likely reason (inferred, not yet captured from the wire): the gateway had not yet persisted the message when loadHistory fetched, i.e. the final stream event reached the TUI before the message was written to history, so record.messages omitted it.
  5. Later /verbose offloadHistory() again → server now has it → message reappears.

Intermittent because it depends on persistence-vs-fetch timing.

Why external/gateway runs only

Local runs are guarded (src/tui/tui-event-handlers.ts, ~lines 351-356):

if (isLocalRun) {
  // Local runs with displayable output do not need a history reload.
  if (!opts?.allowLocalWithoutDisplayableFinal) return;   // no reload
}

External/gateway runs have no equivalent guard, so they reload history on every final and are always exposed to the race. Reproduces on agent/gateway sessions (e.g. agent:main:...).

Evidence

pi-tui redraw log (~/.pi/agent/pi-debug.log, PI_DEBUG_REDRAW=1) at the moment of a vanish:

[..:..:49.474] fullRender: terminal width changed (prev=0, new=190, ...)   <- response present (190 lines)
[..:..:49.755] fullRender: clearOnShrink (maxLinesRendered=190) (prev=190, new=8, ...)  <- collapsed to 8 lines

The render input collapsed from 190 to 8 lines — i.e. the content was removed from the chat log before rendering, not mis-painted.

Ruled out

  • streamAssembler.finalize() returning "(no output)" → no: resolveFinalAssistantText (src/tui/tui-formatters.ts:213) falls back to streamedText, which is non-empty after a stream, so dropAssistant via the "(no output)" path is not taken.

Suggested fix directions

Not a repaint fix — avoid destroying correct local content:

  • A: After the rebuild, if the just-finalized run had displayable local text but it's absent from record.messages, re-add it (detect-missing-and-restore).
  • B (cleanest): Extend the local-run guard to external runs — if finalize already produced displayable text, skip the reload (avoid the destructive clearAll()).
  • C: Reconcile instead of clearAll() (merge rather than wipe).

Relation to #86871 / #87423

This supersedes the assumption in #86871. #87423 (requestRender(true)) is a valid repaint hardening but does not fix the disappearing-message symptom; the cause is the loadHistory() reload racing server persistence, not the repaint flag.

Environment

  • OpenClaw TUI, agent/gateway session (non-local run).
  • Reproduced on a bundle that already forces requestRender(true) throughout handleChatEvent.
  • Intermittent; correlates with run completion.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix [TUI] Final assistant message disappears on completion — loadHistory() clearAll() races server persistence (not a repaint bug; #86871 / #87423 does not fix it)