Skip to content

TUI stuck on 'streaming' indicator after run completes — finalizeRun() doesn't transition UI when wasActiveRun is false #64825

@adamgries

Description

@adamgries

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/lossless-claw@0.6.3 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

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