Merge pull request #1740 from nesquena/stage-303

v0.51.9 — 2-PR batch (boot path + Codex session repair)
This commit is contained in:
nesquena-hermes
2026-05-06 08:21:42 -07:00
committed by GitHub
8 changed files with 302 additions and 5 deletions
+29
View File
@@ -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/<sid>` 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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: <repo>/*
+22
View File
@@ -934,6 +934,28 @@ 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
):
# 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
# but raw_active_provider is set. If model_provider doesn't start with the raw
+21 -1
View File
@@ -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.
@@ -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/<sid>` 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/<sid>` 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)"
+133
View File
@@ -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
+3 -1
View File
@@ -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():