Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7534,6 +7534,13 @@ async def _session_expiry_watcher(self, interval: int = 300):
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)
Expand Down Expand Up @@ -10517,6 +10524,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
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
Expand Down
7 changes: 7 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3633,6 +3633,13 @@ async def _list_titled_sessions() -> list[dict]:
_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
Expand Down
46 changes: 46 additions & 0 deletions tests/gateway/test_10710_auto_reset_evicts_cached_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
)
29 changes: 29 additions & 0 deletions tests/gateway/test_resume_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,35 @@ async def test_resume_clears_session_model_overrides(self, tmp_path):
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."""
Expand Down
78 changes: 78 additions & 0 deletions tests/gateway/test_session_boundary_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,81 @@ def _hook_and_stop(*a, **kw):
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"
)
Loading