Skip to content

[WebChat] Final assistant message not displayed — history reload race + stream collapse on multi-turn tool runs #66316

@jaruesink

Description

@jaruesink

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

  1. Open webchat, send a message that triggers tool use
  2. Observe the streamed response appearing correctly
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions