mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
Merge pull request #1740 from nesquena/stage-303
v0.51.9 — 2-PR batch (boot path + Codex session repair)
This commit is contained in:
@@ -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
@@ -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
@@ -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>/*
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user