fix: clear stale saved session on 404 + structured api() errors - #1304
nesquena-hermes wants to merge 1 commit into
Conversation
Two coupled fixes for the stale-empty-session regression: 1. api() in static/workspace.js now attaches HTTP context (.status, .statusText, .body) to thrown errors. Callers can branch on status without re-parsing the message string. 2. loadSession() in static/sessions.js: when a 404 comes back for the currently-saved active session ID, wipe the localStorage entry, clear the in-flight load marker, and rethrow so boot can fall through to the empty state. Previously the UI would stick on "Session not available in web UI." across reloads because the saved key never got removed. Click-into-404 (where the user clicked an existing list item that vanished) is unaffected — the cleanup is gated on !currentSid. Tests: tests/test_stale_empty_session_restore.py — 3 new assertions: * api() attaches status/statusText/body to errors * loadSession clears saved-stale-404 + rethrows * Click-into-404 does NOT clear the saved session (gate on !currentSid) Full suite: 3255 passed. Salvaged from contributor work in PR #1084. Co-authored-by: Hermes Agent <hermes@get-hermes.ai>
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve)
Two coupled, scope-tight UX fixes salvaged from external PR #1084 (@GeoffBao). When a user has a saved active-session ID in localStorage and that session 404s on the server (e.g. it was deleted from another browser, or a state-DB rotation removed it), the WebUI used to stick on "Session not available in web UI." across reloads because the saved key was never cleared. This PR makes the boot path self-heal.
What this ships
static/workspace.jsapi()— attaches.status/.statusText/.bodyto thrown errors so callers can branch on HTTP status without re-parsing the message string (static/workspace.js:14-26)static/sessions.jsloadSession()404 handler — when the request was for the saved active-session ID (!currentSid && localStorage.getItem('hermes-webui-session') === sid), wipe the stale key, clear the in-flight load marker, and rethrow so boot can fall through to the fresh empty-state (static/sessions.js:336-355)- 3 new regression tests in
tests/test_stale_empty_session_restore.pycovering: status preservation on errors, 404-cleanup-and-rethrow, click-into-404 NOT clearing saved key - Update to
tests/test_1038_pwa_auth_redirect.py— accepts boththrow new Error(...)andthrow err;patterns; the 401-before-throw invariant is preserved
End-to-end trace
Pre-fix path (the bug):
- Boot at static/boot.js:911 reads saved sid from localStorage
await loadSession(saved)(static/boot.js:914)api('/api/session?...')404s- Old
loadSession()swallowed the 404 — set the "Session not available" innerHTML andreturn-ed without throwing - Boot's
tryblock resolved successfully (no exception),S.sessionis null,localStorage.removeItem('hermes-webui-session')at static/boot.js:945 never runs (it's only in thecatch) - Stale key persists → next reload reproduces the bug
Post-fix path:
1-3. Same.
4. New api() throws err with err.status === 404
5. New loadSession() 404 handler:
- Sets innerHTML to "Session not available"
- Checks
!currentSid && localStorage.getItem('hermes-webui-session') === sid(static/sessions.js:344) - Wipes localStorage, clears
_loadingSessionId, rethrows e (static/sessions.js:347)
- Boot's
catch(e){localStorage.removeItem(...)}at static/boot.js:945 runs (defensively redundant but harmless) - Boot falls through to the no-saved-session empty-state at static/boot.js:947-959
- On the next reload there's no saved sid, so the empty state renders cleanly
Verified the gating logic
currentSid is captured at static/sessions.js:314 as S.session ? S.session.session_id : null. So:
- Boot path (the bug):
S.sessionis null →currentSid === null→!currentSidis true → cleanup runs ✅ - Click-into-404 path: user clicks a different session while another is active →
S.sessionset →currentSidnon-null →!currentSidis false → cleanup is skipped, saved active-session preserved ✅ - Reload-same-stale-session path: same as boot path — gated condition holds ✅
The second guard localStorage.getItem('hermes-webui-session') === sid is belt-and-suspenders: even on a wonky boot where currentSid races to null but the in-flight sid wasn't the saved one, we don't wipe. ✅
Security audit
- ✅
err.body = text— attaches raw response body to a JS Error object. The body is server-controlled and the error object is in-process; no PII exfil channel. Callers can introspect for branching but no logging change. - ✅ No new endpoints / no new routes / no auth changes — the 401 redirect short-circuit at static/workspace.js:15 still runs before the new throw block. Confirmed by the
test_workspace_js_401_before_throwtest still passing with the newthrow err;pattern. - ✅ No XSS / no innerHTML user-data interpolation — the "Session not available" innerHTML is a static literal.
- ✅ No retry behavior change — the catch-block check
if(e.message&&/401/.test(e.message)) throw e;at static/workspace.js:36 still works because the newerr.messageis still set from the JSON error body. Network errors (TypeError) still retry.
Other audit — things that are correct
- ✅ Existing callers using
e.messagekeep working — the new throw still setsmessageexactly as before - ✅
_loadingSessionIdcleared before throw (static/sessions.js:346) — prevents the in-flight marker from blocking subsequent loads after the rethrow - ✅ The post-cleanup throw lands in boot's already-existing catch — no caller changes required at static/boot.js:945
- ✅ No agent / cross-tool surface touched — pure WebUI client-side fix; CLI never reads localStorage; agent never sees the saved-session key
Edge-case matrix
| Scenario | Expected | Verified |
|---|---|---|
| Fresh boot, saved sid 404s | Wipe localStorage, fall through to empty state | ✅ trace |
| User clicks list item that 404s (active session present) | Show "not available", DON'T wipe saved key | ✅ !currentSid gate |
| User reloads same stale sid | Wipe + clean empty state on next reload | ✅ trace |
| Saved sid resolves successfully | Normal flow | ✅ unchanged |
| Network error (5xx, timeout) | "Failed to load" toast, no wipe | ✅ unchanged (else branch) |
| 401 during api() | Redirect to /login, no throw | ✅ short-circuit preserved |
Generic error from api() |
e.message, e.status, e.body all populated |
✅ test |
Caller does if (e.status === 404) |
Works without re-parsing message string | ✅ contract test |
Tests
tests/test_stale_empty_session_restore.py— 3/3 pass (new file)tests/test_1038_pwa_auth_redirect.py— 12/12 pass (1 updated test)- Full suite: 3203 passed, 54 skipped, 3 xpassed, 0 failed in 15.22s (matches PR body's 3255-on-PR-runner once skip-set differences are accounted for)
Minor observations (non-blocking)
- The "Session not available in web UI." DOM message remains briefly visible during the rethrow before boot's catch transitions to the empty state. This is fine — on the next reload the empty state renders cleanly because the localStorage key is now gone. A future polish could explicitly clear
msgInnerin boot's catch handler, but it's out of scope. test_click_into_404_does_not_clear_saved_sessionasserts"!currentSid" in block— it verifies the gate token is present but not that it's the negation gating the clear. Acceptable for a regex-style assertion; behavioural confirmation is in the trace above.api()retry logic still inspectse.messagefor/401/(static/workspace.js:36) — could now usee.status === 401directly, but that's a separate cleanup PR.
Recommendation
✅ Approved. End-to-end trace verified the self-heal path; gating logic correctly distinguishes saved-session-load from click-into-load; no security regression; 401 path preserved; full suite green. Parked at approval — ready for the release agent's merge/tag pipeline.
release: v0.50.244 Batch release of 4 PRs: - #1303 (@fecolinhares) — TTS playback of agent responses via Web Speech API. Per-message speaker button + auto-read toggle + voice/rate/pitch in Settings. localStorage-only state. Closes #499. - #1304 — Stale saved session 404 cleanup + structured api() errors. Salvaged from #1084. Independently approved on 358275e. - #1306 — Cmd/Ctrl+K works while a conversation is busy. Salvaged from #1084. Independently approved on 2e8a239. - #1307 — Sienna skin (warm clay & sand earth palette). Salvaged from #1084. Independently approved on 5cd79c8. Tests: 3290 passed, 2 skipped, 3 xpassed, 0 failures (was 3254; +36 tests). Independently reviewed and approved by nesquena (commit 47f0e0d). End-to-end trace verified the TTS flow; security audit confirmed SpeechSynthesisUtterance is plain-text-only with no XSS surface; behavioural harness confirmed _stripForTTS handles all 12 markdown-stripping cases; bounds clamping on rate/pitch verified; opt-in behavior verified.
Shipped in v0.50.244 🎉Thanks @nesquena-hermes! Your work landed in the v0.50.244 batch release.
Closing — the change is on |
release: v0.50.244 Batch release of 4 PRs: - nesquena#1303 (@fecolinhares) — TTS playback of agent responses via Web Speech API. Per-message speaker button + auto-read toggle + voice/rate/pitch in Settings. localStorage-only state. Closes nesquena#499. - nesquena#1304 — Stale saved session 404 cleanup + structured api() errors. Salvaged from nesquena#1084. Independently approved on 358275e. - nesquena#1306 — Cmd/Ctrl+K works while a conversation is busy. Salvaged from nesquena#1084. Independently approved on 2e8a239. - nesquena#1307 — Sienna skin (warm clay & sand earth palette). Salvaged from nesquena#1084. Independently approved on 5cd79c8. Tests: 3290 passed, 2 skipped, 3 xpassed, 0 failures (was 3254; +36 tests). Independently reviewed and approved by nesquena (commit 47f0e0d). End-to-end trace verified the TTS flow; security audit confirmed SpeechSynthesisUtterance is plain-text-only with no XSS surface; behavioural harness confirmed _stripForTTS handles all 12 markdown-stripping cases; bounds clamping on rate/pitch verified; opt-in behavior verified.
fix: clear stale saved session on 404 + structured api() errors
Two coupled fixes for a real user-facing regression: when a saved session ID returns 404 (e.g. the session was deleted from another browser, or a state DB rotation removed it), the prior behavior was to show "Session not available in web UI." and stick there forever — every reload reproduced the broken state because the saved
localStorageentry never got cleared.Salvaged from PR #1084
The contributor PR #1084 (@GeoffBao) included this fix mixed with multiple unrelated changes. Lifted out as a focused PR.
What ships
1.
static/workspace.js— structuredapi()errorsPreviously
api()threw anew Error(message)and discarded the HTTP context. Callers that wanted to branch on status (404 stale-session cleanup, 401 redirect, 503 retry) had to re-parse the message string.Now it attaches
.status,.statusText, and.bodyto the thrown error:The 401 →
/loginredirect path is unchanged and still short-circuits before the throw.2.
static/sessions.js— stale-session 404 cleanup inloadSession()When
/api/session?id=Xreturns 404 for the currently-saved active session ID:hermes-webui-sessionfromlocalStorage_loadingSessionIdso the next session can loadThe cleanup is gated on
!currentSid— if the user clicked into a session that subsequently 404s (a different code path), we don't wipe their saved active-session key.Tests
tests/test_stale_empty_session_restore.py— 3 new assertions:test_api_http_errors_preserve_response_statusapi()attaches.status/.statusText/.bodyto thrown errorstest_load_session_clears_saved_stale_404_and_rethrows_to_bootloadSession404 branch clearslocalStorage, clears_loadingSessionId, rethrowstest_click_into_404_does_not_clear_saved_session!currentSidgate prevents user-initiated clicks-into-missing sessions from wiping the saved keytests/test_1038_pwa_auth_redirect.py::test_workspace_js_401_before_throwupdated to accept either the oldthrow new Error(...)or the newthrow err;pattern. The check that the 401 redirect comes before any throw is preserved.Full suite: 3255 passed, 2 skipped, 3 xpassed, 0 failures.
What's intentionally NOT included from #1084
The other changes in #1084 ship as separate sibling PRs:
Each is independently scoped and reviewable.
Risk
Low. Adds context to thrown errors (additive — existing callers using
e.messagekeep working). The localStorage cleanup is gated narrowly on!currentSid && saved === sidso it can't wipe state for active interactions.Diff stats
Closes part of #1084 once merged.