From fe55cf5b9eda12a0321b204ee52e6993cb53824f Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 13:14:34 +0800 Subject: [PATCH] Refresh session context metadata on model changes --- CHANGELOG.md | 4 + api/routes.py | 119 ++++++++++++------ static/boot.js | 30 ++++- static/sessions.js | 19 ++- ...t_issue1436_context_indicator_load_path.py | 104 ++++++++++++++- ..._issue1896_context_length_fallback_args.py | 19 ++- tests/test_session_metadata_fast_path.py | 15 +++ 7 files changed, 251 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..812aea51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Refresh context-window metadata when a session's resolved model changes during load or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Fixes #2442. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/routes.py b/api/routes.py index 677a6677..21bc8632 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1630,6 +1630,57 @@ def _resolve_effective_session_model_provider_for_display(session) -> str | None return provider +def _resolve_context_length_for_session_model( + model: str | None, + provider: str | None = None, +) -> int: + """Best-effort current context window for a session model. + + Persisted session context metadata is a snapshot from a prior model call. + During session hydration/model switching, the current model metadata should + be allowed to replace that stale snapshot. + """ + model_for_lookup = str(model or "").strip() + if not model_for_lookup: + return 0 + try: + from agent.model_metadata import get_model_context_length as _get_cl + from api.config import get_config as _get_config_for_cl + + _cfg_for_cl = _get_config_for_cl() + _cfg_ctx_len_load = None + _cfg_custom_providers_load = None + try: + _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {} + if isinstance(_model_cfg_load, dict): + _raw_cfg_ctx_load = _model_cfg_load.get('context_length') + if _raw_cfg_ctx_load is not None: + try: + _parsed_load = int(_raw_cfg_ctx_load) + if _parsed_load > 0: + _cfg_ctx_len_load = _parsed_load + except (TypeError, ValueError): + pass + _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None + if isinstance(_raw_cp_load, list): + _cfg_custom_providers_load = _raw_cp_load + except Exception: + pass + try: + return _get_cl( + model_for_lookup, + "", + config_context_length=_cfg_ctx_len_load, + provider=provider or "", + custom_providers=_cfg_custom_providers_load, + ) or 0 + except TypeError: + # Older hermes-agent builds: legacy 2-arg form. + return _get_cl(model_for_lookup, "") or 0 + except Exception: + return 0 + + def _session_model_state_from_request( model: str | None, requested_provider: str | None, @@ -3553,48 +3604,22 @@ def handle_get(handler, parsed) -> bool: # /api/session/get response — the same wrong-window display this # fix addresses on the streaming side. _persisted_cl = getattr(s, "context_length", 0) or 0 - if not _persisted_cl: + _threshold_tokens = getattr(s, "threshold_tokens", 0) or 0 + if (not _persisted_cl) or resolve_model: _model_for_lookup = ( - getattr(s, "model", "") or effective_model or "" + effective_model or getattr(s, "model", "") or "" ).strip() - if _model_for_lookup: - try: - from agent.model_metadata import get_model_context_length as _get_cl - from api.config import get_config as _get_config_for_cl - _cfg_for_cl = _get_config_for_cl() - _cfg_ctx_len_load = None - _cfg_custom_providers_load = None - try: - _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {} - if isinstance(_model_cfg_load, dict): - _raw_cfg_ctx_load = _model_cfg_load.get('context_length') - if _raw_cfg_ctx_load is not None: - try: - _parsed_load = int(_raw_cfg_ctx_load) - if _parsed_load > 0: - _cfg_ctx_len_load = _parsed_load - except (TypeError, ValueError): - pass - _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None - if isinstance(_raw_cp_load, list): - _cfg_custom_providers_load = _raw_cp_load - except Exception: - pass - try: - _fb_cl = _get_cl( - _model_for_lookup, - "", - config_context_length=_cfg_ctx_len_load, - provider=effective_provider or "", - custom_providers=_cfg_custom_providers_load, - ) or 0 - except TypeError: - # Older hermes-agent builds: legacy 2-arg form. - _fb_cl = _get_cl(_model_for_lookup, "") or 0 - if _fb_cl: - _persisted_cl = _fb_cl - except Exception: - pass + _fb_cl = _resolve_context_length_for_session_model( + _model_for_lookup, + effective_provider or getattr(s, "model_provider", None) or "", + ) + if _fb_cl: + if _persisted_cl and _fb_cl != _persisted_cl: + # The old threshold belongs to the old window. Hiding it + # is less misleading than rendering a stale compression + # threshold against a freshly resolved context length. + _threshold_tokens = 0 + _persisted_cl = _fb_cl _session_tool_calls = getattr(s, "tool_calls", []) if load_messages else [] if ( load_messages @@ -3613,7 +3638,7 @@ def handle_get(handler, parsed) -> bool: "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [], "pending_started_at": getattr(s, "pending_started_at", None), "context_length": _persisted_cl, - "threshold_tokens": getattr(s, "threshold_tokens", 0) or 0, + "threshold_tokens": _threshold_tokens, "last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0, } if original_stream_id: @@ -4638,6 +4663,8 @@ def handle_post(handler, parsed) -> bool: except KeyError: return bad(handler, "Session not found", 404) old_ws = getattr(s, "workspace", "") + old_model = getattr(s, "model", None) + old_provider = getattr(s, "model_provider", None) try: new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace))) except ValueError as e: @@ -4653,6 +4680,16 @@ def handle_post(handler, parsed) -> bool: if model is not None: s.model = model s.model_provider = provider + if ( + str(old_model or "") != str(getattr(s, "model", "") or "") + or str(old_provider or "") != str(getattr(s, "model_provider", "") or "") + ): + s.context_length = _resolve_context_length_for_session_model( + getattr(s, "model", None), + getattr(s, "model_provider", None), + ) + s.threshold_tokens = 0 + s.last_prompt_tokens = 0 s.save() if str(old_ws or "") != str(new_ws or ""): try: diff --git a/static/boot.js b/static/boot.js index d48123e1..9464f167 100644 --- a/static/boot.js +++ b/static/boot.js @@ -907,6 +907,25 @@ function clearPreview(opts={}){ } $('btnClearPreview').onclick=handleWorkspaceClose; // workspacePath click handler removed -- use topbar workspace chip dropdown instead +function _applySessionContextMetadataUpdate(data){ + if(!S.session||!data||!data.session)return; + S.session.context_length=data.session.context_length||0; + S.session.threshold_tokens=data.session.threshold_tokens||0; + S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; + if(typeof _syncCtxIndicator==='function'){ + const u=S.lastUsage||{}; + const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); + _syncCtxIndicator({ + input_tokens:_pick(u.input_tokens,S.session.input_tokens), + output_tokens:_pick(u.output_tokens,S.session.output_tokens), + estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost), + context_length:S.session.context_length||0, + last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), + threshold_tokens:S.session.threshold_tokens||0, + }); + } +} + $('modelSelect').onchange=async()=>{ if(!S.session)return; const selectedModel=$('modelSelect').value; @@ -916,7 +935,11 @@ $('modelSelect').onchange=async()=>{ if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{} - await api('/api/session/update',{method:'POST',body:JSON.stringify({ + // Clarify scope: composer model changes are session-local, not the global default. + if(typeof showToast==='function'){ + showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); + } + const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:S.session.workspace, model:modelState.model, @@ -926,10 +949,7 @@ $('modelSelect').onchange=async()=>{ S.session.model_provider=modelState.model_provider||null; if(typeof syncModelChip==='function') syncModelChip(); syncTopbar(); - // Clarify scope: composer model changes are session-local, not the global default. - if(typeof showToast==='function'){ - showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); - } + _applySessionContextMetadataUpdate(data); // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ const warn=_checkProviderMismatch(selectedModel); diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..0e15f267 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -707,9 +707,9 @@ async function loadSession(sid){ input_tokens: _pick(u.input_tokens, _s.input_tokens), output_tokens: _pick(u.output_tokens, _s.output_tokens), estimated_cost: _pick(u.estimated_cost, _s.estimated_cost), - context_length: _pick(u.context_length, _s.context_length), + context_length: _pick(_s.context_length, u.context_length), last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens), - threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens), + threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens), }); } if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession(); @@ -1102,8 +1102,23 @@ function _resolveSessionModelForDisplaySoon(sid){ if(!model||!S.session||S.session.session_id!==sid) return; S.session.model=model; S.session.model_provider=provider||null; + S.session.context_length=data.session.context_length||0; + S.session.threshold_tokens=data.session.threshold_tokens||0; + S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; S.session._modelResolutionDeferred=false; syncTopbar(); + if(typeof _syncCtxIndicator==='function'){ + const u=S.lastUsage||{}; + const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); + _syncCtxIndicator({ + input_tokens:_pick(u.input_tokens,S.session.input_tokens), + output_tokens:_pick(u.output_tokens,S.session.output_tokens), + estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost), + context_length:data.session.context_length||0, + last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), + threshold_tokens:data.session.threshold_tokens||0, + }); + } }catch(_){ // Keep session switching non-blocking; the next load can try again. } diff --git a/tests/test_issue1436_context_indicator_load_path.py b/tests/test_issue1436_context_indicator_load_path.py index 59a11b0e..608f616e 100644 --- a/tests/test_issue1436_context_indicator_load_path.py +++ b/tests/test_issue1436_context_indicator_load_path.py @@ -103,7 +103,7 @@ class TestIssue1436BackendFallback: return captured def test_persisted_context_length_passed_through_unchanged(self): - """When Session.context_length is non-zero, return it as-is (no fallback).""" + """Fast metadata loads keep the persisted value to avoid catalog work.""" s = self._stub_session(context_length=200_000, model="claude-sonnet-4.6") result = self._invoke_get_session(s, fallback_returns=999_999) body = result["data"]["session"] @@ -112,6 +112,94 @@ class TestIssue1436BackendFallback: f"got {body['context_length']}" ) + def test_resolved_model_load_refreshes_stale_persisted_context_length(self): + """The deferred resolve_model=1 load must refresh stale context metadata. + + Session switching first asks for messages=0&resolve_model=0 for speed, + then follows with messages=0&resolve_model=1 to hydrate the final + model/provider display. That second path is also where a stale + context_length from a prior model must be corrected; otherwise a + resumed DeepSeek 1M session can stay stuck on an old 200k window until + the user manually toggles models. + """ + import api.routes as routes + + captured = {} + + def fake_j(h, data, status=200): + captured["data"] = data + + fake_module = MagicMock() + fake_module.get_model_context_length = MagicMock(return_value=1_000_000) + + s = self._stub_session(context_length=200_000, model="deepseek-v4-pro") + handler = MagicMock() + parsed = urlparse( + "/api/session?session_id=test-1436&messages=0&resolve_model=1" + ) + + with patch("api.routes.get_session", return_value=s), \ + patch("api.routes.j", side_effect=fake_j), \ + patch.dict("sys.modules", {"agent.model_metadata": fake_module}): + routes.handle_get(handler, parsed) + + body = captured["data"]["session"] + assert body["context_length"] == 1_000_000, ( + "resolve_model=1 must refresh stale persisted context_length from " + "current model metadata" + ) + + def test_session_model_update_refreshes_context_metadata(self): + """Changing the session model must not keep the prior model's window.""" + import api.routes as routes + + captured = {} + + def fake_j(h, data, status=200): + captured["data"] = data + + fake_module = MagicMock() + fake_module.get_model_context_length = MagicMock(return_value=1_000_000) + + s = self._stub_session(context_length=200_000, model="old-model") + s.model_provider = "old-provider" + s.workspace = "/tmp" + s.threshold_tokens = 100_000 + s.last_prompt_tokens = 80_000 + s.save = MagicMock() + s.compact.return_value = { + **s.compact.return_value, + "model": "deepseek-v4-pro", + "model_provider": "deepseek", + "context_length": 1_000_000, + "threshold_tokens": 0, + "last_prompt_tokens": 0, + } + handler = MagicMock() + parsed = urlparse("/api/session/update") + + body = { + "session_id": "test-1436", + "workspace": "/tmp", + "model": "deepseek-v4-pro", + "model_provider": "deepseek", + } + with patch("api.routes._check_csrf", return_value=True), \ + patch("api.routes.read_body", return_value=body), \ + patch("api.routes.get_session", return_value=s), \ + patch("api.routes.resolve_trusted_workspace", return_value="/tmp"), \ + patch("api.routes.j", side_effect=fake_j), \ + patch.dict("sys.modules", {"agent.model_metadata": fake_module}): + routes.handle_post(handler, parsed) + + assert s.model == "deepseek-v4-pro" + assert s.model_provider == "deepseek" + assert s.context_length == 1_000_000 + assert s.threshold_tokens == 0 + assert s.last_prompt_tokens == 0 + s.save.assert_called_once() + assert captured["data"]["session"]["context_length"] == 1_000_000 + def test_zero_context_length_falls_back_to_model_metadata(self): """Pre-#1318 sessions with context_length=0 must resolve via model_metadata.""" s = self._stub_session(context_length=0, model="claude-opus-4-7", @@ -276,13 +364,19 @@ class TestIssue1436SourceMarkers: def test_routes_load_path_imports_get_model_context_length(self): src = ROUTES.read_text(encoding="utf-8") - # The import must appear inside the GET /api/session load-path block. + # The session load path can call a helper, but the lazy import must + # remain in routes.py so WebUI still works with older/missing agent + # bundles by swallowing metadata-resolution failures. start = src.find('if parsed.path == "/api/session":') end = src.find('if parsed.path == "/api/projects":', start) block = src[start:end] - assert "from agent.model_metadata import get_model_context_length" in block, ( - "GET /api/session load-path block must lazy-import " - "get_model_context_length for the context_length=0 fallback (#1436)" + assert "_resolve_context_length_for_session_model" in block, ( + "GET /api/session load-path block must resolve model context " + "metadata for the context_length fallback (#1436)" + ) + assert "from agent.model_metadata import get_model_context_length" in src, ( + "routes.py must lazy-import get_model_context_length for the " + "context_length fallback (#1436)" ) def test_routes_load_path_marks_fix_with_issue_number(self): diff --git a/tests/test_issue1896_context_length_fallback_args.py b/tests/test_issue1896_context_length_fallback_args.py index 9e2af74f..d22ba1e5 100644 --- a/tests/test_issue1896_context_length_fallback_args.py +++ b/tests/test_issue1896_context_length_fallback_args.py @@ -202,25 +202,32 @@ def test_routes_session_load_fallback_passes_config_overrides(): anchor = "older sessions (pre-#1318) that have context_length=0 persisted" idx = ROUTES_PY.find(anchor) assert idx != -1, "session-load fallback comment moved/removed" - # Find the resolver callsite that follows. + # The route block may delegate the resolver details to a helper, but the + # session-load path must still call the helper and that helper must preserve + # the same kwargs as the streaming.py fix. block_end = ROUTES_PY.find("if _fb_cl:", idx) assert block_end != -1, "_fb_cl assignment not found after fallback comment" block = ROUTES_PY[idx:block_end] + helper_start = ROUTES_PY.find("def _resolve_context_length_for_session_model") + assert helper_start != -1, "context-length resolver helper not found" + helper_end = ROUTES_PY.find("\ndef ", helper_start + 1) + helper = ROUTES_PY[helper_start:helper_end if helper_end != -1 else len(ROUTES_PY)] + assert "_resolve_context_length_for_session_model" in block # Same kwargs as the streaming.py fix. - assert "config_context_length=" in block, ( + assert "config_context_length=" in helper, ( "session-load fallback in api/routes.py must pass config_context_length= " "so user-set model.context_length wins over the 256K default. See #1896." ) - assert "provider=effective_provider" in block, ( - "session-load fallback in api/routes.py must pass provider=effective_provider " + assert "provider=provider or" in helper, ( + "session-load fallback in api/routes.py must pass provider= " "so the registry lookup is provider-aware. See #1896." ) - assert "custom_providers=" in block, ( + assert "custom_providers=" in helper, ( "session-load fallback in api/routes.py must pass custom_providers= " "so the per-model override path applies. See #1896." ) # Legacy fallback for older hermes-agent builds that pre-date the kwargs. - assert "except TypeError:" in block, ( + assert "except TypeError:" in helper, ( "session-load fallback must catch TypeError to support older " "hermes-agent builds without the new kwargs." ) diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index c4e5e719..b3c3bacc 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -42,6 +42,21 @@ def test_session_switch_defers_model_resolution_without_blocking(): assert "if(fallback&&!deferModelCorrection)" in ui +def test_deferred_model_resolution_refreshes_context_metadata(): + src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + start = src.index("function _resolveSessionModelForDisplaySoon") + end = src.index("const _INITIAL_MSG_LIMIT", start) + block = src[start:end] + + assert "S.session.context_length" in block, ( + "deferred model resolution must also hydrate context_length so a " + "resumed high-context session does not keep the old model's limit" + ) + assert "S.session.threshold_tokens" in block + assert "_syncCtxIndicator" in block + assert "context_length:data.session.context_length||0" in block.replace(" ", "") + + def test_boot_does_not_block_session_restore_on_model_catalog(): src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")