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.
#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
final event → maybeRefreshHistoryForRun fires void loadHistory() (async fetch starts).
- Synchronously:
finalizeAssistant(finalText) + requestRender(true) → message renders; user sees it.
loadHistory fetch resolves → clearAll() wipes everything, rebuilds from record.messages.
- 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.
- Later
/verbose off → loadHistory() 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).
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.
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 forcingrequestRender(true)), but that fix does not resolve it. The real cause is a read-after-write race: on everyfinalevent for an external/gateway run, the TUI firesloadHistory(), which doeschatLog.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
finalearly-returns inhandleChatEventcallrequestRender(true). But the disappearing message has already been rendered correctly at finalize time. The content is then wiped by an asyncloadHistory()that resolves later. No repaint flag affects this — verified on an install whose bundle already forcesrequestRender(true)across all ofhandleChatEvent; the bug still reproduces.Root cause
On a normal
finalfor a non-local run,handleChatEvent(src/tui/tui-event-handlers.ts, ~line 514) does:loadHistory(src/tui/tui-session-actions.ts, ~line 297):Sequence
finalevent →maybeRefreshHistoryForRunfiresvoid loadHistory()(async fetch starts).finalizeAssistant(finalText)+requestRender(true)→ message renders; user sees it.loadHistoryfetch resolves →clearAll()wipes everything, rebuilds fromrecord.messages.loadHistoryfetched, i.e. thefinalstream event reached the TUI before the message was written to history, sorecord.messagesomitted it./verbose off→loadHistory()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):External/gateway runs have no equivalent guard, so they reload history on every
finaland are always exposed to the race. Reproduces on agent/gateway sessions (e.g.agent:main:...).Evidence
pi-tuiredraw log (~/.pi/agent/pi-debug.log,PI_DEBUG_REDRAW=1) at the moment of a vanish: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 tostreamedText, which is non-empty after a stream, sodropAssistantvia the"(no output)"path is not taken.Suggested fix directions
Not a repaint fix — avoid destroying correct local content:
record.messages, re-add it (detect-missing-and-restore).finalizealready produced displayable text, skip the reload (avoid the destructiveclearAll()).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 theloadHistory()reload racing server persistence, not the repaint flag.Environment
requestRender(true)throughouthandleChatEvent.