diff --git a/gateway/run.py b/gateway/run.py index c91130e9495..53562b4f483 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7534,6 +7534,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._set_session_reasoning_override(key, None) if hasattr(self, "_pending_model_notes"): self._pending_model_notes.pop(key, None) + # Clear per-session model cache so a resumed turn + # resolves from current config, not a stale fallback + # cached before the session went idle (mirrors /new + # and the compression-exhausted auto-reset, #58403). + _lrm = getattr(self, "_last_resolved_model", None) + if _lrm is not None: + _lrm.pop(key, None) _pending_approvals = getattr(self, "_pending_approvals", None) if isinstance(_pending_approvals, dict): _pending_approvals.pop(key, None) @@ -10538,6 +10545,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._set_session_reasoning_override(session_key, None) if hasattr(self, "_pending_model_notes"): self._pending_model_notes.pop(session_key, None) + # Clear per-session model cache so the fresh session resolves + # from current config, not a stale fallback cached before the + # auto-reset (mirrors /new and the compression-exhausted + # auto-reset, #58403). + _lrm = getattr(self, "_last_resolved_model", None) + if _lrm is not None: + _lrm.pop(session_key, None) # Evict the cached agent so the fresh session does not inherit the # previous conversation's context_compressor._previous_summary — # the cache is keyed on the stable session_key, so an auto-reset diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index a41fed13384..14687e07dde 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3632,6 +3632,13 @@ class GatewaySlashCommandsMixin: _pending_notes = getattr(self, "_pending_model_notes", None) if isinstance(_pending_notes, dict): _pending_notes.pop(session_key, None) + # Clear per-session model cache too, for the same reason — the + # resumed conversation must resolve from current config, not a + # stale value cached under this session_key before the switch + # (mirrors /new and the compression-exhausted auto-reset, #58403). + _lrm = getattr(self, "_last_resolved_model", None) + if isinstance(_lrm, dict): + _lrm.pop(session_key, None) # Evict any cached agent for this session so the next message # rebuilds with the correct session_id end-to-end — mirrors diff --git a/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py b/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py index 05e5dea2cad..7af17ce1dad 100644 --- a/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py +++ b/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py @@ -90,3 +90,49 @@ def test_evict_cached_agent_method_exists(): "GatewayRunner._evict_cached_agent is the helper the auto-reset " "cleanup depends on (#10710)." ) + + +def _references_name(node: ast.AST, literal: str) -> bool: + """True if a string constant equal to ``literal`` appears anywhere under ``node``.""" + return any( + isinstance(n, ast.Constant) and n.value == literal for n in ast.walk(node) + ) + + +def test_auto_reset_cleanup_clears_last_resolved_model(): + """Regression test for #58403. + + The auto-reset cleanup block (daily/idle/suspended, fingerprinted by + `_set_session_reasoning_override` + `was_auto_reset = False`) already + pops `_session_model_overrides` and `_pending_model_notes` — the same + "full conversation boundary" treatment /new and the compression-exhausted + auto-reset give `_last_resolved_model`. Without also popping + `_last_resolved_model` here, the fresh auto-reset session could serve a + model cached before the reset on a transient config-cache miss. + """ + tree = ast.parse(inspect.getsource(gateway_run)) + + found = False + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + calls = _calls(node) + if ( + "_set_session_reasoning_override" in calls + and _assigns_false(node, "was_auto_reset") + ): + assert _references_name(node, "_last_resolved_model") and "pop" in _calls( + node + ), ( + "gateway/run.py auto-reset cleanup block must also pop the " + "session's entry from `_last_resolved_model`, mirroring the " + "existing `_session_model_overrides`/`_pending_model_notes` " + "pops in the same block (#58403)." + ) + found = True + break + assert found, ( + "could not locate the auto-reset transient-state cleanup block in " + "gateway/run.py (fingerprint: _set_session_reasoning_override + " + "was_auto_reset = False)." + ) diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index dfa065bc3a8..b7f08713b1d 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -211,6 +211,35 @@ class TestHandleResumeCommand: assert runner._pending_model_notes["agent:main:telegram:dm:other"] == "[Note: keep-me]" db.close() + @pytest.mark.asyncio + async def test_resume_clears_last_resolved_model(self, tmp_path): + """Resume must also clear the resumed chat's cached last-resolved + model, so the restored conversation re-resolves from current config + instead of a value cached before the switch (mirrors /new and the + compression-exhausted auto-reset, #58403), while leaving other + chats' cache entries intact.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("old_session_abc", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("old_session_abc", "My Project") + db.create_session("current_session_001", "telegram", user_id="12345", chat_id="67890") + + event = _make_event(text="/resume My Project") + runner = _make_runner(session_db=db, current_session_id="current_session_001", + event=event) + key = _session_key_for_event(event) + runner._last_resolved_model = { + key: "gpt-5", + "agent:main:telegram:dm:other": "keep-me", + } + + result = await runner._handle_resume_command(event) + + assert "Resumed" in result + assert key not in runner._last_resolved_model + assert runner._last_resolved_model["agent:main:telegram:dm:other"] == "keep-me" + db.close() + @pytest.mark.asyncio async def test_resume_nonexistent_name(self, tmp_path): """Returns error for unknown session name.""" diff --git a/tests/gateway/test_session_boundary_hooks.py b/tests/gateway/test_session_boundary_hooks.py index 9831e636c20..58eb449adf9 100644 --- a/tests/gateway/test_session_boundary_hooks.py +++ b/tests/gateway/test_session_boundary_hooks.py @@ -253,3 +253,81 @@ async def test_idle_expiry_fires_finalize_hook(mock_invoke_hook): f"on_session_finalize was not fired during idle expiry; " f"got session_ids={session_ids} (regression of #14981)" ) + + +@pytest.mark.asyncio +@patch("hermes_cli.plugins.invoke_hook") +async def test_idle_expiry_clears_last_resolved_model(mock_invoke_hook): + """Regression test for #58403. + + ``_session_expiry_watcher`` permanently finalizes an expired session and + already drops ``_session_model_overrides`` / the reasoning override / + ``_pending_model_notes`` — a resumed conversation must not inherit stale + per-session state. It missed ``_last_resolved_model``: without clearing + it, a resumed session could serve a cached model from before it went + idle on a transient config-cache miss, exactly the #58403 class the + /new and compression-exhausted-reset paths already guard against. + """ + from datetime import datetime, timedelta + + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._running_agents = {} + runner._agent_cache = {} + runner._agent_cache_lock = None + runner._last_session_store_prune_ts = 0.0 + + session_key = "agent:main:telegram:dm:42" + expired_entry = SessionEntry( + session_key=session_key, + session_id="sess-expired", + created_at=datetime.now() - timedelta(hours=2), + updated_at=datetime.now() - timedelta(hours=2), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + expired_entry.expiry_finalized = False + + runner.session_store = MagicMock() + runner.session_store._ensure_loaded = MagicMock() + runner.session_store._entries = {session_key: expired_entry} + runner.session_store._is_session_expired = MagicMock(return_value=True) + runner.session_store._lock = MagicMock() + runner.session_store._lock.__enter__ = MagicMock(return_value=None) + runner.session_store._lock.__exit__ = MagicMock(return_value=None) + runner.session_store._save = MagicMock() + + runner._evict_cached_agent = MagicMock() + runner._cleanup_agent_resources = MagicMock() + runner._sweep_idle_cached_agents = MagicMock(return_value=0) + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._last_resolved_model = { + session_key: "gpt-5", + "agent:main:telegram:dm:other": "keep-me", + } + + _orig_sleep = __import__("asyncio").sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + def _hook_and_stop(*a, **kw): + runner._running = False + return None + + mock_invoke_hook.side_effect = _hook_and_stop + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await runner._session_expiry_watcher(interval=0) + + assert session_key not in runner._last_resolved_model, ( + "session-expiry finalization did not clear the expired session's " + "_last_resolved_model entry (#58403)" + ) + assert runner._last_resolved_model["agent:main:telegram:dm:other"] == "keep-me", ( + "session-expiry finalization must only clear the expired session's " + "own key, not unrelated sessions' cached entries" + )