Merge pull request #2760 from nesquena/release/stage-405
Release CJ: v0.51.112 (stage-405 — 1-PR — session model authoritative across restore)
This commit is contained in:
6 files changed
+186
-21
No files matched your search
@@ -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
|
||||
|
||||
+25
-9
@@ -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.
|
||||
|
||||
+16
-3
@@ -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 = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
|
||||
}
|
||||
// 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){
|
||||
|
||||
+39
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user