openclaw - ✅(Solved) Fix TUI stuck on 'streaming' indicator after run completes — finalizeRun() doesn't transition UI when wasActiveRun is false [4 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#64825Fetched 2026-04-12 13:26:33
View on GitHub
Comments
0
Participants
1
Timeline
5
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×4referenced ×1

The TUI's status bar stays stuck on streaming for minutes (indefinitely in some cases) after a chat turn has already completed. The visible reply renders correctly, but the spinner keeps running and the status line keeps saying streaming • N m Ns long after sessions.json has already recorded the run as done.

This isn't normal post-response cleanup (LCM compaction, tool finalization, etc.) — I can see durations of 1–5+ minutes on very short replies where generation should take <1 second.

Error Message

This is a silent failure path — there's no log, no error, no watchdog. The UI is just permanently out of sync with the actual run state.

Root Cause

In dist/tui-wNBv-LUG.js (openclaw 2026.4.5), finalizeRun() is defined around line 2634:

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun) setActivityStatus(params.status);  // ← only fires when wasActiveRun is true
    refreshSessionInfo?.();
};

wasActiveRun is computed in the event handler at line 2704:

const wasActiveRun = state.activeChatRunId === evt.runId;

And state.activeChatRunId is only set at line 2689 when:

if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
    state.activeChatRunId = evt.runId;
    ...
}

So if state.activeChatRunId was cleared by another event, or if isLocalBtwRunId() returned true and prevented it from being set in the first place, wasActiveRun is false when the "final" event arrives, and setActivityStatus("idle") is never called. The spinner keeps spinning even though the run is genuinely finished.

This is a silent failure path — there's no log, no error, no watchdog. The UI is just permanently out of sync with the actual run state.

Fix Action

Workaround

I've published this patch locally via our post-update reconciliation pipeline so it gets re-applied automatically after every npm update openclaw. Happy to send a PR if the above proposal looks reasonable.

PR fix notes

PR #64842: fix(tui): clear stale streaming status for completed unbound runs

Description (problem / solution / changelog)

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: the TUI can stay stuck on streaming after a run finishes if that run never bound as activeChatRunId.
  • Why it matters: completed turns look hung until the user manually clears the status, even though the reply/rendered state is already done.
  • What changed: finalizeRun() now falls back to the terminal status when no tracked runs remain, and empty local /btw finals use the same idle recovery path.
  • What did NOT change (scope boundary): concurrent-run status handling and history refresh behavior stay as-is.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #64825
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: chat deltas always set streaming, but the terminal status transition only fired when wasActiveRun was true; local /btw runs can stream without ever binding as the active run, and their empty final path never had a fallback idle transition.
  • Missing detection / guardrail: there was no regression test for a local /btw run that renders delta text and then finishes with an empty final event.
  • Contributing context (if known): handleChatEvent() intentionally skips binding activeChatRunId for local /btw runs, so the edge case only appears on that unbound completion path.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/tui/tui-event-handlers.test.ts
  • Scenario the test should lock in: a local /btw run emits a visible delta, then completes with an empty final event, and the TUI status returns from streaming to idle.
  • Why this is the smallest reliable guardrail: it exercises the exact handler/state transition without needing a live model or interactive TUI session.
  • Existing test that already covers this (if any): the concurrent-run tests in the same file already cover the “do not clear another active run” side of this state machine.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

  • Completed TUI turns that finish on the unbound local /btw path now return the status bar to idle instead of leaving it stuck on streaming.

Diagram (if applicable)

Before:
local /btw run -> delta sets streaming -> empty final arrives unbound -> status stays streaming

After:
local /btw run -> delta sets streaming -> empty final arrives unbound -> status returns to idle

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS (local dev machine)
  • Runtime/container: Node 22.17.1
  • Model/provider: N/A
  • Integration/channel (if any): TUI local /btw event-handler path
  • Relevant config (redacted): none

Steps

  1. Mark a run as local /btw so it does not bind activeChatRunId.
  2. Send a chat delta for that run so the handler renders text and sets streaming.
  3. Send the empty final event for the same run.

Expected

  • The TUI status returns to idle once the final event is processed.

Actual

  • Before this change, the status remained stuck on streaming.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: reproduced the handler seam locally before the fix (statusCalls: ["streaming"]) and after the fix (statusCalls: ["streaming", "idle"]); ran pnpm test src/tui/tui-event-handlers.test.ts, pnpm build, and pnpm check.
  • Edge cases checked: the existing concurrent-run regression in src/tui/tui-event-handlers.test.ts still passes, so another active run does not get forced to idle.
  • What you did not verify: a live interactive TUI session against a real provider.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Risks and Mitigations

  • Risk: a terminal status fallback could clear streaming too early if another run were still in flight.
    • Mitigation: the new path only fires when no tracked runs remain, and the existing concurrent-run regression test stays green.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/tui/tui-event-handlers.test.ts (modified, +82/-0)
  • src/tui/tui-event-handlers.ts (modified, +5/-2)

PR #64843: fix(tui): recover footer state after stale run final

Description (problem / solution / changelog)

Summary

Closes #64825.

A non-local chat final could leave the TUI footer stuck in streaming when activeChatRunId had gone stale, because finalizeRun() only settled the footer when the finishing run was still marked active. This lets external final events recover the footer once no tracked runs remain, and adds a regression test that preserves the existing local-run defer behavior from #53115.

Validation

  • pnpm exec vitest run src/tui/tui-event-handlers.test.ts src/tui/tui-session-actions.test.ts
  • pnpm exec oxlint src/tui/tui-event-handlers.ts src/tui/tui-event-handlers.test.ts

pnpm check still fails on main with the existing repo-wide 442-error oxlint baseline, unchanged by this PR.

Changed files

  • src/tui/tui-event-handlers.test.ts (modified, +17/-0)
  • src/tui/tui-event-handlers.ts (modified, +22/-3)

PR #64847: fix(tui): recover activity status when no runs are in flight

Description (problem / solution / changelog)

Summary

The TUI status bar can get stuck on streaming indefinitely after a chat turn completes. This happens when state.activeChatRunId is cleared by a concurrent event before the final event arrives, causing wasActiveRun to be false in finalizeRun — the setActivityStatus("idle") call is skipped and the spinner never stops.

Changes

Add a recovery condition to both finalizeRun and terminateRun: when wasActiveRun is false but there are provably no other runs in flight (sessionRuns.size === 0 and activeChatRunId is null), still transition the activity status.

This is safe because it only fires when the state machine has confirmed no other work is active — it cannot interfere with legitimate in-flight runs.

Testing

  • Normal case: wasActiveRun === true → unchanged behavior
  • Edge case: wasActiveRun === false, no runs in flight → status recovers to idle
  • Concurrent runs: sessionRuns.size > 0 → no premature status change

Fixes #64825

Changed files

  • src/tui/tui-event-handlers.ts (modified, +6/-2)

PR #64862: fix(tui): recover activity status when no runs are in flight

Description (problem / solution / changelog)

Summary

The TUI status bar can get stuck on streaming indefinitely after a chat turn completes. When activeChatRunId is cleared by a concurrent event before the final event arrives, wasActiveRun is false and setActivityStatus("idle") is never called.

Changes

Add a recovery condition to finalizeRun and terminateRun: when wasActiveRun is false but no other runs are in flight (sessionRuns.size === 0 and activeChatRunId is null), still transition the activity status. Safe because it only fires when no work is active.

Fixes #64825

Changed files

  • src/tui/tui-event-handlers.ts (modified, +6/-2)

Code Example

"agent:main:fresh": {
  "status": "done",
  "startedAt": 1775839318369,
  "endedAt": 1775839413742,
  "updatedAt": 1775839443762,
  "origin": { "label": "openclaw-tui", "surface": "webchat" }
}

---

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun) setActivityStatus(params.status);  // ← only fires when wasActiveRun is true
    refreshSessionInfo?.();
};

---

const wasActiveRun = state.activeChatRunId === evt.runId;

---

if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
    state.activeChatRunId = evt.runId;
    ...
}

---

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun || (sessionRuns.size === 0 && !state.activeChatRunId)) {
        setActivityStatus(params.status);
    }
    refreshSessionInfo?.();
};
RAW_BUFFERClick to expand / collapse

Summary

The TUI's status bar stays stuck on streaming for minutes (indefinitely in some cases) after a chat turn has already completed. The visible reply renders correctly, but the spinner keeps running and the status line keeps saying streaming • N m Ns long after sessions.json has already recorded the run as done.

This isn't normal post-response cleanup (LCM compaction, tool finalization, etc.) — I can see durations of 1–5+ minutes on very short replies where generation should take <1 second.

Reproduction

  1. Start a TUI session: openclaw
  2. Send any message (a trivial one like "how are you?" is enough)
  3. Wait for the reply to render
  4. Observe: the status bar continues to show ⠋ streaming • Ns | connected for >2 minutes even though the reply has been visible since second 1–2

The problem is intermittent — maybe 1 in 3–5 turns — but once it happens, the indicator never clears without hitting Esc manually.

Evidence the run actually did complete

While the TUI still displays streaming, ~/.openclaw/agents/main/sessions/sessions.json shows the session is done:

"agent:main:fresh": {
  "status": "done",
  "startedAt": 1775839318369,
  "endedAt": 1775839413742,
  "updatedAt": 1775839443762,
  "origin": { "label": "openclaw-tui", "surface": "webchat" }
}

So the gateway has already marked the run complete. The bug is UI-side — the TUI missed/dropped the signal that should have transitioned activityStatus from streamingidle.

Root cause

In dist/tui-wNBv-LUG.js (openclaw 2026.4.5), finalizeRun() is defined around line 2634:

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun) setActivityStatus(params.status);  // ← only fires when wasActiveRun is true
    refreshSessionInfo?.();
};

wasActiveRun is computed in the event handler at line 2704:

const wasActiveRun = state.activeChatRunId === evt.runId;

And state.activeChatRunId is only set at line 2689 when:

if (!state.activeChatRunId && !isLocalBtwRunId?.(evt.runId)) {
    state.activeChatRunId = evt.runId;
    ...
}

So if state.activeChatRunId was cleared by another event, or if isLocalBtwRunId() returned true and prevented it from being set in the first place, wasActiveRun is false when the "final" event arrives, and setActivityStatus("idle") is never called. The spinner keeps spinning even though the run is genuinely finished.

This is a silent failure path — there's no log, no error, no watchdog. The UI is just permanently out of sync with the actual run state.

Proposed fix

The safest surgical fix: in finalizeRun, always call setActivityStatus("idle") when there are provably no other runs in flight. This preserves the existing wasActiveRun behavior for the common case but adds a recovery path for the edge case:

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun || (sessionRuns.size === 0 && !state.activeChatRunId)) {
        setActivityStatus(params.status);
    }
    refreshSessionInfo?.();
};

This is safer than a watchdog timer because it only fires when the state machine has proven no other runs are active — it can't interfere with legitimate in-flight work.

Workaround

I've published this patch locally via our post-update reconciliation pipeline so it gets re-applied automatically after every npm update openclaw. Happy to send a PR if the above proposal looks reasonable.

Environment

  • openclaw 2026.4.5 (3e72c03)
  • macOS 15.4 (Darwin 25.4.0)
  • Node 22
  • OpenAI OAuth (not per-token billing) → openai-codex/gpt-5.4
  • LCM plugin @martian-engineering/[email protected] enabled

Related behaviors observed while debugging

  • sessions.json always shows correct state (status: "done", endedAt set) when the TUI is stuck
  • Hitting Esc in the TUI recovers it instantly (so the state IS reachable, it's just not being driven)
  • No gateway abort API exists, so a stale TUI can't be cleared programmatically from outside

extent analysis

TL;DR

The proposed fix involves modifying the finalizeRun function to always call setActivityStatus("idle") when there are no other runs in flight, ensuring the UI syncs with the actual run state.

Guidance

  • Verify that the sessions.json file shows the correct state (status: "done", endedAt set) when the TUI is stuck to confirm the issue is UI-side.
  • Apply the proposed fix to the finalizeRun function to add a recovery path for the edge case where wasActiveRun is false.
  • Test the fix by reproducing the issue and checking if the status bar correctly transitions from streaming to idle after a chat turn completes.
  • Consider sending a PR with the proposed fix to ensure it gets reviewed and merged into the main codebase.

Example

The proposed fix involves modifying the finalizeRun function as follows:

const finalizeRun = (params) => {
    noteFinalizedRun(params.runId);
    clearActiveRunIfMatch(params.runId);
    flushPendingHistoryRefreshIfIdle();
    if (params.wasActiveRun || (sessionRuns.size === 0 && !state.activeChatRunId)) {
        setActivityStatus(params.status);
    }
    refreshSessionInfo?.();
};

This change ensures that setActivityStatus("idle") is called when there are no other runs in flight, preventing the UI from getting stuck in the streaming state.

Notes

The issue is intermittent and may not occur every time, so thorough testing is necessary to verify the fix. Additionally, the fix assumes that the sessionRuns.size and state.activeChatRunId variables accurately reflect the state of the runs.

Recommendation

Apply the proposed workaround by modifying the finalizeRun function as described, and consider sending a PR to get the fix reviewed and merged into the main codebase. This approach ensures that the UI correctly syncs with the actual run state and prevents the status bar from getting stuck in the streaming state.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING