diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 78784d4c9..791c3ddb6 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -28,6 +28,7 @@ these paths see no behavioural change. from __future__ import annotations +import copy import inspect import logging import os @@ -1055,6 +1056,7 @@ def compress_context( engine_name, ) + messages_before_compression = copy.deepcopy(messages) compressed = compress_fn(messages, **compress_kwargs) except BaseException: # ANY exception after lock acquisition — memory hook, capability @@ -1097,10 +1099,13 @@ def compress_context( finally: _release_lock() - # A compressor that returns the exact input object made no structural - # progress. Do not rotate/rewrite the session or arm post-compression - # deferral in that case; its own anti-thrash counter records the no-op. - if compressed is messages: + # Compare against the pre-dispatch semantic state, not object identity: + # legacy/plugin engines may return an equal copy for a no-op, or mutate + # the live list while returning an unchanged snapshot. Neither case may + # rotate or rewrite the session. + if compressed == messages_before_compression: + if messages != messages_before_compression: + messages[:] = copy.deepcopy(messages_before_compression) logger.info( "Compression made no progress (session=%s) — skipping boundary rewrite.", agent.session_id or "none", diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py index 3405bcdfd..5296c67ec 100644 --- a/tests/agent/test_compression_concurrent_fork.py +++ b/tests/agent/test_compression_concurrent_fork.py @@ -371,6 +371,42 @@ def test_empty_compression_result_does_not_rotate_session(tmp_path: Path) -> Non assert db.get_session(parent_sid)["end_reason"] is None +@pytest.mark.parametrize("in_place", [False, True]) +def test_equal_copy_compression_result_does_not_rewrite_session( + tmp_path: Path, + in_place: bool, +) -> None: + db = SessionDB(db_path=tmp_path / "state.db") + parent_sid = f"EQUAL_COPY_NOOP_{in_place}" + db.create_session(parent_sid, source="cli") + + agent = _build_agent_with_db(db, parent_sid) + setattr(agent, "compression_in_place", in_place) + messages = [{"role": "user", "content": f"m{i}"} for i in range(20)] + compressor = getattr(agent, "context_compressor") + compressor.compress.side_effect = lambda incoming, **_kw: list(incoming) + + with patch.object( + db, + "archive_and_compact", + wraps=db.archive_and_compact, + ) as archive_and_compact: + returned, _sp = agent._compress_context( + messages, + "sys", + approx_tokens=120_000, + ) + + assert returned is messages + assert getattr(agent, "session_id") == parent_sid + assert _count_children(db, parent_sid) == 0 + parent = db.get_session(parent_sid) + assert parent is not None + assert parent["end_reason"] is None + assert db.get_compression_lock_holder(parent_sid) is None + archive_and_compact.assert_not_called() + + def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypatch) -> None: """The owning compression call must keep its lease alive while it runs.""" real_try_acquire = SessionDB.try_acquire_compression_lock