"""Stall-interrupted preflight compression must persist a durable backoff. #96775: an explicit /stop after the summary stream has already crossed the no-progress stall window restores the original transcript but must record a stall-specific cooldown so the next automatic turn does not re-enter the same strategy. An ordinary early /stop stays cooldown-neutral. Native Codex app-server compaction is a sibling path (#75364) and is not covered here. Stall classification reads the commit fence's progress clock, not Chat Completions vs Responses frame shapes. """ from __future__ import annotations import copy import os import time from pathlib import Path from unittest.mock import patch from agent.auxiliary_client import AuxiliaryExplicitCancellation from agent.conversation_compression import ( STALL_INTERRUPTED_FAILURE_CLASS, CompressionCommitFence, compress_context, compression_attempt_stalled, ) from hermes_state import SessionDB def _build_agent(tmp_path: Path, session_id: str = "STALL_INTERRUPT_96775"): db = SessionDB(db_path=tmp_path / "state.db") db.create_session(session_id, source="cli") with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): from run_agent import AIAgent agent = AIAgent( api_key="test-key", base_url="https://openrouter.ai/api/v1", model="test/model", quiet_mode=True, session_db=db, session_id=session_id, skip_context_files=True, skip_memory=True, ) agent._compression_feasibility_checked = True agent.compression_in_place = True agent._cached_system_prompt = "sys" agent.context_compressor.threshold_tokens = 1_000 return db, agent def _messages(): return [{"role": "user", "content": f"m{i}"} for i in range(20)] def _age_fence(fence: CompressionCommitFence, idle_seconds: float) -> None: fence._last_progress = time.monotonic() - float(idle_seconds) class TestStallClassificationIsFenceIdle: def test_fresh_fence_is_not_stalled(self): fence = CompressionCommitFence() assert compression_attempt_stalled( commit_fence=fence, started_at=time.monotonic(), idle_timeout_seconds=1.0, ) is False def test_idle_fence_is_stalled(self): fence = CompressionCommitFence() _age_fence(fence, 2.0) assert compression_attempt_stalled( commit_fence=fence, started_at=time.monotonic(), idle_timeout_seconds=1.0, ) is True def test_recent_progress_is_not_stalled(self): fence = CompressionCommitFence() _age_fence(fence, 2.0) fence.touch_progress() assert compression_attempt_stalled( commit_fence=fence, started_at=time.monotonic() - 5.0, idle_timeout_seconds=1.0, ) is False def test_chat_and_responses_share_the_fence_clock(self): """Transport-agnostic: both APIs tick the same fence or they don't.""" chat_fence = CompressionCommitFence() responses_fence = CompressionCommitFence() _age_fence(chat_fence, 2.0) responses_fence.touch_progress() assert compression_attempt_stalled( commit_fence=chat_fence, started_at=time.monotonic(), idle_timeout_seconds=1.0, ) is True assert compression_attempt_stalled( commit_fence=responses_fence, started_at=time.monotonic(), idle_timeout_seconds=1.0, ) is False class TestEarlyStopStaysNeutral: def test_explicit_interrupt_before_stall_does_not_arm_cooldown( self, tmp_path: Path ): db, agent = _build_agent(tmp_path, "EARLY_STOP_96775") original = _messages() live = copy.deepcopy(original) fence = CompressionCommitFence() def _early_stop(messages, **_kwargs): messages[0]["content"] = "must be rolled back" raise AuxiliaryExplicitCancellation() agent.context_compressor.compress = _early_stop compressed, _prompt = compress_context( agent, live, "sys", approx_tokens=50_000, commit_fence=fence, ) assert compressed == original assert live == original assert db.get_compression_lock_holder("EARLY_STOP_96775") is None assert db.get_compression_failure_cooldown("EARLY_STOP_96775") is None assert agent.context_compressor.should_compress(50_000) is True db.append_message("EARLY_STOP_96775", "assistant", "still writable") class TestStallInterruptedBackoff: def test_aux_explicit_cancel_after_stall_persists_backoff( self, tmp_path: Path, monkeypatch ): monkeypatch.setattr( "agent.conversation_compression.resolve_context_compression_timeouts", lambda compression_cfg=None: (1.0, 10.0), ) db, agent = _build_agent(tmp_path, "STALL_AUX_CANCEL") original = _messages() live = copy.deepcopy(original) fence = CompressionCommitFence() compress_calls = {"n": 0} def _stalled_then_stop(messages, **_kwargs): compress_calls["n"] += 1 _age_fence(fence, 2.0) messages[0]["content"] = "must be rolled back" raise AuxiliaryExplicitCancellation() agent.context_compressor.compress = _stalled_then_stop compressed, _prompt = compress_context( agent, live, "sys", approx_tokens=50_000, commit_fence=fence, ) assert compressed == original assert live == original assert db.get_compression_lock_holder("STALL_AUX_CANCEL") is None state = db.get_compression_failure_cooldown("STALL_AUX_CANCEL") assert state is not None assert STALL_INTERRUPTED_FAILURE_CLASS in str(state["error"]) assert "msgs=20" in str(state["error"]) assert agent.context_compressor.should_compress(50_000) is False compress_calls["n"] = 0 again, _ = compress_context( agent, copy.deepcopy(original), "sys", approx_tokens=50_000, commit_fence=CompressionCommitFence(), ) assert again == original assert compress_calls["n"] == 0 db.append_message("STALL_AUX_CANCEL", "assistant", "still writable") def test_commit_fence_cancel_after_stall_persists_backoff( self, tmp_path: Path, monkeypatch ): monkeypatch.setattr( "agent.conversation_compression.resolve_context_compression_timeouts", lambda compression_cfg=None: (1.0, 10.0), ) db, agent = _build_agent(tmp_path, "STALL_FENCE_CANCEL") original = _messages() live = copy.deepcopy(original) fence = CompressionCommitFence() def _summary_then_cancel(messages, **_kwargs): _age_fence(fence, 2.0) fence.cancel_before_commit() return [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "tail"}, ] agent.context_compressor.compress = _summary_then_cancel compressed, _prompt = compress_context( agent, live, "sys", approx_tokens=50_000, commit_fence=fence, ) assert compressed == original assert live == original state = db.get_compression_failure_cooldown("STALL_FENCE_CANCEL") assert state is not None assert STALL_INTERRUPTED_FAILURE_CLASS in str(state["error"]) assert agent.context_compressor.should_compress(50_000) is False db.append_message("STALL_FENCE_CANCEL", "user", "next turn") def test_commit_fence_cancel_with_fresh_progress_stays_neutral( self, tmp_path: Path, monkeypatch ): monkeypatch.setattr( "agent.conversation_compression.resolve_context_compression_timeouts", lambda compression_cfg=None: (1.0, 10.0), ) db, agent = _build_agent(tmp_path, "FRESH_FENCE_CANCEL") original = _messages() live = copy.deepcopy(original) fence = CompressionCommitFence() def _healthy_then_cancel(messages, **_kwargs): fence.touch_progress() fence.cancel_before_commit() return [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "tail"}, ] agent.context_compressor.compress = _healthy_then_cancel compressed, _prompt = compress_context( agent, live, "sys", approx_tokens=50_000, commit_fence=fence, ) assert compressed == original assert db.get_compression_failure_cooldown("FRESH_FENCE_CANCEL") is None assert agent.context_compressor.should_compress(50_000) is True def test_manual_force_bypasses_stall_backoff( self, tmp_path: Path, monkeypatch ): monkeypatch.setattr( "agent.conversation_compression.resolve_context_compression_timeouts", lambda compression_cfg=None: (1.0, 10.0), ) db, agent = _build_agent(tmp_path, "FORCE_BYPASS_STALL") original = _messages() fence = CompressionCommitFence() def _stalled_then_stop(messages, **_kwargs): _age_fence(fence, 2.0) raise AuxiliaryExplicitCancellation() agent.context_compressor.compress = _stalled_then_stop compress_context( agent, copy.deepcopy(original), "sys", approx_tokens=50_000, commit_fence=fence, ) assert agent.context_compressor.should_compress(50_000) is False forced_calls = {"n": 0} def _forced(messages, **kwargs): forced_calls["n"] += 1 assert kwargs.get("force") is True return messages agent.context_compressor.compress = _forced compress_context( agent, copy.deepcopy(original), "sys", approx_tokens=50_000, force=True, commit_fence=CompressionCommitFence(), ) assert forced_calls["n"] == 1 def test_stall_backoff_merges_with_longer_active_deadline( self, tmp_path: Path, monkeypatch ): monkeypatch.setattr( "agent.conversation_compression.resolve_context_compression_timeouts", lambda compression_cfg=None: (1.0, 10.0), ) db, agent = _build_agent(tmp_path, "MERGE_MAX_STALL") agent.context_compressor._record_compression_failure_cooldown( 900.0, "prior timeout" ) before = db.get_compression_failure_cooldown("MERGE_MAX_STALL") assert before is not None prior_until = float(before["cooldown_until"]) fence = CompressionCommitFence() def _stalled_then_stop(messages, **_kwargs): _age_fence(fence, 2.0) raise AuxiliaryExplicitCancellation() agent.context_compressor.compress = _stalled_then_stop # force=True so the pre-existing cooldown does not skip the attempt # before the stall-interrupt restore/merge path can run. compress_context( agent, _messages(), "sys", approx_tokens=50_000, force=True, commit_fence=fence, ) after = db.get_compression_failure_cooldown("MERGE_MAX_STALL") assert after is not None assert float(after["cooldown_until"]) >= prior_until - 1.0 assert STALL_INTERRUPTED_FAILURE_CLASS in str(after["error"]) def test_session_db_cooldown_write_does_not_shorten_longer_deadline( tmp_path: Path, ): db = SessionDB(db_path=tmp_path / "state.db") db.create_session("merge-max", source="cli") later = time.time() + 900.0 sooner = time.time() + 60.0 db.record_compression_failure_cooldown("merge-max", later, "timeout") db.record_compression_failure_cooldown( "merge-max", sooner, STALL_INTERRUPTED_FAILURE_CLASS ) row = db.get_compression_failure_cooldown("merge-max") assert row is not None assert float(row["cooldown_until"]) >= later - 1.0 assert row["error"] == STALL_INTERRUPTED_FAILURE_CLASS