[Bug] Idle resume cannot clear a stale stream entry — a finished session keeps showing 「正在思考」
中文摘要
已结束的会话在 Studio 里仍永久显示「正在思考」,而且每次点进去,秒数都从 0 重新开始。
服务端 resume 明确回答 working: false,数据库里 ended_at / end_reason 也正常写入 —— 也就是说服务端是健康的,是客户端把自己仍当成运行中。
根因在 packages/client/src/stores/hermes/chat.ts 的 resumeServerWorkingRun():它先检查自己手里的 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:
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:
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)) returnWhen 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:
isRunActivestaystrue→ the session renders as busy indefinitely;runStartedAthas already been cleared by the idle resume, soMessageListfalls back toDate.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
abortStateper 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:
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)) returnThis 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) → resumeisWorking: false→ assertisRunActive === falseand no leftoverrunStartedAt - 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