Skip to content

[Bug]: [tui] Outbound messages not visible until agent response completes (loadHistory wipes optimistic render) #54722

@seanturner001

Description

@seanturner001

Bug type

Behavior bug (incorrect output/state without crash)

Summary

Bug Report: TUI Outbound Messages Not Rendering Until Agent Response Completes


Metadata

Field Value
Date 2026-03-25
Status ✅ Fixed (local patch applied)
Severity Medium — UX regression; no data loss
Component openclaw-tui
File dist/tui-B38Q46Tm.js
Affects All openclaw-tui versions using this bundle
Reporter Ghost (OpenClaw internal)

Executive Summary

When a user sends a message in the OpenClaw TUI, their outbound message briefly disappears from the chat window and does not reappear until after the agent finishes responding. The message is delivered and processed correctly — the agent receives it and replies — but the UX makes it appear as though the message was dropped or lost.

The root cause is a protocol mismatch in the TUI's "local run ID" tracking system. The TUI registers a client-generated UUID as the expected run ID for the outgoing message, but the gateway never echoes that UUID back in its chat events. This causes every incoming event to be misidentified as a remote/external run, triggering a full loadHistory() call that wipes the optimistically-rendered user message before it is painted to the terminal.

A client-side fix was applied that defers run ID registration until the gateway's actual run ID is known, resolving the rendering issue without any protocol changes.


Symptom

  1. User types a message and submits it in the TUI.
  2. The message disappears from the chat log immediately after submission.
  3. The agent begins processing (activity indicator shows, token count climbs).
  4. The user's message remains invisible throughout the entire agent response.
  5. Once the agent response completes, both the user's message and the agent response appear together.

The message is not lost — it reaches the gateway and the agent responds correctly. The issue is purely visual.


Reproduction Steps

  1. Launch openclaw-tui and connect to a gateway with an active agent session.
  2. Type any message and press Enter.
  3. Observe: Your outbound message disappears from the chat log.
  4. Wait for the agent to finish responding.
  5. Observe: Your message reappears alongside the completed agent response.

Note: This is consistently reproducible on every message send. There is no intermittent trigger.


Root Cause Analysis

Background: Local Run ID Tracking

The TUI maintains a registry of "local run IDs" to distinguish between:

  • Runs initiated by the current user — these should skip loadHistory() on completion to avoid wiping/rebuilding the chat log.
  • Runs initiated externally (other sessions, BTW runs, etc.) — these should trigger loadHistory() to pull in changes.

The Protocol Mismatch

The tracking mechanism relies on the assumption that the run ID registered at send time will match the run ID broadcast in subsequent chat events. This assumption is incorrect:

Step What Happens
1 sendMessage() generates runId = randomUUID() (client-side UUID)
2 runId is passed as idempotencyKey in the chat.send RPC call
3 noteLocalRunId(runId) registers this UUID as a "local" run
4 The gateway receives chat.send and starts a run with its own internally-generated run ID
5 All chat events (delta, final, etc.) are broadcast with the gateway's run ID
6 handleChatEvent calls isLocalRunId(evt.runId) — this always returns false
7 maybeRefreshHistoryForRun() falls through to loadHistory()
8 loadHistory() calls chatLog.clearAll()wiping the optimistic user message
9 History repopulates from the server after the async fetch resolves

Key protocol fact: ChatSendParams.idempotencyKey is stored server-side for deduplication purposes only. It is never echoed back in ChatEvent.runId. The GatewayChatClient.sendChat() method returns { runId: clientUUID }, but this is the client's own UUID — not the gateway's broadcast run ID. The gateway's actual run ID is unknowable until the first chat event arrives.

Timing: Why the Message Disappears

chatLog.addUser(text) schedules a render via process.nextTick. However, loadHistory() is triggered by an incoming WebSocket event, which fires in the same or an earlier async turn. The clear (chatLog.clearAll()) races ahead of the render tick, so the terminal never paints the user message before it is erased.


Fix Applied

Approach: Replace the broken pre-registration pattern with a pendingOptimisticUserMessage flag. Defer local run ID registration until the actual gateway run ID is known from the first incoming chat event.

Change 1 — New state variable

let activeChatRunId = null;
let pendingOptimisticUserMessage = false;  // ← added
let historyLoaded = false;

Change 2 — Expose via state object

get pendingOptimisticUserMessage() { return pendingOptimisticUserMessage; },
set pendingOptimisticUserMessage(value) { pendingOptimisticUserMessage = value; },

Change 3 — sendMessage(): replace broken pre-registration

// Before (broken):
chatLog.addUser(text);
noteLocalRunId(runId);         // BUG: runId is client UUID, never matches gateway events
state.activeChatRunId = runId; // BUG: sets wrong ID
setActivityStatus("sending");

// After (fixed):
chatLog.addUser(text);
state.pendingOptimisticUserMessage = true;  // deferred — real runId not known yet
setActivityStatus("sending");

Change 4 — handleChatEvent(): late-bind the real gateway run ID

// Before:
noteSessionRun(evt.runId);
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId))
    state.activeChatRunId = evt.runId;

// After:
noteSessionRun(evt.runId);
if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
    state.activeChatRunId = evt.runId;
    if (state.pendingOptimisticUserMessage) {
        noteLocalRunId(evt.runId);               // register real gateway run ID
        state.pendingOptimisticUserMessage = false;
    }
}

Change 5 — Clear flag on send error

if (!isBtw) state.pendingOptimisticUserMessage = false;

Why This Works

When the first chat event arrives from the gateway, we now have the real evt.runId. If a user message is pending (flag is set), we register that ID as local — preventing the subsequent loadHistory() call that was clearing the chat. The optimistic user message survives and renders correctly.


Upstream Recommendations

The client-side fix is correct and stable, but two cleaner protocol-level alternatives exist that would eliminate the need for the flag entirely:

Option A — Gateway returns its run ID in the chat.send response

Modify the chat.send RPC response to include the gateway-assigned runId. The client can then call noteLocalRunId() with the real ID immediately after sendChat() resolves, before any chat events arrive.

Pros: Eliminates timing dependency entirely. Clean and explicit.
Cons: Requires a gateway API change; minor breaking change to GatewayChatClient.sendChat() return type.

Option B — Gateway sets ChatEvent.runId to idempotencyKey when provided

When a chat.send includes an idempotencyKey, broadcast it as ChatEvent.runId for all events belonging to that run.

Pros: No client changes needed; existing pre-registration logic would work correctly.
Cons: More invasive gateway change; semantics of runId become ambiguous (client UUID vs. internal ID).

Recommendation: Option A is preferred — it's the most explicit and maintains clean separation between client identity tokens and server run IDs.


Impact Assessment

Dimension Assessment
User impact High visibility — affects every single message send
Data integrity None — messages are delivered correctly
Workaround Wait for agent response before sending next message (poor UX)
Fix risk Low — isolated to rendering/state logic, no protocol changes
Regression surface Minimal — flag is cleared on both success and error paths

Files Modified

  • /opt/homebrew/lib/node_modules/openclaw/dist/tui-B38Q46Tm.js — local patch applied 2026-03-25

Steps to reproduce

Steps to Reproduce

  1. Launch openclaw-tui and connect to a gateway with an active agent session
  2. Type any message in the input and press Enter
  3. Watch the chat log

Expected behavior

Expected Behavior

The sent message appears immediately in the chat history upon submission and remains visible while
the agent processes and responds.

Actual behavior

Actual Behavior

The sent message disappears from the chat log immediately after submission. It remains invisible
for the entire duration of the agent's response. Both the user message and the agent reply appear
together only after the agent finishes.

│ Consistently reproducible on every message send — no intermittent trigger.

OpenClaw version

OpenClaw 2026.3.23-2 (7ffe7e4)

Operating system

macOS 26.4 (Build 25E246)

Install method

No response

Model

anthropic/claude-sonnet-4-6

Provider / routing chain

anthropic/claude-sonnet-4-6 via api-key (anthropic:default)

Additional provider/model setup details

No response

Logs, screenshots, and evidence

Impact and severity

No response

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingbug:behaviorIncorrect behavior without a crash

    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