diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f47ac7..0255bc7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.112] — 2026-05-22 — Release CJ (stage-405 — 1-PR — session model authoritative across restore) + +### Fixed + +- **PR #2737** by @ai-ag2026 — Keep the session model authoritative when a restored session is reactivated. Previously, stale browser-cached picker state could override an active conversation's model in four scenarios: (1) on initial boot when `localStorage` had a different model preference than the active session, (2) on hard refresh when `S._bootReady` revealed the composer chip before the live catalog hydrated, (3) when the session's model wasn't in the current provider catalog (the static/default fallback silently rewrote `S.session.model`), (4) when starting a new session whose model wasn't in the static HTML dropdown. The fix: `loadSession()` now requests `resolve_model=1` so backend normalization happens synchronously with metadata; boot model hydration prefers the active session over `localStorage`; hard refresh re-runs the model dropdown hydration before `_bootReady`; a new `_ensureModelOptionInDropdown()` helper injects a `data-custom='1'` option for models not in the catalog instead of silently rewriting `S.session.model` to the default. 100 LOC of new pytest regression coverage pinning each behavior. + ## [v0.51.111] — 2026-05-22 — Release CI (stage-404 — 1-PR — keep state.db replays out of sidecar tail) ### Fixed diff --git a/static/boot.js b/static/boot.js index 67aa84c4..2497df69 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1576,20 +1576,30 @@ function applyBotName(){ // options are enough for first paint; the dynamic provider list can settle // after the saved session is visible. const _hydrateBootModelDropdown=()=>populateModelDropdown().then(()=>{ + const sessionModelState=S.session&&S.session.model + ? {model:S.session.model,model_provider:S.session.model_provider||null} + : null; const savedState=(typeof _readPersistedModelState==='function') ? _readPersistedModelState() : (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null); - const savedModel=savedState&&savedState.model; - // Guardrail: prefer profile/server default on boot when available. - // Persisted browser model state should not override a valid default model. - const allowBootSavedModelOverride=!window._defaultModel; - if(savedModel && $('modelSelect') && allowBootSavedModelOverride){ + // Active sessions are authoritative. On fresh boot without a restored + // session, keep the profile/server default ahead of stale browser model + // state when a default exists. + const stateToApply=sessionModelState||(!window._defaultModel?savedState:null); + const savedModel=stateToApply&&stateToApply.model; + if(savedModel && $('modelSelect')){ const applied=(typeof _applyModelToDropdown==='function') - ? _applyModelToDropdown(savedModel,$('modelSelect'),savedState.model_provider||null) + ? (sessionModelState + ? _applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null) + : _applyModelToDropdown(savedState.model,$('modelSelect'),savedState.model_provider||null)) : null; - if(!applied) $('modelSelect').value=savedModel; - // If the value didn't take (model not in list), clear the bad pref - if(!applied&&$('modelSelect').value!==savedModel){ + if(!applied) $('modelSelect').value=stateToApply.model; + // If the value didn't take (model not in list), clear the bad pref only + // for persisted browser preferences. Active sessions remain authoritative. + if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function'){ + _ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null); + } + else if(!applied&&!sessionModelState&&$('modelSelect').value!==stateToApply.model){ if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); else { localStorage.removeItem('hermes-webui-model'); @@ -1644,6 +1654,12 @@ function applyBotName(){ return; } await loadSession(saved); + // Hard refresh starts from the static HTML model list. Hydrate the live + // catalog after the saved session is known, then re-apply that session's + // model before S._bootReady lets syncModelChip reveal the composer label. + // Otherwise the chip can display the static default (e.g. GPT-5.4 Mini) + // even though S.session already points at the Codex/current model. + if(S.session) await _startBootModelDropdown(); // 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. // loadSession() already ran, so loadDir() has populated the workspace file tree. diff --git a/static/sessions.js b/static/sessions.js index d864e0a6..d828862b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -490,8 +490,17 @@ async function newSession(flash, options={}){ const sessionProvider=S.session.model_provider||null; const currentProvider=currentModelState.model_provider||null; if(S.session.model!==modelSel.value || sessionProvider !== currentProvider){ - _applyModelToDropdown(S.session.model,modelSel,sessionProvider); - if(typeof syncModelChip==='function') syncModelChip(); + let sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider); + if(!sessionModelApplied){ + const opt=document.createElement('option'); + opt.value=S.session.model; + opt.textContent=typeof getModelLabel==='function'?getModelLabel(S.session.model):S.session.model; + opt.dataset.custom='1'; + opt.dataset.provider=sessionProvider||''; + modelSel.appendChild(opt); + sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider); + } + if(sessionModelApplied&&typeof syncModelChip==='function') syncModelChip(); } } // Reset per-session visual state: a fresh chat is idle even if another @@ -567,10 +576,14 @@ async function loadSession(sid){ if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '
Loading conversation...
'; } // Phase 1: Load metadata only (~1KB) for fast session switching. + // Resolve model immediately: old sessions can persist stale provider-shaped + // IDs (e.g. openai/gpt-5.4-mini) and assigning those to S.session creates a + // short race where the composer can display/send the wrong model before the + // deferred resolver catches up. // Guard against network/server failures to prevent a permanently stuck loading state. let data; try { - data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); + data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`); } catch(e) { const _msgInner = $('msgInner'); if(_msgInner){ diff --git a/static/ui.js b/static/ui.js index cab1ec06..e8bc9b8c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -869,6 +869,24 @@ function _applyModelToDropdown(modelId, sel, preferredProviderId){ } return null; } +function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){ + if(!modelId||!sel) return null; + const applied=_applyModelToDropdown(modelId,sel,preferredProviderId); + if(applied) return applied; + const opt=document.createElement('option'); + opt.value=modelId; + opt.textContent=typeof getModelLabel==='function'?getModelLabel(modelId):modelId; + opt.dataset.custom='1'; + const provider=preferredProviderId||_providerFromModelValue(modelId)||''; + if(provider) opt.dataset.provider=provider; + sel.appendChild(opt); + sel.value=modelId; + if(sel.id==='modelSelect'){ + if(typeof syncModelChip==='function') syncModelChip(); + _refreshOpenModelDropdown(); + } + return modelId; +} function _modelStateFromAppliedDropdown(sel, modelValue){ const state=(typeof _modelStateForSelect==='function') ? _modelStateForSelect(sel,modelValue) @@ -4813,8 +4831,9 @@ function syncTopbar(){ } } else { const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null); - // If the model isn't in the current provider list, reset to the configured - // default rather than silently retaining the previous chat's selection (#1771). + // If the session model is missing from the current provider list, inject + // a session-scoped option instead of displaying the previous/static + // selection. Only fall back if that repair path is unavailable. if(!applied){ const deferModelCorrection=Boolean(S.session._modelResolutionDeferred); const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null); @@ -4827,14 +4846,25 @@ function syncTopbar(){ // _addLiveModelsToSelect() will re-apply S.session.model once done (#1169). // Named custom providers/OpenRouter can also route vendor-prefixed IDs // outside the static catalog, so preserve the user's explicit choice. + if(typeof _ensureModelOptionInDropdown==='function'){ + const sessionOption=_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null); + if(sessionOption) currentModel=sessionOption; + } } else { - const fallback=_applySessionModelFallback(modelSel); - if(fallback&&!deferModelCorrection){ - S.session.model=fallback.model; - S.session.model_provider=fallback.model_provider||null; - currentModel=fallback.model; - // Persist the correction so the session doesn't re-inject on next load. - _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); + const sessionOption=(typeof _ensureModelOptionInDropdown==='function') + ? _ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null) + : null; + if(sessionOption){ + currentModel=sessionOption; + } else { + const fallback=_applySessionModelFallback(modelSel); + if(fallback&&!deferModelCorrection){ + S.session.model=fallback.model; + S.session.model_provider=fallback.model_provider||null; + currentModel=fallback.model; + // Persist the correction so the session doesn't re-inject on next load. + _persistSessionModelCorrection(fallback.model,S.session.model_provider||null); + } } } } diff --git a/tests/test_new_chat_default_model_frontend.py b/tests/test_new_chat_default_model_frontend.py index e12399bc..267c3105 100644 --- a/tests/test_new_chat_default_model_frontend.py +++ b/tests/test_new_chat_default_model_frontend.py @@ -35,6 +35,60 @@ def test_new_chat_syncs_model_picker_when_default_provider_changes_but_model_id_ assert "_applyModelToDropdown(S.session.model,modelSel,sessionProvider)" in fn +def test_new_chat_inserts_session_model_when_static_picker_lacks_default(): + fn = _new_session_function() + assert "sessionModelApplied" in fn + assert "document.createElement('option')" in fn + assert "opt.value=S.session.model" in fn + assert "opt.dataset.provider=sessionProvider||''" in fn + assert "modelSel.appendChild(opt)" in fn + + +def test_boot_model_hydration_prefers_active_session_over_persisted_model(): + boot_js = Path("static/boot.js").read_text(encoding="utf-8") + marker = "const sessionModelState=S.session&&S.session.model" + assert marker in boot_js + session_branch = boot_js[boot_js.index(marker) : boot_js.index("if(S.session) syncTopbar();", boot_js.index(marker))] + assert "_applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in session_branch + assert "savedState" in session_branch + assert session_branch.index("sessionModelState") < session_branch.index("savedState"), ( + "active session model must be considered before localStorage so stale saved model preferences cannot override new chats" + ) + + +def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip(): + boot_js = Path("static/boot.js").read_text(encoding="utf-8") + load_marker = "await loadSession(saved);" + assert load_marker in boot_js + saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index("await checkInflightOnBoot(saved);return;", boot_js.index(load_marker))] + assert "await _startBootModelDropdown();" in saved_restore + assert saved_restore.index("await _startBootModelDropdown();") > saved_restore.index(load_marker) + assert saved_restore.index("await _startBootModelDropdown();") < saved_restore.index("S._bootReady=true;"), ( + "hard refresh must hydrate/re-apply the active session model before S._bootReady lets syncModelChip display stale static HTML defaults" + ) + + +def test_hard_refresh_injects_missing_active_session_model_option(): + boot_js = Path("static/boot.js").read_text(encoding="utf-8") + marker = "if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function')" + assert marker in boot_js + branch = boot_js[boot_js.index(marker) : boot_js.index("else if(!applied&&!sessionModelState", boot_js.index(marker))] + assert "_ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in branch + + +def test_sync_topbar_preserves_missing_session_model_as_dropdown_option(): + ui_js = Path("static/ui.js").read_text(encoding="utf-8") + assert "function _ensureModelOptionInDropdown" in ui_js + sync_topbar = _extract_function(ui_js, "function syncTopbar") + branch_start = sync_topbar.index("const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null);") + session_model_branch = sync_topbar[branch_start:] + assert "_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)" in session_model_branch + assert "const fallback=_applySessionModelFallback(modelSel);" in session_model_branch + assert session_model_branch.index("_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)") < session_model_branch.index("const fallback=_applySessionModelFallback(modelSel);"), ( + "active session models missing from the current catalog must be injected before fallback can select the static/default model" + ) + + def test_new_chat_does_not_send_stale_dropdown_model_when_session_has_default_model(): assert "model:S.session.model||$('modelSelect').value" in MESSAGES_JS assert "model_provider:S.session.model_provider||null" in MESSAGES_JS diff --git a/tests/test_session_model_resolution_on_load.py b/tests/test_session_model_resolution_on_load.py new file mode 100644 index 00000000..fcb29157 --- /dev/null +++ b/tests/test_session_model_resolution_on_load.py @@ -0,0 +1,46 @@ +"""Regression tests for stale session model hydration in the WebUI. + +Old sessions can persist provider-shaped model IDs such as ``openai/gpt-5.4-mini`` +after the active runtime moved to OpenAI Codex ``gpt-5.5``. The first +``loadSession()`` metadata request must ask the backend for the resolved model so +that the composer state cannot briefly use the stale raw value for display or the +next chat-start payload. +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _extract_function(src: str, signature: str) -> str: + start = src.find(signature) + assert start >= 0, f"missing function signature: {signature}" + brace = src.find("{", start) + assert brace >= 0, f"missing function body for: {signature}" + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"unterminated function body for: {signature}") + + +def test_load_session_initial_metadata_request_resolves_model_before_state_assignment(): + body = _extract_function(SESSIONS_JS, "async function loadSession(sid") + metadata_fetch = "messages=0&resolve_model=1" + stale_metadata_fetch = "messages=0&resolve_model=0" + assignment = "S.session=data.session" + + assert metadata_fetch in body, ( + "loadSession() must resolve model metadata on the initial fetch so stale " + "persisted models like openai/gpt-5.4-mini cannot become active composer state" + ) + assert stale_metadata_fetch not in body[: body.index(assignment)], ( + "loadSession() must not assign S.session from unresolved metadata before the " + "backend has normalized stale model/provider combinations" + )