From c38ee6c3391449be716826e253b12763c38e671c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 7 May 2026 03:10:43 +0000 Subject: [PATCH] =?UTF-8?q?chore(release):=20stamp=20v0.51.16=20=E2=80=94?= =?UTF-8?q?=203-PR=20batch=20(#1768,=20#1778,=20#1779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constituent PRs: - #1768 (@franksong2702) serialize Anthropic env fallback reads. Closes #1736. - #1778 (@Michaelyklam) preserve CLI session tool metadata. Closes #1772. - #1779 (@Michaelyklam) reset model picker on session switch. Closes #1771. AUTO-FIX: Opus stage-310 caught a regression in the new !hasSessionModel branch — it dropped the deferModelCorrection guard that the parallel else-branch keeps. Fired spurious /api/session/update POSTs against imported/read-only CLI sessions whose model field reads 'unknown' (the exact surface #1778 introduces in this same release). Wrapped the new branch's _persistSessionModelCorrection call + state mutation in if(!deferModelCorrection). Added test_sync_topbar_does_not_persist_correction_while_model_resolution_deferred regression test covering both empty and 'unknown' fast-path interaction. Tests: 4694 → 4702 collected (+8). 4695 passed, 4 skipped, 3 xpassed, 0 failed in 141.29s. Pre-release verification: - All 3 PRs CI-green individually. - node -c clean on static/ui.js. - 11/11 browser API endpoints PASS. - Pre-stamp re-fetch: all PR heads match local rebases. - Opus advisor: SHIP #1768 + #1778, #1779 SHOULD-FIX before merge — auto-fix applied at stage with regression test, re-verified clean. Closes #1736, #1771, #1772. --- CHANGELOG.md | 24 ++++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 +-- static/ui.js | 19 +++++++++--- ...est_issue1771_session_model_switch_sync.py | 31 ++++++++++++++++++- 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 506b119e..c49ebc56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Hermes Web UI -- Changelog +## [v0.51.16] — 2026-05-07 — 3-PR contributor batch (anthropic env race close, CLI tool metadata, model picker reset) + +### Fixed + +- **PR #1768** by @franksong2702 — Serialize Anthropic env fallback reads (closes #1736, the architectural follow-up filed in v0.51.8 sweep). Wraps `_clear_anthropic_env_values()` and the runtime-provider resolver behind `_ENV_LOCK` (the same `threading.Lock` already serializing env save/restore in `streaming.py`). New helper `resolve_runtime_provider_with_anthropic_env_lock()` in `api/oauth.py` is called from 3 sites in `api/routes.py` and 2 in `api/streaming.py`. Opus stage-310 verified: same-lock not a new lock (no ordering risk), nested acquires are sequential not nested (no deadlock), the lock is released before the agent runs (chat throughput unaffected). `api/oauth.py +36`, `api/routes.py +18`, `api/streaming.py +16`, +52 LOC test coverage in `tests/test_issue1362_codex_oauth_onboarding.py`. Race window in `_clear_anthropic_env_values` now closed for the chat hot path; remaining detector-style polls in `api/config.py` are UI-only and never bypass real credentials. +- **PR #1778** by @Michaelyklam — Preserve CLI session tool metadata (closes #1772). The server's CLI session loader was reading only `role`, `content`, `timestamp` from `state.db.messages`, missing tool_calls/tool_results columns. `api/models.py +54` extends the loader to read those columns plus `reasoning_details`, `codex_reasoning_items`, `codex_message_items`, `reasoning_content`, `reasoning` and rehydrate them onto the message dicts. `PRAGMA table_info(messages)` check ensures legacy state.db schemas without the columns don't error. `_is_cli_tool_metadata_enrichment()` correctly rebuilds sidecars when message count is identical but new metadata is present, and uses `save(touch_updated_at=False)` to avoid bumping updated_at on passive enrichment. `api/routes.py +66`, 152 LOC test coverage in `tests/test_cli_session_tool_metadata.py` plus captured API evidence at `docs/pr-media/1772/cli-tool-metadata-api-evidence.json`. +- **PR #1779** by @Michaelyklam — Reset model picker on session switch (closes #1771). Bug: switching sessions silently kept the previous chat's model selected in the composer (could route an inexpensive chat to an expensive model unnoticed — high-impact for users on premium-credit OAuth providers). Fix in `static/ui.js +88/-29`: when session model metadata is missing, `unknown`, or stale, fall back to configured default model/provider, with first-available dropdown option only as last resort. **Auto-fix applied at stage**: Opus stage-310 caught a regression in the new `!hasSessionModel` branch — it dropped the `deferModelCorrection` guard that the parallel else-branch keeps. Without the guard, every fast-path session view of an empty/unknown-model session fired a spurious `/api/session/update` POST that raced `_resolveSessionModelForDisplaySoon` and silently wrote to imported/read-only CLI sessions whose model field reads `"unknown"` (#1778 introduces exactly that surface in this same release). Wrapped the new branch's `_persistSessionModelCorrection` call + state mutation in `if(!deferModelCorrection)` mirroring the else-branch. Added `test_sync_topbar_does_not_persist_correction_while_model_resolution_deferred` regression test that exercises the fast-path interaction with `_modelResolutionDeferred=true` for both empty and `"unknown"` model values; asserts the visible `sel.value` still updates for UX but no POST is issued and no state mutation occurs. 192 LOC of original regression coverage in `tests/test_issue1771_session_model_switch_sync.py` (now 215 LOC with the new test), 7 LOC tweak to `test_provider_mismatch.py` and 1 LOC to `test_session_metadata_fast_path.py` to align existing tests with the new fallback helper. + +### Tests + +4694 → **4702 collected** (+8 across 2 new test files plus 1 stage auto-fix regression test). 4695 passed, 4 skipped (2 dev-only spawn from v0.51.15 + 2 prong-2 noise), 3 xpassed, 0 failed in 141.29s. + +### Pre-release verification + +- All 3 PRs CI-green individually. +- File overlap on `api/routes.py` (#1768 + #1778) auto-merged cleanly (different functions: oauth env-lock helpers vs CLI session loader extension). +- `node -c` clean on `static/ui.js`; Python compile clean on all 6 changed .py files. +- pytest: 4695 passed, 0 failed. +- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789. +- Pre-stamp re-fetch: all 3 PR heads still match local rebases. +- Opus advisor: SHIP #1768 + #1778, #1779 SHOULD-FIX before merge — auto-fix applied at stage with regression test, re-verified clean. + +Closes #1736, #1771, #1772. + ## [v0.51.15] — 2026-05-07 — 4-PR contributor batch + 1 self-built (cron spawn migration, context menu, codex quota, model prefix) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 1121ff29..cea946e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.15 (May 7, 2026) — 4694 tests collected — 4-PR contributor batch + 1 self-built (#1762, #1767, #1769, #1770) +> Last updated: v0.51.16 (May 7, 2026) — 4702 tests collected — 3-PR contributor batch (#1768, #1778, #1779) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index 48d0d3ad..1c837924 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.15, May 7, 2026* -*Total automated tests collected: 4694* +*Last updated: v0.51.16, May 7, 2026* +*Total automated tests collected: 4702* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/static/ui.js b/static/ui.js index 3ad6b706..4ec9f498 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3711,10 +3711,21 @@ function syncTopbar(){ // first, then the first available option only as an HTML fallback. const fallback=_applySessionModelFallback(modelSel); if(fallback){ - S.session.model=fallback.model; - S.session.model_provider=fallback.model_provider||null; - currentModel=fallback.model; - _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); + // Defer state mutation + network write while the live model resolution + // is in flight — sessions.js sets _modelResolutionDeferred=true between + // the fast-path session render and the resolve_model=1 round-trip. + // Persisting here would race that resolution and would also issue + // silent /api/session/update POSTs against imported/read-only CLI + // sessions whose model field reads "unknown" (#1779 stage-310 review). + // The visible sel.value change still happens above for UX; only the + // state mutation + persist defers. + const deferModelCorrection=Boolean(S.session._modelResolutionDeferred); + if(!deferModelCorrection){ + S.session.model=fallback.model; + S.session.model_provider=fallback.model_provider||null; + currentModel=fallback.model; + _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); + } } } else { const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null); diff --git a/tests/test_issue1771_session_model_switch_sync.py b/tests/test_issue1771_session_model_switch_sync.py index 7384387d..4a028458 100644 --- a/tests/test_issue1771_session_model_switch_sync.py +++ b/tests/test_issue1771_session_model_switch_sync.py @@ -120,6 +120,7 @@ var S = { model: args.sessionModel, model_provider: args.sessionProvider || null, messages: [], + _modelResolutionDeferred: !!args.modelResolutionDeferred, }, messages: [], activeProfile: 'default', @@ -143,7 +144,7 @@ def driver_path(tmp_path_factory): return str(p) -def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5", default_model="@safe:gpt-4o-mini", dropdown_open=False): +def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5", default_model="@safe:gpt-4o-mini", dropdown_open=False, model_resolution_deferred=False): payload = { "sessionModel": session_model, "sessionProvider": None, @@ -151,6 +152,7 @@ def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5", "defaultModel": default_model, "activeProvider": "safe", "dropdownOpen": dropdown_open, + "modelResolutionDeferred": model_resolution_deferred, "options": [ {"provider": "expensive", "value": "@expensive:gpt-5.5", "label": "GPT-5.5"}, {"provider": "safe", "value": "@safe:gpt-4o-mini", "label": "GPT-4o mini"}, @@ -190,3 +192,30 @@ def test_sync_topbar_rerenders_open_visible_model_dropdown_after_session_model_c assert got["selectValue"] == "@safe:gpt-4o-mini" assert got["calls"]["renderModelDropdown"] >= 1 assert got["calls"]["positionModelDropdown"] >= 1 + + + +def test_sync_topbar_does_not_persist_correction_while_model_resolution_deferred(driver_path): + """Regression for stage-310 Opus review: the !hasSessionModel branch must + skip the network write + state mutation while sessions.js has set + _modelResolutionDeferred=true between the fast-path session render and + the resolve_model=1 round-trip. + + Without this guard, every fast-path session view of an empty/unknown-model + session fires a /api/session/update POST that races _resolveSessionModelForDisplaySoon + and thrashes imported/read-only CLI sessions whose model field reads "unknown" + (#1778 introduced exactly that surface in v0.51.16). + """ + got_empty = _run_sync(driver_path, session_model="", model_resolution_deferred=True) + # Visible UX still happens (sel.value gets the safe default) ... + assert got_empty["selectValue"] == "@safe:gpt-4o-mini" + # ... but session state is NOT mutated and NO POST is issued. + assert got_empty["sessionModel"] == "", "S.session.model must not be mutated while resolution is deferred" + update_calls = [c for c in got_empty["calls"]["fetches"] if "session" in c["url"] and "update" in c["url"]] + assert update_calls == [], f"no /api/session/update POSTs while deferred (got {update_calls})" + + got_unknown = _run_sync(driver_path, session_model="unknown", model_resolution_deferred=True) + assert got_unknown["selectValue"] == "@safe:gpt-4o-mini" + assert got_unknown["sessionModel"] == "unknown" + update_calls_u = [c for c in got_unknown["calls"]["fetches"] if "session" in c["url"] and "update" in c["url"]] + assert update_calls_u == [], "imported/read-only CLI session with model=unknown must not be silently written"