Skip to content

fix(ui): enable live tool call streaming in Control UI webchat#39036

Closed
jakepresent wants to merge 2 commits into
openclaw:mainfrom
jakepresent:fix/control-ui-tool-streaming
Closed

fix(ui): enable live tool call streaming in Control UI webchat#39036
jakepresent wants to merge 2 commits into
openclaw:mainfrom
jakepresent:fix/control-ui-tool-streaming

Conversation

@jakepresent

@jakepresent jakepresent commented Mar 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Tool call output (e.g. web_search, exec) in the Control UI webchat was invisible during streaming and only appeared after a manual page refresh. This PR fixes five independent issues that combined to cause this.

Fixes #38888

Root Causes

1. Missing tool-events capability (gateway.ts)

Control UI connected with caps: [] and never received tool events from the gateway's broadcastToConnIds path.

Fix: Register tool-events capability in the hello handshake.

2. runId mismatch rejecting all tool events (app-tool-stream.ts)

handleAgentEvent filtered tool events by chatRunId, but the client sets chatRunId to a client-generated UUID (via generateUUID in sendChatMessage), while tool events arrive with the server's engine runId. They could never match.

Fix: Filter tool events by sessionKey only, which is the correct scoping boundary.

3. Tool cards gated behind showThinking (views/chat.ts)

Tool messages were only rendered inside if (props.showThinking). This toggle controls model reasoning/thinking tokens, but tool calls are user-visible actions and should always render.

Fix: Render tool messages unconditionally.

4. History not reloaded after tool runs (app-gateway.ts)

On run completion, resetToolStream cleared chatToolMessages, but shouldReloadHistoryForFinalEvent returned false for normal assistant messages, so tool cards were never repopulated from persisted history.

Fix: Reload history when tool events were seen during the run. Return a flag to prevent the outer shouldReloadHistoryForFinalEvent path from issuing a duplicate loadChatHistory call.

5. No mid-run history reload on tool results (app-gateway.ts)

During streaming, text fragments could be truncated because tool events arrive via a different pipeline than chat deltas. The server persists tool results as they happen, but the UI never fetched them mid-run.

Fix: Reload history on each tool result event so the persisted text and tool output replace any truncated streaming state.

Changes

File Change
ui/src/ui/gateway.ts Register tool-events capability
ui/src/ui/app-tool-stream.ts Filter by sessionKey only (bypass runId), cancel sync timer in resetToolStream
ui/src/ui/app-gateway.ts Reload history on tool result, reload on run completion when tools seen, return flag to prevent double reload
ui/src/ui/controllers/chat.ts Clear chatToolMessages on history load to prevent duplicates
ui/src/ui/views/chat.ts Render tool messages unconditionally (outside showThinking gate)

Testing

Tested manually on Windows with OpenClaw 2026.3.2:

  • Single tool call (web_search) - card renders live, history reloads with full text on result
  • Multiple sequential tool calls - cards and text correct after each result reload
  • Run completion - streaming state cleared, final history reload shows complete message
  • showThinking off - tool cards visible, reasoning hidden
  • showThinking on - tool cards + reasoning tokens visible
  • No tool calls - no behavior change

Related

@greptile-apps

greptile-apps Bot commented Mar 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes four independent bugs that collectively caused live tool-call streaming to be invisible in the Control UI webchat: missing tool-events capability in the gateway handshake, a chatRunId UUID mismatch that rejected all tool events, tool cards gated behind the showThinking toggle, and history not being reloaded after tool runs. The fixes are well-targeted and correctly layered.

Key findings:

  • The core fixes (capability registration, session-key-only filter, unconditional tool card rendering, post-tool history reload) are correct and address the stated root causes.
  • A real inefficiency exists where loadChatHistory is called twice when a tool-only run (hadToolEvents=true) completes with no final message: once from handleTerminalChatEvent (line 275) and again from handleChatGatewayEvent (line 297). Both paths fire in the same synchronous tick because shouldReloadHistoryForFinalEvent returns true for missing messages. This results in duplicate network requests and potential state races. The issue can be fixed by guarding the second reload path to skip it when tool events were already handled.

Confidence Score: 4/5

  • Safe to merge with minor efficiency issue in tool-heavy runs.
  • The four core fixes are correct and well-targeted. One inefficiency is identified: a double loadChatHistory call in tool-only runs, which wastes bandwidth but does not affect correctness since both calls are idempotent. The code cleanly resets streaming state before reloading, preventing data corruption. No blocking issues present.
  • No files require special attention — the double-reload issue is an efficiency concern, not a correctness risk.

Comments Outside Diff (1)

  1. ui/src/ui/app-gateway.ts, line 271-298 (link)

    Concurrent double loadChatHistory call when tools are used in a final event with no assistant message.

    When a run that used tool calls completes with a final event that has no message, both handleTerminalChatEvent (line 275) and the outer check in handleChatGatewayEvent (line 297) will fire loadChatHistory concurrently on the same event. The sequence is:

    1. handleTerminalChatEvent sees hadToolEvents && state === "final" → calls loadChatHistory (line 275)
    2. handleChatGatewayEvent then evaluates shouldReloadHistoryForFinalEvent(payload), which returns true when payload.message is absent (absent message = need to reload) → calls loadChatHistory again (line 297)

    Both calls are void-discarded, so neither is awaited. They race, issuing two network requests instead of one, and any in-flight state can be clobbered by the second call.

    Consider guarding the outer reload to avoid the redundant call when tools were handled:

    // In handleChatGatewayEvent, after handleTerminalChatEvent:
    if (state === "final" && !hadToolEvents && shouldReloadHistoryForFinalEvent(payload)) {
      void loadChatHistory(host as unknown as OpenClawApp);
    }

    Since hadToolEvents is local to handleTerminalChatEvent, it would need to be returned or extracted before calling that function to be visible in handleChatGatewayEvent.

Last reviewed commit: dace5b4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dace5b42ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +414 to 416
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep tool events scoped to the active run

Filtering tool events only by sessionKey lets this handler accept events from any concurrent run in the same session, not just the run the chat view is currently streaming. The gateway intentionally registers one connection for other active runs in that session (for late-join behavior), so in concurrent-run scenarios this will merge unrelated tool cards into the current conversation and can prematurely clear the current chatStream when the first foreign tool event arrives. Please keep run-level scoping (or map server/internal run IDs back to the active client run) instead of session-only acceptance.

Useful? React with 👍 / 👎.

@jakepresent jakepresent force-pushed the fix/control-ui-tool-streaming branch 2 times, most recently from ad118eb to 4b4ef87 Compare March 7, 2026 17:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b4ef87abf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/ui/app-tool-stream.ts Outdated
Comment on lines +444 to +448
host.chatStreamSegments.push({
text: appHost.chatStream,
ts: appHost.chatStreamStartedAt ?? now,
});
appHost.chatStream = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve only post-tool text in live stream

When a tool starts, this code commits the current chatStream into chatStreamSegments and then resets chatStream to null, but subsequent chat deltas are cumulative assistant text for the whole run (the gateway emits merged text and handleChatEvent replaces chatStream with that full value). In any run where the assistant continues after a tool call, the pre-tool prefix is rendered twice—once as a committed segment and again at the start of the resumed live stream—so the interleaved transcript becomes incorrect during streaming.

Useful? React with 👍 / 👎.

Comment thread ui/src/ui/app-tool-stream.ts Outdated
Comment on lines +444 to +446
host.chatStreamSegments.push({
text: appHost.chatStream,
ts: appHost.chatStreamStartedAt ?? now,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound committed stream segments by tool stream limit

Each new tool call appends a new entry to chatStreamSegments, but only toolStreamOrder is trimmed by TOOL_STREAM_LIMIT. In long tool-heavy runs, old tool cards are dropped while their paired stream segments remain indefinitely, causing unbounded growth in rendered items and leaving orphaned text segments with no corresponding tool card.

Useful? React with 👍 / 👎.

Tool call output (web_search, etc.) was invisible during streaming and only
appeared after a manual page refresh. Four independent issues combined to
cause this:

1. Control UI connected with caps: [] and never received tool events from
   the gateway's broadcastToConnIds path. Fix: register tool-events cap.

2. handleAgentEvent rejected all tool events due to runId mismatch: the
   client sets chatRunId to a client-generated UUID while tool events
   arrive with the server engine runId. Fix: filter by sessionKey only.

3. Tool cards were gated behind showThinking (intended for model reasoning
   tokens). When the user had showThinking disabled, tool cards and
   committed stream segments were hidden. Fix: render tool messages and
   stream segments unconditionally.

4. On run completion, resetToolStream cleared chatToolMessages, but
   shouldReloadHistoryForFinalEvent returned false for normal assistant
   messages, so tool cards were never repopulated from history. Fix:
   reload history when tool events were seen during the run. Return a
   flag to prevent the outer shouldReloadHistoryForFinalEvent path from
   issuing a duplicate loadChatHistory call.

Additional improvements:
- Commit streaming text to chatStreamSegments when a tool call starts, so
  assistant text before a tool card is preserved rather than replaced.
- Interleave stream segments and tool messages by timestamp for correct
  visual ordering.
- Clear chatToolMessages on history load to prevent duplicates.
- Cancel pending sync timer in resetToolStream instead of flushing from
  empty state.

Fixes openclaw#38888

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f2c4f9600

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

state.chatThinkingLevel = res.thinkingLevel ?? null;
// Clear streaming tool messages — history includes tool results inline,
// so keeping them would cause duplicates or wrong ordering.
(state as unknown as { chatToolMessages: unknown[] }).chatToolMessages = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tool stream state during history reload

loadChatHistory now unconditionally clears chatToolMessages, but the tab refresh path (refreshActiveTabrefreshChat) calls loadChatHistory without first resetting/rebuilding tool stream state. If a user switches away from chat and back while a tool-heavy run is still in progress, this wipes the currently streamed tool cards from the UI, and they may not reappear until another tool event arrives or the run finishes, causing missing live output in the transcript.

Useful? React with 👍 / 👎.

Comment thread ui/src/ui/app-gateway.ts
Comment on lines 283 to +286
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
});
}
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reload history after reset-command runs with tool events

In handleTerminalChatEvent, runs tracked in refreshSessionsAfterChat (the /new and /reset flow) always return without reloading chat history, even when hadToolEvents is true. Since resetToolStream already cleared live tool state and shouldReloadHistoryForFinalEvent is false for normal assistant finals, reset-command runs that continue with prompt text can lose their tool cards/segments after completion instead of being repopulated from persisted history.

Useful? React with 👍 / 👎.

Tool call output (web_search, etc.) was invisible during streaming and only
appeared after a manual page refresh. Four independent issues combined to
cause this:

1. Control UI connected with caps: [] and never received tool events from
   the gateway's broadcastToConnIds path. Fix: register tool-events cap.

2. handleAgentEvent rejected all tool events due to runId mismatch: the
   client sets chatRunId to a client-generated UUID while tool events
   arrive with the server engine runId. Fix: filter by sessionKey only.

3. Tool cards were gated behind showThinking (intended for model reasoning
   tokens). When the user had showThinking disabled, tool cards were
   hidden. Fix: render tool messages unconditionally.

4. On run completion, resetToolStream cleared chatToolMessages, but
   shouldReloadHistoryForFinalEvent returned false for normal assistant
   messages, so tool cards were never repopulated from history. Fix:
   reload history when tool events were seen during the run. Return a
   flag to prevent the outer shouldReloadHistoryForFinalEvent path from
   issuing a duplicate loadChatHistory call.

5. Reload history on each tool result event so persisted text and tool
   output replace any truncated streaming fragments mid-run.

Additional improvements:
- Clear chatToolMessages on history load to prevent duplicates.
- Cancel pending sync timer in resetToolStream instead of flushing from
  empty state.

Fixes openclaw#38888

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 391384e318

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/ui/app-gateway.ts
Comment on lines +324 to +328
agentPayload?.stream === "tool" &&
typeof (agentPayload.data as Record<string, unknown>)?.phase === "string" &&
(agentPayload.data as Record<string, unknown>).phase === "result"
) {
void loadChatHistory(host as unknown as OpenClawApp);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize tool-result history reload requests

This branch triggers loadChatHistory for every tool phase === "result" event, but each call is fire-and-forget and loadChatHistory unconditionally rewrites state.chatMessages when it resolves. In tool-heavy runs, multiple history requests can overlap; if an earlier request resolves after a later one, it can overwrite newer history with a stale snapshot and temporarily (or until manual refresh) hide newer tool output. Coalescing or sequencing these reloads would prevent out-of-order state rollback.

Useful? React with 👍 / 👎.

@jakepresent

Copy link
Copy Markdown
Contributor Author

Superseded by a cleaner PR — same core fixes but slimmed down after iteration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] WebChat/Control UI: WebSocket messages not pushed to frontend, requires refresh to see output

1 participant