From 8138ca8479140a33368e9d152aaee5ac2a317385 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Tue, 5 May 2026 22:14:18 +0800 Subject: [PATCH 1/4] fix: keep saved running sessions sidebar-only on root boot Root page loads should not automatically project a localStorage-saved running session into the active pane. Keep explicit /session/ behavior unchanged while leaving the saved session discoverable from the sidebar. (cherry picked from commit bb60cf21d911a84e285363bcecf46fb441181fb9) --- static/boot.js | 22 ++++- tests/test_1694_root_saved_running_policy.py | 91 ++++++++++++++++++++ tests/test_session_cross_tab_sync.py | 4 +- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/test_1694_root_saved_running_policy.py diff --git a/static/boot.js b/static/boot.js index 8767dd67..a85df0c5 100644 --- a/static/boot.js +++ b/static/boot.js @@ -42,6 +42,17 @@ async function cancelSessionStream(session){ if(typeof renderSessionList==='function') renderSessionList(); } +async function _savedSessionShouldStaySidebarOnly(sid){ + if(!sid) return false; + try{ + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); + const session = data&&data.session; + return !!(session&&(session.active_stream_id||session.pending_user_message)); + }catch(e){ + return false; + } +} + // ── Mobile navigation ────────────────────────────────────────────────────── let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' @@ -1346,9 +1357,18 @@ function applyBotName(){ // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load) if(typeof fetchReasoningChip==='function') fetchReasoningChip(); const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; - const saved=urlSession||localStorage.getItem('hermes-webui-session'); + const savedLocal=localStorage.getItem('hermes-webui-session'); + const saved=urlSession||savedLocal; if(saved){ try{ + if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){ + S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false; + S._bootReady=true; + syncTopbar();syncWorkspacePanelState(); + $('emptyState').style.display=''; + await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE(); + return; + } await loadSession(saved); // If the restored session has no messages it is an ephemeral scratch pad — // treat the page as a fresh start rather than resuming a blank conversation. diff --git a/tests/test_1694_root_saved_running_policy.py b/tests/test_1694_root_saved_running_policy.py new file mode 100644 index 00000000..ca4a13e7 --- /dev/null +++ b/tests/test_1694_root_saved_running_policy.py @@ -0,0 +1,91 @@ +"""Regression tests for #1694 root boot policy around saved running sessions. + +The active pane is only a projection. A root `/` tab restored from +``localStorage['hermes-webui-session']`` should not automatically project into a +saved session that is still running, because that makes the new tab inherit the +running pane's busy/stream state even though the user did not explicitly open +that session. + +Explicit `/session/` reload remains different: it should still restore and +reattach to the requested running session. +""" + +from pathlib import Path + + +REPO = Path(__file__).parent.parent +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + +def _boot_saved_session_block() -> str: + marker = "const urlSession=" + start = BOOT_JS.find(marker) + assert start > 0, "boot saved-session restore block not found" + end_marker = "// no saved session" + end = BOOT_JS.find(end_marker, start) + assert end > start, "no-saved-session marker not found after restore block" + return BOOT_JS[start:end] + + +def test_root_boot_distinguishes_url_session_from_localstorage_saved_session(): + """Root restore and explicit URL restore must be separate decisions.""" + block = _boot_saved_session_block() + assert "const savedLocal=" in block, ( + "boot must keep the localStorage session separate from urlSession so " + "root `/` policy can differ from explicit `/session/` reload" + ) + compact = block.replace(" ", "") + assert "constsaved=urlSession||savedLocal" in compact, ( + "boot should still prefer explicit URL sessions over saved localStorage sessions" + ) + + +def test_root_saved_running_session_is_checked_before_load_session_projection(): + """A saved running localStorage session should be detected before loadSession().""" + block = _boot_saved_session_block() + guard = "!urlSession&&savedLocal" + guard_pos = block.replace(" ", "").find(guard) + load_pos = block.find("await loadSession(saved)") + assert guard_pos >= 0, ( + "root `/` boot must have a !urlSession && savedLocal guard for saved " + "running sessions before projecting them into the active pane" + ) + assert load_pos >= 0, "loadSession(saved) call not found" + assert guard_pos < load_pos, ( + "saved running-session root guard must run before loadSession(saved), " + "otherwise loadSession already projects the session into the active pane" + ) + assert "_savedSessionShouldStaySidebarOnly" in block, ( + "boot should delegate the saved-running metadata check to a named helper" + ) + + +def test_saved_running_session_helper_uses_metadata_only_and_runtime_markers(): + """The helper should inspect metadata without loading messages or attaching SSE.""" + helper_idx = BOOT_JS.find("async function _savedSessionShouldStaySidebarOnly") + assert helper_idx > 0, "saved-running root policy helper not found" + helper = BOOT_JS[helper_idx:helper_idx + 1200] + assert "/api/session?session_id=" in helper, ( + "helper should inspect session metadata via /api/session before deciding" + ) + assert "messages=0" in helper, "helper must avoid loading full messages" + assert "resolve_model=0" in helper, "helper must avoid unnecessary model resolution" + assert "active_stream_id" in helper, "helper must treat active_stream_id as running" + assert "pending_user_message" in helper, "helper must treat pending_user_message as running" + assert "loadSession(" not in helper, ( + "helper must not call loadSession(), because that would already project " + "the saved session into the active pane" + ) + + +def test_root_saved_running_sidebar_only_path_renders_empty_state_and_sidebar(): + """Skipping projection should still leave the app usable and sidebar visible.""" + block = _boot_saved_session_block() + helper_pos = block.find("_savedSessionShouldStaySidebarOnly") + render_pos = block.find("await renderSessionList()", helper_pos) + empty_pos = block.find("$('emptyState').style.display=''", helper_pos) + return_pos = block.find("return;", helper_pos) + assert helper_pos >= 0, "saved-running helper call not found" + assert empty_pos > helper_pos, "sidebar-only path must show the empty state" + assert render_pos > helper_pos, "sidebar-only path must render the session list" + assert return_pos > render_pos, "sidebar-only path should return before loadSession(saved)" diff --git a/tests/test_session_cross_tab_sync.py b/tests/test_session_cross_tab_sync.py index 38cf81b2..419db30a 100644 --- a/tests/test_session_cross_tab_sync.py +++ b/tests/test_session_cross_tab_sync.py @@ -41,7 +41,9 @@ def test_session_switch_updates_url_path_for_tab_local_anchor(): def test_boot_prefers_url_session_over_local_storage_session(): assert "const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;" in BOOT_JS - assert "const saved=urlSession||localStorage.getItem('hermes-webui-session');" in BOOT_JS + assert "const savedLocal=localStorage.getItem('hermes-webui-session');" in BOOT_JS + assert "const saved=urlSession||savedLocal;" in BOOT_JS + assert "if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal))" in BOOT_JS def test_api_helper_resolves_against_document_base_not_session_path(): From 3e2a945501e7c239d946e3282debed4d86a3180e Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Wed, 6 May 2026 03:01:05 -0700 Subject: [PATCH 2/4] fix: repair stale OpenAI session models for Codex --- api/routes.py | 21 +++++ tests/test_provider_mismatch.py | 133 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/api/routes.py b/api/routes.py index 6f7254d6..4fe14df2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -934,6 +934,27 @@ def _resolve_compatible_session_model_state( # Skip normalization for models on custom/openrouter namespaces — these are # user-controlled and should never be silently replaced. + # + # OpenAI Codex is intentionally normalized to the OpenAI family above so bare + # GPT IDs survive provider switches. Slash-qualified OpenAI IDs are different: + # ``openai/gpt-...`` is the OpenRouter shape for OpenAI models, and + # resolve_model_provider() routes that through OpenRouter when Codex is the + # configured provider. Legacy sessions can carry that stale slash ID without + # a saved model_provider, so repair it to the active Codex default unless the + # session/request explicitly says it is an OpenRouter selection. (#1734) + if ( + raw_active_provider == "openai-codex" + and model_provider == "openai" + and requested_provider is None + and default_model + ): + provider_context = ( + raw_active_provider + if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog) + else None + ) + return default_model, provider_context, True + # Also normalize when the model is from a known provider but the active provider # is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case # but raw_active_provider is set. If model_provider doesn't start with the raw diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 2ef6a000..632bf0fe 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -794,6 +794,139 @@ def test_named_custom_provider_hint_with_colon_is_preserved(monkeypatch): assert effective == "@custom:sub2api:gpt-5.4-mini" +def test_issue1734_stale_openai_slash_session_model_repairs_to_codex(monkeypatch): + """Legacy openai/... session IDs must not route to OpenRouter when Codex is active.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + { + "provider": "OpenRouter", + "provider_id": "openrouter", + "models": [{"id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"}], + }, + ], + }, + ) + + effective, provider, changed = routes._resolve_compatible_session_model_state( + "openai/gpt-5.4-mini", + None, + ) + + assert changed is True + assert effective == "gpt-5.5" + assert provider == "openai-codex" + + +def test_issue1734_chat_start_persists_repaired_codex_provider(monkeypatch): + """/api/chat/start should save repaired Codex model state before spawning.""" + import contextlib + import io + import json + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI Codex", + "provider_id": "openai-codex", + "models": [{"id": "gpt-5.5", "label": "GPT-5.5"}], + }, + ], + }, + ) + + save_calls = [] + + class DummySession: + session_id = "issue1734_session" + workspace = "/tmp/hermes-webui-test" + model = "openai/gpt-5.4-mini" + model_provider = None + active_stream_id = None + pending_user_message = None + pending_attachments = [] + pending_started_at = None + messages = [{"role": "user", "content": "old"}] + context_messages = [] + + def save(self, touch_updated_at=True): + save_calls.append( + { + "touch_updated_at": touch_updated_at, + "model": self.model, + "model_provider": self.model_provider, + "pending_user_message": self.pending_user_message, + } + ) + + captured_thread = {} + + class FakeThread: + def __init__(self, target, args=(), kwargs=None, daemon=None): + captured_thread.update( + {"target": target, "args": args, "kwargs": kwargs or {}, "daemon": daemon} + ) + + def start(self): + captured_thread["started"] = True + + class FakeHandler: + def __init__(self): + self.wfile = io.BytesIO() + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + session = DummySession() + monkeypatch.setattr(routes, "get_session", lambda sid: session) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: value) + monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) + monkeypatch.setattr(routes.threading, "Thread", FakeThread) + + handler = FakeHandler() + routes._handle_chat_start( + handler, + {"session_id": session.session_id, "message": "new turn"}, + ) + payload = json.loads(handler.wfile.getvalue().decode("utf-8")) + + assert handler.status == 200 + assert payload["effective_model"] == "gpt-5.5" + assert payload["effective_model_provider"] == "openai-codex" + assert session.model == "gpt-5.5" + assert session.model_provider == "openai-codex" + assert captured_thread["args"][2] == "gpt-5.5" + assert captured_thread["kwargs"]["model_provider"] == "openai-codex" + assert save_calls[-1]["model_provider"] == "openai-codex" + + def test_stale_at_provider_model_falls_back_when_family_mismatches(monkeypatch): """Unroutable @provider:model should not invent a bare model for another family.""" import api.routes as routes From ec403fa3cf4118d2ca71e89a87bb83ff78db3387 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 6 May 2026 15:18:34 +0000 Subject: [PATCH 3/4] fix(routes): persist openai-codex provider unconditionally on stale-session repair (Opus stage-303 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus advisor on stage-303 (#1738 verification Q4) flagged that the catalog-coverage branch produces a redundant repair-write per chat-start when the active Codex default is itself slash-prefixed: the repair sets `provider_context = None`, the next chat-start hits the same branch because `requested_provider is None` again, and the repair fires repeatedly. In practice Codex `default_model` is always a bare `gpt-...` ID from the Codex catalog, so this is theoretical. But once we've decided this session belongs to Codex, we should persist that decision. Drop the conditional catalog-coverage check and unconditionally attach `raw_active_provider` ("openai-codex") on this repair path. The shape is now stable across resolutions. Absorb-in-release per Opus stage-303 verdict — small, defensive, ≤10 LOC. --- api/routes.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/api/routes.py b/api/routes.py index 4fe14df2..20bca779 100644 --- a/api/routes.py +++ b/api/routes.py @@ -948,12 +948,13 @@ def _resolve_compatible_session_model_state( and requested_provider is None and default_model ): - provider_context = ( - raw_active_provider - if _should_attach_codex_provider_context(default_model, raw_active_provider, catalog) - else None - ) - return default_model, provider_context, True + # Persist provider_context = "openai-codex" unconditionally on this + # repair path so the resolved shape is stable across resolutions + # (Opus stage-303 SHOULD-FIX: avoid redundant repair-writes per + # chat-start when the catalog-coverage check fails — e.g. if a + # future Codex default is itself slash-prefixed). Once we've + # decided the session belongs to Codex, persist that decision. + return default_model, raw_active_provider, True # Also normalize when the model is from a known provider but the active provider # is an unlisted one (e.g. ollama-cloud) — active_provider is "" in that case From 1b9c8c660cba1884f4cf3d2a25ed2bba166971f4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 6 May 2026 15:19:38 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20stamp=20v0.51.9=20?= =?UTF-8?q?=E2=80=94=202-PR=20batch=20(boot=20path=20+=20Codex=20session?= =?UTF-8?q?=20repair)=20+=20Opus=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constituent PRs: - #1735 (@dso2ng) — keep saved running sessions sidebar-only on root boot (slice of #1694) - #1738 (@Michaelyklam) — repair stale OpenAI session models for Codex (closes #1734) Plus 1 in-stage absorbed fix: - Opus SHOULD-FIX: persist openai-codex provider unconditionally on stale-session repair (drop conditional catalog-coverage check that produced redundant repair-writes per chat-start). Tests: 4584 → 4590 passing (+6). 0 regressions. Stably green. Pre-release verification: - Both PRs CI-green individually + rebased onto master - pytest 4590 passed, 0 failed - node -c clean on static/boot.js - 11/11 browser API endpoints PASS - Opus advisor: SHIP, 5/5 verification clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed Closes #1734. --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24dab63..7fc4dc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Hermes Web UI -- Changelog +## [v0.51.9] — 2026-05-06 — 2-PR full-sweep batch + +### Fixed + +- **PR #1735** by @dso2ng — Keep saved running sessions sidebar-only on root boot (slice of #1694). When a fresh root `/` tab restored a localStorage-saved last session and that session was still running (`active_stream_id` or `pending_user_message` present), the boot path projected the running session into the active pane and the new tab looked busy with another tab's stream. New `_savedSessionShouldStaySidebarOnly()` helper does a metadata-only `/api/session?messages=0&resolve_model=0` probe; if the saved session is running, root `/` boot leaves the pane empty/idle and refreshes the sidebar instead of calling `loadSession(savedLocal)`. Explicit `/session/` URL behavior unchanged — the gate is `!urlSession && savedLocal`. Probe failure fails open (legacy projecting behavior). 4 new regression tests + 1 cross-tab static-assertion scope-fix. +- **PR #1738** by @Michaelyklam — Repair stale OpenAI session models for Codex (closes #1734). Existing sessions with `model=openai/gpt-...` (OpenRouter shape) and no saved `model_provider` were being treated as compatible by `_resolve_compatible_session_model_state()` when the active provider was OpenAI Codex (both normalize to "openai" family), so they passed through. At runtime, `resolve_model_provider()` then interpreted that slash-qualified ID as an OpenRouter selection under Codex, producing a misleading provider-credential failure. New branch in `_resolve_compatible_session_model_state()` at `api/routes.py:937-955` repairs the legacy no-`model_provider` shape: when `raw_active_provider == "openai-codex" AND model_provider == "openai" AND requested_provider is None AND default_model`, swap the session to active Codex default and persist `model_provider="openai-codex"`. Explicit OpenRouter selections preserved by the line 838 early return + the `requested_provider is None` gate. + +### In-stage absorbed fixes + +**Opus-applied fix (absorbed in-release):** + +- **#1738 follow-up — persist openai-codex provider unconditionally on repair.** Opus stage-303 advisor flagged that the catalog-coverage branch produces a redundant repair-write per chat-start when the active Codex default is itself slash-prefixed (theoretical edge case — Codex defaults are bare `gpt-...` in practice). Drop the conditional `_should_attach_codex_provider_context` check and unconditionally attach `raw_active_provider` ("openai-codex") on this repair path. Once the session has been decided to belong to Codex, that decision is persisted so the same shape can't re-trigger the repair. + +### Tests + +4584 → **4590 passing** (+6 regression tests across the 2 PRs). 0 regressions. Full suite ~138s. Stably green across multiple clean runs. + +### Pre-release verification + +- Stage-303: 2 PRs merged with zero conflicts (each rebased clean onto current master). +- All JS files syntax-clean (`node -c static/boot.js`). +- All Python files syntax-clean. +- pytest: 4590 passed, 0 failed (verified across multiple runs). +- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789 with stage-303 binary. +- Pre-stamp re-fetch: both PR heads still match local rebases — no late contributor commits. +- Opus advisor: SHIP, 5/5 verification questions clean, 0 MUST-FIX, 1 SHOULD-FIX absorbed (Codex provider context unconditional persistence). + +Closes #1734. + ## [v0.51.8] — 2026-05-06 — 7-PR full-sweep batch ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 916dbfe5..969c1cc1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.8 (May 6, 2026) — 4584 tests collected — 7-PR full-sweep batch (#1725-1730, #1732) +> Last updated: v0.51.9 (May 6, 2026) — 4590 tests collected — 2-PR full-sweep batch (#1735, #1738) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 791299ad..0546e658 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.8, May 6, 2026* -*Total automated tests collected: 4584* +*Last updated: v0.51.9, May 6, 2026* +*Total automated tests collected: 4590* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /*