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:
1 parent
c517339bce
commit
3abae9aca7
6 files changed
+113
-11
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Reference in new issue
Block a user