chore(release): stamp v0.50.267 — 7 contributor PR batch + Opus follow-up

- 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
This commit is contained in:
Hermes Bot committed 2026-05-02 17:03:25 +00:00
1 parent c517339bce
commit 3abae9aca7
6 files changed
+113 -11

No files matched your search

+16
View File
@@ -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)
+1 -1
View File
@@ -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: <repo>/
+2 -2
View File
@@ -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: <repo>/*
+9 -5
View File
@@ -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]]:
+5 -3
View File
@@ -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,'.');
}
@@ -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"