"""Idle-triggered compaction: interaction with the compression guards. The idle trigger (``agent/turn_context.py``, opt-in via ``compression.idle_compact_after_seconds``) does not compress anything itself — it routes through ``agent._compress_context`` (the forwarder to ``agent.conversation_compression.compress_context``), which owns ALL of the automatic-compaction guards: - the per-session compression lock (added after the idle-compaction PR was written — an idle-triggered compress racing a turn-triggered one on the same session_id must be serialized, not forked), - the persisted summary-failure cooldown, - the anti-thrash / fallback-streak breaker. These tests pin that composition end-to-end with a real ``AIAgent`` wired to a real ``SessionDB``: when a guard says no, the idle path must be a strict no-op for the turn (no compressor call, no session rotation, no flush re-baseline, no user-message re-anchor). """ from __future__ import annotations import time import types from pathlib import Path from unittest.mock import MagicMock, patch from hermes_state import SessionDB from agent.turn_context import build_turn_context from tests.agent.test_compression_concurrent_fork import _build_agent_with_db def _prep_idle_agent(db: SessionDB, session_id: str, *, idle_after: int = 60, idle_gap: float = 3600.0): """Real AIAgent (mock compressor) primed so the idle trigger is eligible.""" agent = _build_agent_with_db(db, session_id) agent.compression_enabled = True agent.compression_idle_compact_after_seconds = idle_after agent._last_activity_ts = time.time() - idle_gap # The idle block reads these from the compressor; give the MagicMock real # numbers so the floor computation and the preflight gate behave. agent.context_compressor.threshold_tokens = 100_000 agent.context_compressor.summary_target_ratio = 0.20 agent.context_compressor.protect_first_n = 2 agent.context_compressor.protect_last_n = 2 # No active failure cooldown unless a test installs one. agent.context_compressor.get_active_compression_failure_cooldown = ( lambda *a, **k: None ) agent._cached_system_prompt = "SYSTEM" return agent def _run_prologue(agent, history, user_message="hello again"): """Invoke ``build_turn_context`` the way ``conversation_loop`` does. The token-threshold preflight gate is pinned False so these tests exercise the IDLE trigger in isolation (the preflight path has its own coverage in ``test_turn_context.py``). """ with patch("agent.auxiliary_client.set_runtime_main", lambda *a, **k: None), \ patch("agent.turn_context._should_run_preflight_estimate", return_value=False), \ patch("agent.turn_context.estimate_request_tokens_rough", return_value=999_999): return build_turn_context( agent=agent, user_message=user_message, system_message=None, conversation_history=history, task_id=None, stream_callback=None, persist_user_message=None, restore_or_build_system_prompt=lambda *a, **k: None, install_safe_stdio=lambda: None, sanitize_surrogates=lambda s: s, summarize_user_message_for_log=lambda s: str(s), set_session_context=lambda _sid: None, set_current_write_origin=lambda _o: None, ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *a, **k: None), ) def _history(n: int = 20) -> list: return [{"role": "user", "content": f"m{i}"} for i in range(n)] def test_idle_compaction_runs_through_guarded_path_and_releases_lock( tmp_path: Path, ) -> None: """Happy path: the idle trigger reaches the real ``compress_context``. It must acquire + release the per-session lock, invoke the compressor exactly once, and (rotation mode) rotate the session — proving the trigger is wired through the guarded entrypoint rather than calling the compressor directly. """ db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_HAPPY" db.create_session(sid, source="cli") agent = _prep_idle_agent(db, sid) ctx = _run_prologue(agent, _history()) agent.context_compressor.compress.assert_called_once() # Rotation mode (in_place=False in the shared fixture) creates a child. assert agent.session_id != sid # The lock keyed on the OLD session id must not leak. assert db.get_compression_lock_holder(sid) is None # The turn continues on the compacted transcript, with the user-message # anchor pointing at a live user row in the rebuilt list. assert 0 <= ctx.current_turn_user_idx < len(ctx.messages) assert ctx.messages[ctx.current_turn_user_idx].get("role") == "user" def test_idle_compaction_status_suppressed_when_engine_opts_out( tmp_path: Path, ) -> None: """Quiet context engines silence the 💤 idle-resume status line too. The idle emit routes through ``automatic_compaction_status_message`` (phase="idle") the same way the preflight and pre-API emits do — an engine with ``emit_automatic_compaction_status = False`` gets a fully silent idle compaction, while the compaction itself still runs. """ db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_QUIET" db.create_session(sid, source="cli") agent = _prep_idle_agent(db, sid) agent.context_compressor.emit_automatic_compaction_status = False events = [] agent.status_callback = lambda ev, msg: events.append((ev, msg)) _run_prologue(agent, _history()) agent.context_compressor.compress.assert_called_once() assert not any( "Resumed after" in str(msg) for _ev, msg in events ), f"idle status leaked despite quiet engine: {events}" def test_idle_compaction_status_emitted_by_default(tmp_path: Path) -> None: """Control: the default engine keeps the 💤 idle-resume status line.""" db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_LOUD" db.create_session(sid, source="cli") agent = _prep_idle_agent(db, sid) # MagicMock would auto-create the hook attributes as truthy mocks; pin # the default-engine surface explicitly. agent.context_compressor.emit_automatic_compaction_status = True del agent.context_compressor.get_automatic_compaction_status_message events = [] agent.status_callback = lambda ev, msg: events.append((ev, msg)) _run_prologue(agent, _history()) agent.context_compressor.compress.assert_called_once() assert any( ev == "lifecycle" and "Resumed after" in str(msg) for ev, msg in events ), f"expected idle status line, got: {events}" def test_idle_compaction_defers_to_held_compression_lock(tmp_path: Path) -> None: """An idle-triggered compress racing another path must sit the round out. The per-session lock landed after the idle-compaction PR: when another path (turn-triggered preflight, background-review fork) already holds the lock, ``compress_context`` returns the input list unchanged. The idle block must treat that skip as a strict no-op: no compressor call, no rotation, no flush re-baseline, anchor untouched. """ db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_LOCKED" db.create_session(sid, source="cli") assert db.try_acquire_compression_lock(sid, "external_holder") is True agent = _prep_idle_agent(db, sid) history = _history() ctx = _run_prologue(agent, history) # Skipped: the compressor never ran and the session did not rotate. agent.context_compressor.compress.assert_not_called() assert agent.session_id == sid # The external holder still owns the lock (we must not have stolen or # released someone else's lease). assert db.get_compression_lock_holder(sid) == "external_holder" # Turn state untouched: full history + this turn's user message, anchor on # the just-appended message, flush baseline not re-baselined to None-then- # doubled semantics. assert len(ctx.messages) == len(history) + 1 assert ctx.current_turn_user_idx == len(ctx.messages) - 1 assert ctx.messages[ctx.current_turn_user_idx]["content"] == "hello again" def test_idle_compaction_respects_anti_thrash_breaker(tmp_path: Path) -> None: """A tripped ineffective-compression breaker must block the idle trigger. The breaker lives in ``ContextCompressor._automatic_compression_blocked`` and is consulted by ``compress_context`` for every non-forced entrypoint. The idle path is an automatic entrypoint, so two prior ineffective compactions must silence it too. """ from agent.context_compressor import ContextCompressor db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_THRASH" db.create_session(sid, source="cli") agent = _prep_idle_agent(db, sid) with patch( "agent.context_compressor.get_model_context_length", return_value=100_000 ): compressor = ContextCompressor( model="test/model", threshold_percent=0.85, protect_first_n=2, protect_last_n=2, quiet_mode=True, ) compressor.bind_session_state(db, sid) # Trip the breaker durably (#54923: the strike counter now round-trips # state.db, and the gate re-reads durable rows before honoring a block). db.set_compression_ineffective_count(sid, 2) compressor._ineffective_compression_count = 2 # breaker tripped compressor.compress = MagicMock() agent.context_compressor = compressor ctx = _run_prologue(agent, _history()) compressor.compress.assert_not_called() assert agent.session_id == sid assert len(ctx.messages) == len(_history()) + 1 def test_idle_compaction_respects_persisted_failure_cooldown( tmp_path: Path, ) -> None: """An active summary-failure cooldown must gate the idle trigger up front. The idle predicate itself consults ``get_active_compression_failure_cooldown`` — with a persisted cooldown in state.db the trigger must not even reach ``_compress_context``. """ from agent.context_compressor import ContextCompressor db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_COOLDOWN" db.create_session(sid, source="cli") db.record_compression_failure_cooldown(sid, 4_000_000_000.0, "timeout") agent = _prep_idle_agent(db, sid) with patch( "agent.context_compressor.get_model_context_length", return_value=100_000 ): compressor = ContextCompressor( model="test/model", threshold_percent=0.85, protect_first_n=2, protect_last_n=2, quiet_mode=True, ) compressor.bind_session_state(db, sid) compressor.compress = MagicMock() agent.context_compressor = compressor agent._compress_context = MagicMock() ctx = _run_prologue(agent, _history()) agent._compress_context.assert_not_called() compressor.compress.assert_not_called() assert agent.session_id == sid assert len(ctx.messages) == len(_history()) + 1 def test_idle_compaction_disabled_by_default(tmp_path: Path) -> None: """With the default config (0) a huge idle gap must never trigger.""" db = SessionDB(db_path=tmp_path / "state.db") sid = "IDLE_OFF" db.create_session(sid, source="cli") agent = _prep_idle_agent(db, sid, idle_after=0, idle_gap=10_000_000.0) agent._compress_context = MagicMock() ctx = _run_prologue(agent, _history()) agent._compress_context.assert_not_called() agent.context_compressor.compress.assert_not_called() assert agent.session_id == sid assert len(ctx.messages) == len(_history()) + 1