[Bug] Idle resume cannot clear a stale stream entry — a finished session keeps showing 「正在思考」

Author: 666-999bCreated Sep 17, 2026Updated Sep 18, 2026

中文摘要

已结束的会话在 Studio 里仍永久显示「正在思考」,而且每次点进去,秒数都从 0 重新开始

服务端 resume 明确回答 working: false,数据库里 ended_at / end_reason 也正常写入 —— 也就是说服务端是健康的,是客户端把自己仍当成运行中

根因在 packages/client/src/stores/hermes/chat.tsresumeServerWorkingRun():它先检查自己手里的 streamStates,一旦有残留条目就直接 return,于是服务端「已空闲」的权威回答永远没机会清掉这个残留。

它与 #3025 / #1998 不是同一个根因(见下方对比)。


English

Summary

A session whose run has already ended keeps rendering as busy — the thinking indicator stays up indefinitely — and the thinking timer restarts from zero every time that session is re-opened.

Environment

  • Studio / Web UI 0.7.22, packaged Windows desktop build
  • Transport: single chat (chat-run)
  • Still present on current main (2a70334)

Root cause

isRunActive is derived from the per-session maps:

typescript
const isStreaming = computed(() => {
  const sid = activeSessionId.value
  if (sid == null) return false
  return streamStates.value.has(sid) || serverWorking.value.has(sid)
})
const isRunActive = computed(() => isStreaming.value)

resumeServerWorkingRun() decides whether to keep listening by consulting its own map before the authoritative resume answer:

typescript
function resumeServerWorkingRun(sid: string, force = false, passive = false) {
  const generation = runtimeGeneration
  // Don't register duplicate listeners if already streaming
  if (streamStates.value.has(sid)) return           // ← early return
  // Only set up listeners if the server reported an active run during resume.
  if (!force && !serverWorking.value.has(sid)) return

When a run ends without a terminal event reaching the client (abort, dropped socket, Studio process restart), the client keeps its streamStates entry. On the next resume the server correctly reports isWorking: false, and switchSession() folds that into serverWorking — but the stale streamStates entry survives the early return, so:

  1. isRunActive stays true → the session renders as busy indefinitely;
  2. runStartedAt has already been cleared by the idle resume, so MessageList falls back to Date.now() → the timer restarts from zero on every entry.

Evidence

1. Server log — every resume of the affected session reports idle (10 consecutive entries in ~/.hermes-web-ui/logs/server.log):

[chat-run-socket] socket ... resumed session <session-id> (working: false, messages: 335)

2. The session row is not stuck (~/.hermes-web-ui/hermes-web-ui.db):

ended_at = <timestamp>
end_reason = 'complete'

3. UI — the affected chat shows 正在思考 · 8s (and 0s immediately after switching in) while the server says idle. The seconds count restarts at 0 on each entry rather than resuming.

Why this is not #3025 / #1998

Both of those are server-side: sessions.ended_at / end_reason are never written, so nothing ever tells the UI the turn ended.

#3025 / #1998 This report
sessions.ended_at NULL <timestamp>
end_reason NULL complete
resume payload isWorking: false
responsible layer server client

The server side here is healthy and does answer correctly; the client discards that answer. Both deserve fixing, but closing this one as a duplicate of #3025 would leave the client half broken.

The other two halves of this state machine are already fixed

  • #2203 scoped abortState per session;
  • #2723 made the thinking timer count from the run rather than from page mount.

This report is the remaining half: an idle resume cannot clear a stale stream entry.

#1558 reported the same broad class earlier (busy indicator never stops when the terminal event is lost), but its resolution was a timeout fallback on the server/poll side; it does not cover a resume that already knows the session is idle.

Proposed fix

Treat the resume payload as authoritative — when the session is idle, drop the leftover entry instead of returning early:

typescript
    if (!serverWorking.value.has(sid) && streamStates.value.has(sid)) {
      streamStates.value.delete(sid)
      clearRunStartedAt(sid)
      unregisterSessionHandlers(sid)
    }
    // Don't register duplicate listeners if already streaming
    if (streamStates.value.has(sid)) return
    // Only set up listeners if the server reported an active run during resume.
    if (!force && !serverWorking.value.has(sid)) return

This mirrors the existing cleanup() semantics exactly. It only fires when the server reports nothing running and nothing pending: force is still true while backgroundPending > 0, so passive background listeners are unaffected.

Regression test

tests/client/chat-store-stale-stream-resume.test.ts

  • resume isWorking: true (installs the stream entry) → resume isWorking: false → assert isRunActive === false and no leftover runStartedAt
  • without the fix: fails with expected true to be false
  • with the fix: passes; 80 neighbouring chat / MessageList store tests still pass

Note for the reviewer

#2717 touches the same resume path, so it may conflict.

Source: EKKOLearnAI/hermes-studio