From 3abae9aca73c958b2d625cfbdcc520a41b346442 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sat, 2 May 2026 17:03:25 +0000 Subject: [PATCH] =?UTF-8?q?chore(release):=20stamp=20v0.50.267=20=E2=80=94?= =?UTF-8?q?=207=20contributor=20PR=20batch=20+=20Opus=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md: v0.50.267 entry detailing #1454/#1474/#1461/#1465/#1467/#1460/#1473 + Opus advisor SHOULD-FIX trailing-empty guard for _norm_model_id - ROADMAP.md: bump to v0.50.267, 3776 tests collected - TESTING.md: bump header + total to 3776 - api/config.py: trailing-empty fallback in _norm_model_id (parts[-1] or s) - static/ui.js: mirror trailing-empty fallback in _normalizeConfiguredModelKey - tests/test_norm_model_id_trailing_empty_guard.py: 5 regression tests --- CHANGELOG.md | 16 ++++ ROADMAP.md | 2 +- TESTING.md | 4 +- api/config.py | 14 ++-- static/ui.js | 8 +- ...test_norm_model_id_trailing_empty_guard.py | 80 +++++++++++++++++++ 6 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 tests/test_norm_model_id_trailing_empty_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba511a52..33fb3a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Hermes Web UI -- Changelog +## [v0.50.267] — 2026-05-02 + +### Fixed (contributor PR batch — 7 PRs) + +- **`_norm_model_id` strips multi-segment provider prefixes** (#1454, by @happy5318) — `s.split(':', 1)[1]` only stripped the first colon-separated segment, leaving `jingdong:GLM-5` un-normalized for `@custom:jingdong:GLM-5`-style IDs. Now uses `s.split(':')[-1]` (with a trailing-empty fallback to preserve distinct ids on malformed input). Same fix applied to the `/` branch. (`api/config.py`) +- **Frontend `_normalizeConfiguredModelKey` matches backend** (#1474, by @happy5318) — the JavaScript helper had the same one-segment-only bug as the Python helper. Mirror fix + trailing-empty fallback. Plus surface the configured-model provider name in the model dropdown badge (e.g. "Primary (jingdong)"). (`static/ui.js`) +- **`pushState` instead of `replaceState` for chat navigation** (#1461, by @JKJameson) — switching between chats wrote to the same browser-history entry, so the back button could not return to a prior chat. Now each chat-switch creates a new history entry. One-line change. (`static/sessions.js`) +- **Session rename: ondblclick handler + loading guard** (#1465, by @AlexeyDsov) — adds a native `ondblclick` handler as a fallback to the existing manual click-counter (which can miss double-taps when the click-delay racing setTimeout fires between pointerups), plus a guard preventing rename while the session is still loading. (`static/sessions.js`) +- **Reuse in-flight session stream on switch-back** (#1467, by @dso2ng) — `attachLiveStream()` now reuses the existing EventSource transport when (sessionId, streamId) match and the browser hasn't marked it CLOSED, instead of always tearing down and reopening. The server-side stream queue is not a replay log, so the close-and-reopen window dropped events that landed during the gap. 4 regression tests pin the invariants. (`static/messages.js`, `tests/test_inflight_stream_reuse.py`) +- **Handle 401 redirect gracefully in loadSession flow** (#1460, by @joaompfp) — when `api()` redirects to `/login` after the auth session expires (e.g. server restart), it returns `undefined`. Five callsites in `loadSession` / `_ensureMessagesLoaded` / `_loadOlderMessages` / `_ensureAllMessagesLoaded` / `_positionModelDropdown` now defensively check for undefined data and bail without state mutation. (`static/sessions.js`, `static/ui.js`) +- **Batch session actions + in-flight reload recovery** (#1473, by @Thanatos-Z) — fixed three regressions: (1) batch action bar rendered as an empty/global bottom bar with literal `{0}` placeholders because i18n placeholder substitution only ran for arrow-function values — `t()` now substitutes `{N}` placeholders at runtime for non-function values when args are passed; (2) batch project-picker dropped onto `document.body` orphaned itself on list re-render — now scoped to the action bar; (3) sessions with `active_stream_id` or `pending_user_message` set but `message_count=0` (mid-restore from in-flight reload) were filtered out of the sidebar — filter widened. 6 regression tests. (`static/boot.js`, `static/i18n.js`, `static/sessions.js`, `static/style.css`, `tests/test_session_batch_select.py`) + +### Defensive hardening (Opus pre-release follow-up) + +- **`_norm_model_id` trailing-empty fallback** — Opus advisor flagged a `SHOULD-FIX` edge case in #1454/#1474: malformed configured-model IDs ending in a colon or slash (`@custom:foo:bar:` or `provider/model/`) would `split('...')[-1]` to an empty string, collapsing distinct IDs to the same key in the configured-model badge filter. Both backend (`api/config.py:1513`) and frontend (`static/ui.js:524`) helpers now fall back to the original input when the last segment is empty (`parts[-1] or s` / `last || s`). 5 regression tests pin the guard, the clean multi-segment fix, and the frontend mirror. (`api/config.py`, `static/ui.js`, `tests/test_norm_model_id_trailing_empty_guard.py`) + ## [v0.50.266] — 2026-05-02 ### Fixed (i18n parity) diff --git a/ROADMAP.md b/ROADMAP.md index a0153324..3249d0c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Everything you can do from the CLI terminal, you can do from this UI. > -> Last updated: v0.50.266 (May 2, 2026) — 3753 tests collected +> Last updated: v0.50.267 (May 02, 2026) — 3776 tests collected > Tests: `pytest tests/ --collect-only -q` > Source: / diff --git a/TESTING.md b/TESTING.md index ca657721..80328e23 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.50.266, May 2, 2026* -*Total automated tests collected: 3753* +*Last updated: v0.50.267, May 02, 2026* +*Total automated tests collected: 3776* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/config.py b/api/config.py index fd40fc40..0d8e7aaf 100644 --- a/api/config.py +++ b/api/config.py @@ -1506,13 +1506,17 @@ def get_available_models() -> dict: def _norm_model_id(model_id: str) -> str: s = str(model_id or "").strip().lower() - # Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5) + # Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5). + # Defensive: if the last segment is empty (trailing colon, malformed + # config), keep the original to avoid collapsing distinct IDs to ''. if s.startswith("@") and ":" in s: - # Split on all colons and take the last part - s = s.split(":")[-1] - # Strip provider/model prefix (e.g., custom:jingdong/GLM-5 -> GLM-5) + parts = s.split(":") + s = parts[-1] or s + # Strip provider/model prefix (e.g., custom:jingdong/GLM-5 -> GLM-5). + # Same trailing-empty guard. if "/" in s: - s = s.split("/")[-1] + parts = s.split("/") + s = parts[-1] or s return s.replace("-", ".") def _build_configured_model_badges() -> dict[str, dict[str, str]]: diff --git a/static/ui.js b/static/ui.js index 2fd83efc..783aefc3 100644 --- a/static/ui.js +++ b/static/ui.js @@ -520,9 +520,11 @@ function _selectedModelOption(){ function _normalizeConfiguredModelKey(modelId){ let s=String(modelId||'').trim().toLowerCase(); - // Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5) - if(s.startsWith('@')&&s.includes(':')) s=s.split(':').pop(); - if(s.includes('/')) s=s.split('/').pop(); + // Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5). + // Defensive: trailing-colon / trailing-slash falls back to the original key + // so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id). + if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;} + if(s.includes('/')){const last=s.split('/').pop();s=last||s;} return s.replace(/-/g,'.'); } diff --git a/tests/test_norm_model_id_trailing_empty_guard.py b/tests/test_norm_model_id_trailing_empty_guard.py new file mode 100644 index 00000000..8ec23d16 --- /dev/null +++ b/tests/test_norm_model_id_trailing_empty_guard.py @@ -0,0 +1,80 @@ +"""Opus pre-release follow-up for stage-267: + +#1454/#1474 split(':')[-1] trailing-empty guard — when a malformed configured model id +has a trailing colon (e.g. `@custom:foo:bar:`), the new normalization would collapse +two distinct ids to the empty string. Defensive `parts[-1] or s` falls back to the +original input so distinct ids stay distinct in the configured-model badge filter. + +Mirrors: + api/config.py _norm_model_id + static/ui.js _normalizeConfiguredModelKey +""" +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _exec_norm(): + """Re-execute the _norm_model_id closure body via a synthetic def, returning the function.""" + # Extract source between `def _norm_model_id(model_id: str) -> str:` and the next `def _build_configured_model_badges` + start_marker = "def _norm_model_id(model_id: str) -> str:" + end_marker = "def _build_configured_model_badges" + s = CONFIG_PY.find(start_marker) + e = CONFIG_PY.find(end_marker, s) + assert s != -1 and e != -1 + body = CONFIG_PY[s:e] + # Dedent (it's nested 8 spaces inside a function) + lines = body.splitlines() + # Find first non-blank line indent + indent = None + for ln in lines: + if ln.strip(): + indent = len(ln) - len(ln.lstrip()) + break + dedented = "\n".join(ln[indent:] if len(ln) >= indent else ln for ln in lines) + ns = {} + exec(dedented, ns) + return ns["_norm_model_id"] + + +def test_norm_model_id_trailing_colon_keeps_original(): + """Malformed @provider: ids with trailing colon must not collapse to empty.""" + norm = _exec_norm() + # Trailing colon — last split segment is empty, must fall back to original + out = norm("@custom:foo:bar:") + assert out, f"trailing-colon collapsed to empty: {out!r}" + + +def test_norm_model_id_clean_multi_segment_strips_correctly(): + """Clean @custom:vendor:model still strips to last segment (the new fix's purpose).""" + norm = _exec_norm() + assert norm("@custom:jingdong:GLM-5") == "glm.5" + + +def test_norm_model_id_trailing_slash_keeps_original(): + """Same guard on the / branch — trailing slash must not collapse to empty.""" + norm = _exec_norm() + out = norm("custom/jingdong/") + assert out, f"trailing-slash collapsed to empty: {out!r}" + + +def test_norm_model_id_simple_inputs_unchanged(): + """Sanity: simple inputs round-trip as before.""" + norm = _exec_norm() + assert norm("gpt-4") == "gpt.4" + assert norm("provider/model-name") == "model.name" + assert norm("") == "" + assert norm(None) == "" + + +def test_ui_js_mirror_has_trailing_empty_guard(): + """Frontend _normalizeConfiguredModelKey must mirror the backend guard.""" + # The new pattern uses `const last=s.split(':').pop();s=last||s;` + assert "s.split(':').pop()" in UI_JS, "ui.js no longer uses split-pop pattern" + # Look for the `||s` fallback specifically + snippet = UI_JS[UI_JS.find("function _normalizeConfiguredModelKey"):UI_JS.find("function _normalizeConfiguredModelKey") + 600] + assert "last||s" in snippet, "ui.js missing trailing-empty guard `||s` fallback" + # And mirror on / branch + assert snippet.count("last||s") >= 2, "ui.js trailing-empty guard not mirrored on slash branch"