Summary
The webchat UI sometimes fails to display the assistant's final response correctly. The response streams correctly during generation, but disappears, collapses, or renders with broken formatting in the final view. This is a compound issue with multiple contributing root causes in the client-side event handling and server-client coordination.
Reproduction
- Open webchat, send a message that triggers tool use
- Observe the streamed response appearing correctly
- After the run completes, the final message may:
- Not appear at all
- Appear briefly then vanish when history reloads
- Show only the content after the last tool call (losing earlier text)
- Be silently truncated at ~4000 chars
- Render with broken markdown — if the model outputs a code fence before a tool call, the unclosed fence makes everything after it render as a code block
Root Causes
1. History reload race condition (critical)
When a run completes with tool events, handleTerminalChatEvent triggers loadChatHistory() which does a full replace of chatMessages:
// app-gateway.ts:343
if (hadToolEvents && state === "final") {
void loadChatHistory(host);
return true;
}
// controllers/chat.ts
state.chatMessages = messages.filter((m) => !isAssistantSilentReply(m));
The problem: loadChatHistory is async and fire-and-forget (void). If the gateway hasn't finished persisting the transcript to disk before the chat.history RPC response is assembled, the history comes back missing the final message. The streaming text was visible, then gets replaced by incomplete history.
This is a known race — see #11139, #37083.
2. Stream segments break markdown across boundaries (rendering bug)
When the assistant emits text that includes a code fence, then triggers a tool call, the streaming text gets committed as a "segment" via chatStreamSegments. Each segment is rendered as an independent markdown block through toSanitizedMarkdownHtml:
// app-tool-stream.ts:505
if (host.chatStream && host.chatStream.trim().length > 0) {
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
host.chatStream = null;
}
// chat.ts — each segment rendered separately
if (item.kind === "stream") {
return renderStreamingGroup(item.text, ...); // independent markdown parse
}
If segment 1 ends with an unclosed code fence (e.g., the model wrote ```ts\nconst foo = bar; then triggered a tool call), the markdown renderer treats everything after the fence as code. When the next segment or final text renders below it, the entire response appears wrapped in a broken code block.
This is also related to #29472 — the visual collapse where only content after the last tool call survives.
3. Silent truncation at ~4000 chars
The webchat channel bypasses textChunkLimit config and hardcodes a ~4000 char chunk limit. Long responses stream fully but get silently cut in the final rendered message.
See #47123 — still open, with detailed root cause analysis.
4. runId mismatch drops events silently
In handleChatEvent:
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
// Only handles "final" state — everything else returns null (dropped)
if (payload.state === "final") { ... }
return null; // deltas from other runs silently dropped
}
When the tab didn't initiate the run (e.g., multi-tab use, or a session that was reset mid-stream), non-final events are silently discarded. See #37083.
5. message undefined in btw reply path
The btw (by-the-way) reply path calls broadcastChatFinal without a message:
// chat.ts:1828
broadcastChatFinal({ context, runId: clientRunId, sessionKey });
// no message parameter
When message is undefined, normalizeFinalAssistantMessage(undefined) returns null, so the final handler tries the chatStream fallback. If the stream was thinking-only content that got stripped, the message vanishes entirely.
Proposed Fix
A. Incremental history merge instead of full replace
Replace the full-swap in loadChatHistory with a merge that preserves messages already in the UI:
// Instead of:
state.chatMessages = messages.filter(m => !isAssistantSilentReply(m));
// Do:
const existing = new Set(state.chatMessages.map(m => m.timestamp));
const newMsgs = messages.filter(m => !isAssistantSilentReply(m) && !existing.has(m.timestamp));
state.chatMessages = [...state.chatMessages, ...newMsgs];
This prevents the flash-to-empty during reload.
B. Delay history reload until transcript is persisted
Either:
- Wait for
emitUserTranscriptUpdate() to resolve before broadcasting final, OR
- Add a small retry/delay in the client's
loadChatHistory when it detects a final event just happened, OR
- Include the final message in the event payload itself (it already is for the non-btw path) and always use it as the source of truth, falling back to history only for tool results.
C. Concatenate stream segments before markdown rendering
Instead of rendering each chatStreamSegment as an independent markdown block, concatenate all segments + the live chatStream into a single text block, then render as one markdown parse. This prevents unclosed code fences from segment 1 from breaking rendering of segment 2.
Alternatively, at minimum: before committing a segment, check if it contains an unclosed code fence (count ``` occurrences). If odd, append a closing fence before committing.
D. Remove the 4k char truncation for webchat
Webchat is a local-only UI with no message size constraints. Either remove the chunk limit entirely for webchat or make it configurable. See #47123.
E. Adopt runId for passive tabs
When a tab receives a delta event for its session without having initiated the run, it should adopt the runId into chatRunId so it can properly track completion and enable the stop button. Related to #66212.
Related Issues
Environment
- OpenClaw v2026.4.3
- WebChat UI (Lit-based)
- Any model with tool use
Summary
The webchat UI sometimes fails to display the assistant's final response correctly. The response streams correctly during generation, but disappears, collapses, or renders with broken formatting in the final view. This is a compound issue with multiple contributing root causes in the client-side event handling and server-client coordination.
Reproduction
Root Causes
1. History reload race condition (critical)
When a run completes with tool events,
handleTerminalChatEventtriggersloadChatHistory()which does a full replace ofchatMessages:The problem:
loadChatHistoryis async and fire-and-forget (void). If the gateway hasn't finished persisting the transcript to disk before thechat.historyRPC response is assembled, the history comes back missing the final message. The streaming text was visible, then gets replaced by incomplete history.This is a known race — see #11139, #37083.
2. Stream segments break markdown across boundaries (rendering bug)
When the assistant emits text that includes a code fence, then triggers a tool call, the streaming text gets committed as a "segment" via
chatStreamSegments. Each segment is rendered as an independent markdown block throughtoSanitizedMarkdownHtml:If segment 1 ends with an unclosed code fence (e.g., the model wrote
```ts\nconst foo = bar;then triggered a tool call), the markdown renderer treats everything after the fence as code. When the next segment or final text renders below it, the entire response appears wrapped in a broken code block.This is also related to #29472 — the visual collapse where only content after the last tool call survives.
3. Silent truncation at ~4000 chars
The webchat channel bypasses
textChunkLimitconfig and hardcodes a ~4000 char chunk limit. Long responses stream fully but get silently cut in the final rendered message.See #47123 — still open, with detailed root cause analysis.
4.
runIdmismatch drops events silentlyIn
handleChatEvent:When the tab didn't initiate the run (e.g., multi-tab use, or a session that was reset mid-stream), non-final events are silently discarded. See #37083.
5.
messageundefined in btw reply pathThe btw (by-the-way) reply path calls
broadcastChatFinalwithout a message:When
messageis undefined,normalizeFinalAssistantMessage(undefined)returnsnull, so the final handler tries thechatStreamfallback. If the stream was thinking-only content that got stripped, the message vanishes entirely.Proposed Fix
A. Incremental history merge instead of full replace
Replace the full-swap in
loadChatHistorywith a merge that preserves messages already in the UI:This prevents the flash-to-empty during reload.
B. Delay history reload until transcript is persisted
Either:
emitUserTranscriptUpdate()to resolve before broadcasting final, ORloadChatHistorywhen it detects a final event just happened, ORC. Concatenate stream segments before markdown rendering
Instead of rendering each
chatStreamSegmentas an independent markdown block, concatenate all segments + the livechatStreaminto a single text block, then render as one markdown parse. This prevents unclosed code fences from segment 1 from breaking rendering of segment 2.Alternatively, at minimum: before committing a segment, check if it contains an unclosed code fence (count ``` occurrences). If odd, append a closing fence before committing.
D. Remove the 4k char truncation for webchat
Webchat is a local-only UI with no message size constraints. Either remove the chunk limit entirely for webchat or make it configurable. See #47123.
E. Adopt runId for passive tabs
When a tab receives a delta event for its session without having initiated the run, it should adopt the
runIdintochatRunIdso it can properly track completion and enable the stop button. Related to #66212.Related Issues
Environment