"""Compression rotation hardening — state-loss fixes at the compaction boundary. When auto-compression rotates ``agent.session_id`` to a continuation child, three pieces of state used to be lost or corrupted: * #33618 — a persistent ``/goal`` did not follow the rotation (``load_goal`` is a flat per-session lookup with no lineage walk), so it silently died. * #33906/#33907 — if the child ``create_session`` raised, the outer handler only warned and let the agent continue on the NEW (un-indexed) id, producing an orphan session missing from state.db. * #27633 — the compaction-boundary ``on_session_start`` notification omitted the ``platform`` kwarg, so context-engine plugins saw ``source=unknown`` for every message after the boundary. These tests drive the real ``compress_context`` path against a real SessionDB. """ from __future__ import annotations import copy import os import time from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest from agent.context_compressor import ContextCompressor, _DB_PERSISTED_MARKER from agent.conversation_compression import ( CompressionCommitFence, _is_real_user_message, ) from hermes_state import SessionDB def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = "telegram"): 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", platform=platform, quiet_mode=True, session_db=db, session_id=session_id, skip_context_files=True, skip_memory=True, ) compressor = MagicMock() compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "user", "content": "tail"}, ] compressor.compression_count = 1 compressor.last_prompt_tokens = 0 compressor.last_completion_tokens = 0 compressor._last_summary_error = None compressor._last_compress_aborted = False compressor._last_summary_auth_failure = False compressor._last_aux_model_failure_model = None compressor._last_aux_model_failure_error = None agent.context_compressor = compressor # ROTATION fallback path — pin in_place=False so these keep covering fork # rotation regardless of the global default (flipped to True in #38763). agent.compression_in_place = False return agent def _msgs(n=20): return [{"role": "user", "content": f"m{i}"} for i in range(n)] def _count_rows(rows, *, content: Any = None, role: str | None = None): return sum( 1 for row in rows if (content is None or row.get("content") == content) and (role is None or row.get("role") == role) ) def _bound_context_compressor(db: SessionDB, session_id: str) -> ContextCompressor: 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, session_id) return compressor @pytest.fixture def refresh_state_db(tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") try: yield db finally: db.close() class TestGoalMigratesOnRotation: def test_goal_follows_compression_rotation(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_GOAL_ROT" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) # Set a persistent goal on the parent via the real persistence path. with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): (tmp_path / ".hermes").mkdir(exist_ok=True) import hermes_cli.goals as goals goals._DB_CACHE.clear() # Point the goal DB at the same state.db the agent uses. with patch.object(goals, "_get_session_db", return_value=db): goals.save_goal(parent, goals.GoalState(goal="finish the migration")) agent._compress_context(_msgs(), "sys", approx_tokens=120_000) child = agent.session_id assert child != parent # rotation happened migrated = goals.load_goal(child) assert migrated is not None assert migrated.goal == "finish the migration" goals._DB_CACHE.clear() class TestOrphanRollbackOnCreateFailure: def test_rolls_back_to_parent_when_child_create_fails(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ORPHAN_ROT" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) # Atomic publication failure must leave the live parent and caller's # original list untouched even when a plugin compressor mutates in place. original = _msgs() def _mutating_compress(live_messages, **_kwargs): live_messages[:] = [ {"role": "user", "content": "mutated compacted snapshot"} ] return live_messages agent.context_compressor.compress.side_effect = _mutating_compress def _boom(*a, **k): raise RuntimeError("simulated atomic publication failure") with patch.object(db, "publish_compression_child", side_effect=_boom): returned, _system_prompt = agent._compress_context( original, "sys", approx_tokens=120_000 ) assert agent.session_id == parent assert [(m["role"], m["content"]) for m in returned] == [ (m["role"], m["content"]) for m in _msgs() ] assert returned is original parent_row = db.get_session(parent) assert parent_row is not None assert parent_row["ended_at"] is None assert db.find_live_compression_child(parent) is None class TestWorkspaceMetadataFollowsRotation: def test_child_row_inherits_cwd_repo_and_origin_on_rotation(self, tmp_path: Path): """Behavioral #64709/#59527: drive the REAL compression rotation path and assert the child session row carries the parent's workspace and gateway-origin metadata, so the project sidebar entry and the peer routing mapping both survive the compaction boundary.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_CWD_ROT" db.create_session( parent, source="telegram", user_id="u1", session_key="telegram:u1:c1", chat_id="c1", chat_type="private", ) db.update_session_cwd( parent, "/work/repo", git_branch="main", git_repo_root="/work/repo" ) agent = _build_agent_with_db(db, parent, platform="telegram") agent._compress_context(_msgs(), "sys", approx_tokens=120_000) child = agent.session_id assert child != parent # rotation happened row = db.get_session(child) assert row is not None assert row["parent_session_id"] == parent # Workspace metadata (#64709): sidebar grouping keys must survive. assert row["cwd"] == "/work/repo" assert row["git_repo_root"] == "/work/repo" assert row["git_branch"] == "main" # Gateway origin metadata (#59527): routing keys must survive even if # the gateway never gets to re-record the peer (crash window). assert row["session_key"] == "telegram:u1:c1" assert row["chat_id"] == "c1" assert row["chat_type"] == "private" assert row["user_id"] == "u1" class TestRotationChildFlushDedup: def test_summary_handoff_row_is_persisted_once_in_child( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_LIVE_USER" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [*loaded, {"role": "user", "content": "live question"}] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ {"role": "assistant", "content": "[CONTEXT COMPACTION] summary"}, ] returned, _ = agent._compress_context(messages, "sys", approx_tokens=120_000) assert any( isinstance(msg, dict) and msg.get("content") == "[CONTEXT COMPACTION] summary" and msg.get(_DB_PERSISTED_MARKER) for msg in returned ) assert any( isinstance(msg, dict) and msg.get("content") == "live question" and msg.get(_DB_PERSISTED_MARKER) for msg in returned ) def test_rotation_flush_of_original_live_list_keeps_user_once_when_handoff_already_contains_user( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_ORIGINAL_LIVE" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) live_user = { "role": "user", "content": "live question", "timestamp": 1234.5, } messages = [*loaded, live_user] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ {"role": "assistant", "content": "[CONTEXT COMPACTION] summary"}, copy.deepcopy(live_user), ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000 ) assert agent.session_id != parent assert _DB_PERSISTED_MARKER in live_user real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows(child_rows, content="live question", role="user") == 1 assert _count_rows( child_rows, content="[CONTEXT COMPACTION] summary", role="assistant" ) == 1 def test_failed_publish_leaves_live_user_unmarked_for_later_flush( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_PUBLISH_FAIL" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [*loaded, {"role": "user", "content": "live question"}] live_user = messages[-1] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "tail"}, ] real_flush = agent._flush_messages_to_session_db with patch.object( db, "publish_compression_child", side_effect=RuntimeError("simulated publish failure"), ): returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000 ) assert _DB_PERSISTED_MARKER not in live_user retry_session = "PARENT_ROT_PUBLISH_RETRY" db.create_session(retry_session, source="cli") agent.session_id = retry_session real_flush([live_user]) retry_rows = db.get_messages_as_conversation( retry_session, include_inactive=True ) assert _count_rows(retry_rows, content="live question", role="user") == 1 def test_mid_tool_loop_rows_do_not_duplicate_after_failed_parent_flush( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_TOOL_LOOP" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) assistant_turn = { "role": "assistant", "content": "", "tool_calls": [ { "id": "call-1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}, } ], } tool_turn = { "role": "tool", "tool_call_id": "call-1", "content": "tool result", } messages = [ *loaded, {"role": "user", "content": "live tool question"}, assistant_turn, tool_turn, ] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(loaded) agent.context_compressor.compress.return_value = [ copy.deepcopy(assistant_turn), copy.deepcopy(tool_turn), ] with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000 ) agent._flush_messages_to_session_db(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows( child_rows, content="live tool question", role="user" ) == 1 assert _count_rows(child_rows, content="tool result", role="tool") == 1 def test_mid_tool_loop_rows_do_not_duplicate_after_failed_parent_flush_direct_path( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_TOOL_LOOP_DIRECT" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) assistant_turn = { "role": "assistant", "content": "", "tool_calls": [ { "id": "call-1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}, } ], } tool_turn = { "role": "tool", "tool_call_id": "call-1", "content": "tool result", } messages = [ *loaded, {"role": "user", "content": "live tool question"}, assistant_turn, tool_turn, ] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(loaded) agent.context_compressor.compress.return_value = [ copy.deepcopy(assistant_turn), copy.deepcopy(tool_turn), ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) assert agent.session_id != parent real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows( child_rows, content="live tool question", role="user" ) == 1 assert _count_rows(child_rows, content="", role="assistant") == 1 assert _count_rows(child_rows, content="tool result", role="tool") == 1 def test_timestampless_duplicate_content_rows_are_all_stamped( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_DUPLICATE_CONTENT" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) assistant_turn = { "role": "assistant", "content": "", "tool_calls": [ { "id": "call-1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}, } ], } tool_turn = { "role": "tool", "tool_call_id": "call-1", "content": "tool result", } messages = [ *loaded, {"role": "user", "content": "live tool question"}, assistant_turn, copy.deepcopy(assistant_turn), tool_turn, copy.deepcopy(tool_turn), ] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(loaded) agent.context_compressor.compress.return_value = [ copy.deepcopy(assistant_turn), copy.deepcopy(tool_turn), ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows( child_rows, content="live tool question", role="user" ) == 1 assert _count_rows(child_rows, content="", role="assistant") == 1 assert _count_rows(child_rows, content="tool result", role="tool") == 1 def test_rotation_stamps_diverged_session_messages_entry_only_when_it_matches( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_SESSION_MESSAGES_DIVERGE" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [*loaded, {"role": "user", "content": "live question"}] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent._session_messages = [ *loaded, {"role": "user", "content": "different live question"}, ] agent.context_compressor.compress.return_value = [ {"role": "assistant", "content": "[CONTEXT COMPACTION] summary"}, ] returned, _ = agent._compress_context(messages, "sys", approx_tokens=120_000) agent._flush_messages_to_session_db(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows(child_rows, content="live question", role="user") == 1 assert _DB_PERSISTED_MARKER in messages[-1] assert _DB_PERSISTED_MARKER not in agent._session_messages[-1] # ------------------------------------------------------------------ # Item 2 review fixes — symmetric identity validation on the primary # stamp. The guard stamps the anchor-source row (the last real user # message in `messages`, the row the published child actually # represents), NEVER an index that may have drifted, and mirrors the # twin (`_session_messages`) by scoped identity against that anchor # source with a marker-independent exact-hit two-phase scan. # ------------------------------------------------------------------ def test_rotation_never_stamps_drifted_user_role_neighbor( self, tmp_path: Path ): """A user-role neighbor at a drifted index must not be stamped.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_DRIFTED_NEIGHBOR" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [ *loaded, {"role": "user", "content": "live question"}, { "role": "user", "content": "drifted neighbor", "_todo_snapshot_synthetic": True, }, ] agent = _build_agent_with_db(db, parent) # Index drifted onto the synthetic user-role neighbor (the reanchor # fallback / stale-index failure shape the guard must not trust). agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ {"role": "assistant", "content": "[CONTEXT COMPACTION] summary"}, ] with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) # The drifted neighbor is not the row the child represents. assert _DB_PERSISTED_MARKER not in messages[-1] # The anchor source (the real live question) is stamped. assert _DB_PERSISTED_MARKER in messages[-2] def test_rotation_drifted_index_does_not_duplicate_live_question_in_child( self, tmp_path: Path ): """Merged outcome + drifted index: real flush must not re-append the live question standalone (the duplicate the PR eliminates).""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_DRIFTED_MERGED" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [ *loaded, {"role": "user", "content": "live question"}, { "role": "user", "content": "drifted neighbor", "_todo_snapshot_synthetic": True, }, ] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ { "role": "user", "content": "handoff scaffolding", "_todo_snapshot_synthetic": True, }, ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) # No standalone "live question" row — the merged handoff already # represents it. assert _count_rows(child_rows, content="live question") == 0 assert ( _count_rows( child_rows, content="live question\n\nhandoff scaffolding" ) == 1 ) def test_merged_outcome_still_stamps_live_question(self, tmp_path: Path): """Constraint: the guard must not break the legitimate merged stamp.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_MERGED_LIVE" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [*loaded, {"role": "user", "content": "live question"}] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ { "role": "user", "content": "scaffolding", "_todo_snapshot_synthetic": True, }, ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) assert _DB_PERSISTED_MARKER in messages[-1] real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert _count_rows( child_rows, content="live question", role="user" ) == 0 assert ( _count_rows(child_rows, content="live question\n\nscaffolding") == 1 ) def test_adoption_divergence_merged_stamps_both_views_and_no_duplicate( self, tmp_path: Path ): """REAL adoption divergence: durable parent grows under the lease, `messages` rebinds to the adopted snapshot while `agent._session_messages` stays on the old live list, and the merged handoff cannot be mirrored by the wrapper's scoped sync. Both views must carry the marker and the real post-rotation flush over the old live view must not re-append the live question standalone.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_ADOPT_DIVERGE" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") # (b) Old live list object kept alive; divergence set so the twin is # the SAME object the guard scans (not a fresh copy). loaded = db.get_messages_as_conversation(parent, include_inactive=True) old_live_list = [ *loaded, {"role": "user", "content": "live question", "timestamp": 1234.5}, ] agent = _build_agent_with_db(db, parent) agent._session_messages = old_live_list assert agent._session_messages is old_live_list # (c) The stale snapshot passed to _compress_context is a separate # object (the production frontend-snapshot shape). stale_snapshot = [ {"role": "user", "content": "persisted question"}, {"role": "assistant", "content": "persisted answer"}, ] assert stale_snapshot is not agent._session_messages # (d) Pin the initial persist-index state: production "no known # un-persisted tail" shape, so the real code takes the adopt-directly # branch (:2994-3001) and the pre-adoption flush (:2988) is provably # never attempted (no fixture flush can mask the divergence). agent._persist_user_message_idx = None assert agent._persist_user_message_idx is None # Grow the DB AFTER the snapshot is taken so the REAL adoption # condition (durable parent longer than the caller snapshot) fires. db.append_message(parent, "user", "live question") durable_check = db.get_messages_as_conversation(parent) assert len(durable_check) == 3 > len(stale_snapshot) == 2 # Sync the twin's timestamp to the committed row so the guard's # exact-timestamp twin scan matches the adopted anchor. old_live_list[-1]["timestamp"] = durable_check[-1]["timestamp"] agent.context_compressor.compress.return_value = [ { "role": "user", "content": "handoff scaffolding", "_todo_snapshot_synthetic": True, }, ] # Phase-keyed flush failure: fail ONLY the pre-publish flush (:3780); # a blanket failure would not distinguish the phases and a masked # pre-adoption flush would hide the divergence. flush_attempts = [] def _fail_only_prepublish_flush(messages_arg, **kwargs): flush_attempts.append((messages_arg, kwargs)) raise RuntimeError("simulated pre-publish flush failure") real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=_fail_only_prepublish_flush, ): _returned, _ = agent._compress_context( stale_snapshot, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) # The ONLY internal flush was the single pre-publish one. assert len(flush_attempts) == 1 # (e) Identity and shape asserts BEFORE markers: adoption fired, the # divergence is preserved, the persist index was rebound out of range. adopted = agent.context_compressor.compress.call_args.args[0] assert adopted is not stale_snapshot assert adopted is not agent._session_messages assert agent._session_messages is old_live_list assert agent._persist_user_message_idx == len(adopted) assert adopted[-1]["role"] == "user" assert adopted[-1]["content"] == "live question" assert adopted[-1].get("timestamp") is not None assert old_live_list[-1]["content"] == "live question" assert ( old_live_list[-1].get("timestamp") == adopted[-1].get("timestamp") ) # (f) Markers on BOTH views. Note: the adopted view's rows are # "born durable" (hermes_state stamps _DB_PERSISTED_MARKER on rows # materialized from the DB), so the adopted assert holds even # pre-fix; the DISCRIMINATING assert is the twin's — the old live # view is a constructed list the production code only stamps via # the guard's twin scan (pre-fix it stays unstamped → FAIL). assert _DB_PERSISTED_MARKER in adopted[-1] assert _DB_PERSISTED_MARKER in old_live_list[-1] real_flush(agent._session_messages, conversation_history=loaded) # (g) No standalone duplicate by EXACT SCOPED IDENTITY (content + # timestamp), and exactly one merged row. child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert not any( row.get("content") == "live question" and row.get("timestamp") == adopted[-1].get("timestamp") for row in child_rows ) assert ( _count_rows( child_rows, content="live question\n\nhandoff scaffolding" ) == 1 ) def test_rotation_stamps_anchor_source_when_reanchor_fallback_rewrote_turn( self, tmp_path: Path ): """REAL reanchor drift: reanchor_current_turn_user_idx's last-user fallback lands on a trailing production-shaped todo-snapshot row (index 2) while the anchor-source scan selects the rewritten carrier (index 1). The stamp must land on the carrier, not the todo row.""" from agent.turn_context import reanchor_current_turn_user_idx db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_REANCHOR_DRIFT" db.create_session(parent, source="cli") db.append_message(parent, "user", "old durable") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) # Pinned production-shaped fixture (plan §3.5): the trailing row is # the todo-snapshot shape compress_context appends at :3484-3489. # The reanchor helper's last-user-originated fallback lands on it # (index 2) while the anchor-source scan skips it (synthetic flag) # and selects the rewritten carrier (index 1) — the drift is real. # Deliberately a 3-row fixture (NOT prefixed with `loaded`): the # pinned drift values below were verified against the real # reanchor_current_turn_user_idx on this shape. messages = [ {"role": "user", "content": "old durable"}, {"role": "user", "content": "current ask\n\n[merged summary]"}, { "role": "user", "content": "Current todos:\n- [ ] leftover", "_todo_snapshot_synthetic": True, }, ] # Drift-first assertions: prove the reanchor index and the anchor # source diverge BEFORE any stamp behavior is checked. drifted = reanchor_current_turn_user_idx(messages, "current ask") anchor_source = max( i for i, m in enumerate(messages) if _is_real_user_message(m) ) assert drifted == 2 assert anchor_source == 1 assert drifted != anchor_source agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = drifted agent.context_compressor.compress.return_value = [ { "role": "user", "content": "handoff scaffolding", "_todo_snapshot_synthetic": True, }, ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) # The carrier (anchor source) is stamped; the todo-snapshot row the # drifted index points at is NOT. assert _DB_PERSISTED_MARKER in messages[1] assert _DB_PERSISTED_MARKER not in messages[2] real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) assert ( _count_rows( child_rows, content="current ask\n\n[merged summary]", role="user", ) == 0 ) assert ( _count_rows( child_rows, content=( "current ask\n\n[merged summary]\n\nhandoff scaffolding" ), ) == 1 ) def test_no_real_user_anchor_guard_not_entered(self, tmp_path: Path): """Negative regression: placeholder_appended/already_present must not enter the anchor-source guard branch — no exception, rotation happens, no live row outside the handoff carries the marker.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_NO_REAL_ANCHOR" db.create_session(parent, source="cli") # All-user-synthetic transcript with NO real user. The rows carry # enough content that compression shrinks the transcript (a single # short synthetic row trips the would-grow gate and aborts rotation, # which would make this a fixture failure, not a regression). messages = [ { "role": "user", "content": f"synthetic scaffolding block {i} with enough " f"content to keep the compressed transcript smaller", "_todo_snapshot_synthetic": True, } for i in range(6) ] agent = _build_agent_with_db(db, parent) agent.context_compressor.compress.return_value = [ {"role": "assistant", "content": "[CONTEXT COMPACTION] summary"}, ] with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) # Rotation happened; no live row carries the marker. assert agent.session_id != parent assert _DB_PERSISTED_MARKER not in messages[0] def test_list_content_merged_outcome_still_stamps_live_question( self, tmp_path: Path ): """Constraint (reviewer list-content requirement): list-content anchor merged via the list branch (anchor_parts + target_parts) must still stamp the live row and not duplicate it.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_LIST_MERGED" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [ *loaded, { "role": "user", "content": [{"type": "text", "text": "live question"}], }, ] agent = _build_agent_with_db(db, parent) agent._persist_user_message_idx = len(messages) - 1 agent.context_compressor.compress.return_value = [ { "role": "user", "content": [{"type": "text", "text": "scaffolding"}], "_todo_snapshot_synthetic": True, }, ] real_flush = agent._flush_messages_to_session_db with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) assert _DB_PERSISTED_MARKER in messages[-1] real_flush(messages, conversation_history=loaded) child_rows = db.get_messages_as_conversation( agent.session_id, include_inactive=True ) # No standalone live-question row (the flush stores list content # flattened to its text join, so match the flattened string too). assert _count_rows(child_rows, content="live question") == 0 assert ( _count_rows( child_rows, content=[{"type": "text", "text": "live question"}], ) == 0 ) # Exactly one merged row with the concatenated parts list. assert ( _count_rows( child_rows, content=[ {"type": "text", "text": "live question"}, {"type": "text", "text": "scaffolding"}, ], ) == 1 ) def test_already_stamped_exact_twin_suppresses_broad_fallback( self, tmp_path: Path ): """Two-phase edge (reviewer scenario): an already-stamped exact twin must still count as an exact hit (marker-INDEPENDENT), suppressing the broad fallback so a timestamp-less same-content historical row is NOT stamped as the anchor's twin.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ROT_STAMPED_TWIN" db.create_session(parent, source="cli") db.append_message(parent, "user", "persisted question") db.append_message(parent, "assistant", "persisted answer") loaded = db.get_messages_as_conversation(parent) messages = [ *loaded, { "role": "user", "content": "live question", "timestamp": "2026-01-01T00:00:00Z", }, ] agent = _build_agent_with_db(db, parent) # Deliberately do NOT set _persist_user_message_idx: the guard must # not trust a persist index at all. agent._session_messages = [ { "role": "user", "content": "live question", "timestamp": "2026-01-01T00:00:00Z", _DB_PERSISTED_MARKER: True, }, {"role": "user", "content": "live question"}, ] agent.context_compressor.compress.return_value = [ { "role": "user", "content": "handoff scaffolding", "_todo_snapshot_synthetic": True, }, ] with patch.object( agent, "_flush_messages_to_session_db", side_effect=RuntimeError("simulated parent flush failure"), ): _returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, commit_fence=CompressionCommitFence(), ) # The timestamp-less ambiguous row must NOT be stamped as a twin. assert _DB_PERSISTED_MARKER not in agent._session_messages[1] # The already-stamped exact twin keeps its marker (idempotent). assert _DB_PERSISTED_MARKER in agent._session_messages[0] # The primary anchor is still stamped. assert _DB_PERSISTED_MARKER in messages[-1] class TestPlatformForwardedAtBoundary: def test_on_session_start_receives_platform(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_PLATFORM_ROT" db.create_session(parent, source="telegram") agent = _build_agent_with_db(db, parent, platform="telegram") agent._compress_context(_msgs(), "sys", approx_tokens=120_000) # The boundary notify must forward the platform so context-engine # plugins don't fall back to source=unknown (#27633). calls = [c for c in agent.context_compressor.on_session_start.call_args_list] assert calls, "on_session_start was not called at the boundary" kwargs = calls[-1].kwargs assert kwargs.get("platform") == "telegram" assert kwargs.get("boundary_reason") == "compression" class TestFallbackStreakFollowsRotation: def test_fallback_boundary_persists_on_child_session(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_FALLBACK_ROT" db.create_session(parent, source="telegram") 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, parent) # A fallback streak must survive the session-id rotation itself. The # boundary then records the just-completed fallback on the child row. compressor.record_completed_compaction(used_fallback=True) assert db.get_compression_fallback_streak(parent) == 1 db.create_session( "CHILD_FALLBACK_ROT", source="telegram", parent_session_id=parent, ) compressor.on_session_start( "CHILD_FALLBACK_ROT", session_db=db, boundary_reason="compression", old_session_id=parent, ) assert compressor._fallback_compression_streak == 1 compressor.record_completed_compaction(used_fallback=True) assert compressor._fallback_compression_streak == 2 assert db.get_compression_fallback_streak("CHILD_FALLBACK_ROT") == 2 resumed = ContextCompressor( model="test/model", threshold_percent=0.85, protect_first_n=2, protect_last_n=2, quiet_mode=True, ) resumed.bind_session_state(db, "CHILD_FALLBACK_ROT") assert resumed._fallback_compression_streak == 2 def test_real_rotation_records_fallback_after_lifecycle_rebind(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_REAL_FALLBACK_ROT" db.create_session(parent, source="telegram") agent = _build_agent_with_db(db, parent, platform="telegram") 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, parent) compressed = [ {"role": "user", "content": "[CONTEXT COMPACTION] fallback"}, {"role": "assistant", "content": "tail"}, ] def _fallback_compress(*_args, **_kwargs): compressor._last_summary_error = "empty summary" compressor._last_summary_fallback_used = True compressor._last_compression_made_progress = True return compressed with patch.object( compressor, "compress", side_effect=_fallback_compress, ): compressor.compression_count = 1 setattr(agent, "context_compressor", compressor) agent._compress_context(_msgs(), "sys", approx_tokens=120_000) child = getattr(agent, "session_id") assert child != parent assert compressor._fallback_compression_streak == 1 assert db.get_compression_fallback_streak(child) == 1 class TestAutomaticCompressionStateRefreshAfterLock: def test_prebound_agent_rejects_parent_rotated_before_lock_acquisition( self, refresh_state_db: SessionDB, ): db = refresh_state_db parent_id = "STALE_ROTATED_PARENT" child_id = "CANONICAL_COMPRESSION_CHILD" db.create_session(parent_id, source="telegram") agent = _build_agent_with_db(db, parent_id, platform="telegram") compressor = _bound_context_compressor(db, parent_id) # A competing path completes rotation after this call's initial checks # but before it acquires the parent lock. real_acquire = db.try_acquire_compression_lock def _acquire_after_rotation(*args, **kwargs): db.end_session(parent_id, "compression") db.create_session( child_id, source="telegram", parent_session_id=parent_id, ) return real_acquire(*args, **kwargs) db.try_acquire_compression_lock = _acquire_after_rotation agent.context_compressor = compressor agent.compression_in_place = False agent._compression_feasibility_checked = True messages = _msgs() with patch.object( compressor, "compress", side_effect=AssertionError("stale parent was compressed again"), ) as compress: returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, force=True, ) children = db._conn.execute( "SELECT id FROM sessions WHERE parent_session_id = ?", (parent_id,), ).fetchall() assert returned is messages assert agent.session_id == parent_id assert [row["id"] for row in children] == [child_id] compress.assert_not_called() assert db.get_compression_lock_holder(parent_id) is None def test_prebound_agent_drops_stale_cooldown_before_initial_gate( self, refresh_state_db: SessionDB, ): db = refresh_state_db session_id = "CLEARED_COMPRESSION_COOLDOWN" db.create_session(session_id, source="telegram") db.record_compression_failure_cooldown( session_id, time.time() + 60, "rate limited", ) agent = _build_agent_with_db(db, session_id, platform="telegram") compressor = _bound_context_compressor(db, session_id) assert compressor.get_active_compression_failure_cooldown() is not None # A successful forced retry on another agent clears the durable row. # This prebound compressor must not keep honoring its stale local timer. db.clear_compression_failure_cooldown(session_id) agent.context_compressor = compressor agent.compression_in_place = True agent._compression_feasibility_checked = True messages = _msgs() with patch.object(compressor, "compress", return_value=messages) as compress: returned, _ = agent._compress_context( messages, "sys", approx_tokens=120_000, ) assert returned is messages assert compressor.get_active_compression_failure_cooldown() is None compress.assert_called_once() assert db.get_compression_lock_holder(session_id) is None class TestGateLevelGuardRefresh: """The unblock direction must work from the should_compress() pre-gates. compress_context refreshes durable guards internally, but the automatic paths (preflight/turn gates) consult should_compress() first — if a stale in-memory fallback streak (which has no expiry timer) blocks there, the refresh inside compress_context is never reached and the agent stays blocked forever. """ def test_should_compress_unblocks_after_another_agent_clears_streak( self, refresh_state_db: SessionDB, ): db = refresh_state_db session_id = "GATE_LEVEL_STREAK_CLEAR" db.create_session(session_id, source="telegram") db.set_compression_fallback_streak(session_id, 2) compressor = _bound_context_compressor(db, session_id) assert compressor._fallback_compression_streak == 2 # Another agent's healthy boundary clears the durable breaker. db.set_compression_fallback_streak(session_id, 0) assert compressor.should_compress(10**9) is True assert compressor._fallback_compression_streak == 0 def test_unblocked_gate_does_not_touch_the_db( self, refresh_state_db: SessionDB, ): db = refresh_state_db session_id = "GATE_LEVEL_HOT_PATH" db.create_session(session_id, source="telegram") compressor = _bound_context_compressor(db, session_id) with patch.object( compressor, "_refresh_durable_guards", side_effect=AssertionError("hot path must not refresh"), ): assert compressor._automatic_compression_blocked() is False class TestCooldownPersistFailureIsNotAClearedRow: def test_refresh_keeps_local_cooldown_when_persist_failed( self, refresh_state_db: SessionDB, ): """An empty durable row is not evidence of a clear when OUR write failed. _record_compression_failure_cooldown sets the local timer first and persists best-effort. If that persist failed, a later refresh=True finding no DB row must keep the local cooldown (otherwise the #11529 thrash guard silently re-opens), until it expires or a successful DB round-trip supersedes it. """ db = refresh_state_db session_id = "PERSIST_FAILED_COOLDOWN" db.create_session(session_id, source="telegram") compressor = _bound_context_compressor(db, session_id) with patch.object( db, "record_compression_failure_cooldown", side_effect=Exception("disk full"), ): compressor._record_compression_failure_cooldown(60, "rate limited") assert compressor._cooldown_persist_failed is True state = compressor.get_active_compression_failure_cooldown(refresh=True) assert state is not None assert compressor._summary_failure_cooldown_until > 0 assert compressor._automatic_compression_blocked() is True # Once a durable round-trip succeeds, the DB is authoritative again. compressor._record_compression_failure_cooldown(30, "retry later") assert compressor._cooldown_persist_failed is False db.clear_compression_failure_cooldown(session_id) assert compressor.get_active_compression_failure_cooldown(refresh=True) is None assert compressor._summary_failure_cooldown_until == 0.0 def test_ineffective_count_block_honors_durable_clear_by_another_agent( self, refresh_state_db: SessionDB, ): """The ineffective-strike counter is durable (#54923): a block owed to it must re-read the DB so another agent's clear (a real usage reading that dipped below the threshold) unblocks this compressor too.""" db = refresh_state_db session_id = "INEFFECTIVE_DURABLE_BLOCK" db.create_session(session_id, source="telegram") db.set_compression_ineffective_count(session_id, 2) compressor = _bound_context_compressor(db, session_id) assert compressor._ineffective_compression_count == 2 assert compressor._automatic_compression_blocked() is True # Another agent's real prompt reading dipped below the threshold and # zeroed the durable counter. db.set_compression_ineffective_count(session_id, 0) assert compressor._automatic_compression_blocked() is False assert compressor._ineffective_compression_count == 0 class TestTodoSnapshotMergedNotDuplicated: """Todo snapshots preserve tail content without duplicate user turns.""" def test_snapshot_merges_into_trailing_user(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_TODO_MERGE" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent, platform="cli") agent.context_compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "acknowledged"}, {"role": "user", "content": "tail"}, ] agent._todo_store._todos = [ {"id": "t1", "content": "task A", "status": "pending"} ] agent._todo_store.format_for_injection = ( lambda: "## Current Tasks\n- [ ] task A" ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) assert len(compressed) == 3 tail = compressed[-1] assert tail["role"] == "user" assert "tail" in tail["content"] assert "task A" in tail["content"] assert not any( previous.get("role") == current.get("role") == "user" for previous, current in zip(compressed, compressed[1:]) ) def test_multimodal_snapshot_merge_is_persisted_in_place(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_TODO_MULTIMODAL_INPLACE" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent, platform="cli") agent.compression_in_place = True original_parts = [ {"type": "text", "text": "last user msg"}, { "type": "image_url", "image_url": {"url": "https://example.com/context.png"}, }, ] agent.context_compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "ok"}, {"role": "user", "content": list(original_parts)}, ] agent._todo_store._todos = [ {"id": "t1", "content": "inspect image", "status": "in_progress"} ] agent._todo_store.format_for_injection = ( lambda: "## Current Tasks\n- [ ] inspect image" ) # Input transcript must be large enough that the fake compressor's # output is a genuine shrink — the no-growth commit guard refuses # to persist a compression that grows the transcript. input_msgs = [ { "role": "user" if i % 2 == 0 else "assistant", "content": f"m{i} " + "x" * 400, } for i in range(20) ] compressed, _ = agent._compress_context( input_msgs, "sys", approx_tokens=120_000 ) assert len(compressed) == 3 tail = compressed[-1] assert tail["role"] == "user" assert isinstance(tail["content"], list) assert tail["content"][: len(original_parts)] == original_parts assert any( isinstance(part, dict) and "inspect image" in (part.get("text") or "") for part in tail["content"] ) assert not any( previous.get("role") == current.get("role") == "user" for previous, current in zip(compressed, compressed[1:]) ) db_msgs = db.get_messages(agent.session_id) persisted_tail = db_msgs[-1] assert persisted_tail["role"] == "user" assert persisted_tail["content"][: len(original_parts)] == original_parts assert any( isinstance(part, dict) and "inspect image" in (part.get("text") or "") for part in persisted_tail["content"] ) assert not any( previous.get("role") == current.get("role") == "user" for previous, current in zip(db_msgs, db_msgs[1:]) ) class TestTodoSnapshotScaffoldingTails: """Scaffolding tails must never absorb the todo snapshot (#69292).""" @staticmethod def _agent_with_todo(db: SessionDB, session_id: str, tail: dict): db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") agent.context_compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "acknowledged"}, tail, ] agent._todo_store.write( [{"id": "t1", "content": "task A", "status": "pending"}] ) return agent def test_previously_merged_snapshot_is_stripped_before_reinjection( self, tmp_path: Path ): from tools.todo_tool import TODO_INJECTION_HEADER previously_merged = ( "please fix the login bug\n\n" f"{TODO_INJECTION_HEADER}\n- [ ] t0. old finished task (pending)" ) db = SessionDB(db_path=tmp_path / "state.db") agent = self._agent_with_todo( db, "PARENT_TODO_RESTRIP", { "role": "user", "content": previously_merged, "api_content": "stale wire copy containing the old task", }, ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) tail = compressed[-1] assert tail["role"] == "user" assert "please fix the login bug" in tail["content"] assert "task A" in tail["content"] assert "old finished task" not in tail["content"] assert tail["content"].count(TODO_INJECTION_HEADER) == 1 assert "api_content" not in tail assert not any( previous.get("role") == current.get("role") == "user" for previous, current in zip(compressed, compressed[1:]) ) def test_empty_todo_store_injects_nothing(self, tmp_path: Path): from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_EMPTY" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") expected = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "acknowledged"}, {"role": "user", "content": "tail"}, ] agent.context_compressor.compress.return_value = [ dict(message) for message in expected ] agent._todo_store.write( [{"id": "t1", "content": "done thing", "status": "completed"}] ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) assert [ { k: v for k, v in m.items() if k not in {"_row_id", _DB_PERSISTED_MARKER} } for m in compressed ] == expected assert not any( TODO_INJECTION_HEADER in str(message.get("content") or "") for message in compressed ) def test_empty_todo_store_removes_previous_compaction_snapshot( self, tmp_path: Path ): """Completed items make the store authoritative and clear old work.""" from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_CLEARED" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") stale_task = "- [ ] stale-task. This task was already cleared" agent.context_compressor.compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": ( "Keep this user context.\n\n" f"{TODO_INJECTION_HEADER}\n{stale_task}" ), "api_content": "stale wire copy containing the old snapshot", }, ] agent._todo_store.write( [{"id": "done", "content": "done thing", "status": "completed"}] ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) combined = "\n".join(str(m.get("content") or "") for m in compressed) assert "Keep this user context." in combined assert TODO_INJECTION_HEADER not in combined assert stale_task not in combined retained = next( message for message in compressed if "Keep this user context." in str(message.get("content") or "") ) assert "api_content" not in retained def test_cancelled_only_store_is_authoritative(self, tmp_path: Path): """Cancelled work retires the old snapshot just like completed work.""" from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_CANCELLED" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") agent.context_compressor.compress.return_value = [ {"role": "user", "content": "Keep the request"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": f"{TODO_INJECTION_HEADER}\n- [ ] obsolete task", "_todo_snapshot_synthetic": True, }, ] agent._todo_store.write( [{"id": "nope", "content": "obsolete task", "status": "cancelled"}] ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) assert [message.get("role") for message in compressed] == [ "user", "assistant", ] assert all( TODO_INJECTION_HEADER not in str(message.get("content") or "") for message in compressed ) def test_structured_snapshot_only_tail_is_removed(self, tmp_path: Path): """A flagged list row is removable only when no other part remains.""" from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_STRUCTURED_ONLY" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") agent.context_compressor.compress.return_value = [ {"role": "user", "content": "Keep the request"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": [ { "type": "text", "text": f"{TODO_INJECTION_HEADER}\n- [ ] stale task", } ], "_todo_snapshot_synthetic": True, }, ] agent._todo_store.write( [{"id": "done", "content": "stale task", "status": "completed"}] ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) assert [message.get("role") for message in compressed] == [ "user", "assistant", ] def test_non_tail_snapshot_deletion_repairs_assistant_alternation( self, tmp_path: Path ): from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_MIDDLE" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") agent.context_compressor.compress.return_value = [ {"role": "user", "content": "Keep the request"}, { "role": "assistant", "content": "before snapshot", "api_content": "stale assistant wire copy", }, { "role": "user", "content": f"{TODO_INJECTION_HEADER}\n- [ ] stale task", "_todo_snapshot_synthetic": True, }, {"role": "assistant", "content": "after snapshot"}, {"role": "user", "content": "new request"}, ] agent._todo_store.write( [{"id": "done", "content": "stale task", "status": "completed"}] ) compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) assert [message.get("role") for message in compressed] == [ "user", "assistant", "user", ] repaired = compressed[1] assert "before snapshot" in repaired["content"] assert "after snapshot" in repaired["content"] assert "api_content" not in repaired assert not any( previous.get("role") == current.get("role") for previous, current in zip(compressed, compressed[1:]) ) def test_multimodal_content_survives_and_synthetic_provenance_clears( self, tmp_path: Path ): from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_MULTIMODAL_RETIRE" db.create_session(session_id, source="cli") agent = _build_agent_with_db(db, session_id, platform="cli") surviving_parts = [ {"type": "text", "text": "Keep this caption"}, { "type": "image_url", "image_url": {"url": "https://example.com/context.png"}, }, {"type": "input_audio", "input_audio": {"data": "audio-data"}}, ] agent.context_compressor.compress.return_value = [ {"role": "user", "content": "summary"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": surviving_parts + [ { "type": "text", "text": f"{TODO_INJECTION_HEADER}\n- [ ] stale task", } ], "api_content": "stale wire copy containing the snapshot", "_todo_snapshot_synthetic": True, "unrelated_metadata": "keep-me", }, ] agent._todo_store.write( [{"id": "done", "content": "stale task", "status": "completed"}] ) input_msgs = [ { "role": "user" if index % 2 == 0 else "assistant", "content": f"message {index} " + "x" * 1000, } for index in range(40) ] compressed, _ = agent._compress_context( input_msgs, "sys", approx_tokens=120_000 ) retained = next( message for message in compressed if isinstance(message.get("content"), list) ) assert retained["content"] == surviving_parts assert retained["unrelated_metadata"] == "keep-me" assert "api_content" not in retained assert "_todo_snapshot_synthetic" not in retained @pytest.mark.parametrize("authority_mode", ["missing", "raises"]) def test_unknown_store_authority_preserves_snapshot( self, tmp_path: Path, authority_mode: str ): """Legacy or broken stores fail conservative, never erasing pending work.""" from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = f"PARENT_TODO_COMPAT_{authority_mode.upper()}" db.create_session(session_id, source="telegram") agent = _build_agent_with_db(db, session_id, platform="telegram") pending_task = "- [ ] pending-task. Preserve conservatively" agent.context_compressor.compress.return_value = [ {"role": "user", "content": "Keep the request"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": f"{TODO_INJECTION_HEADER}\n{pending_task}", "_todo_snapshot_synthetic": True, }, ] class LegacyStore: def format_for_injection(self): return None store = LegacyStore() if authority_mode == "raises": store.has_items = MagicMock(side_effect=RuntimeError("store unavailable")) agent._todo_store = store compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) combined = "\n".join(str(message.get("content") or "") for message in compressed) assert TODO_INJECTION_HEADER in combined assert pending_task in combined def test_unhydrated_empty_todo_store_preserves_pending_snapshot( self, tmp_path: Path ): """A fresh empty store must not erase pending work retained by compression.""" from tools.todo_tool import TODO_INJECTION_HEADER db = SessionDB(db_path=tmp_path / "state.db") session_id = "PARENT_TODO_UNHYDRATED" db.create_session(session_id, source="telegram") agent = _build_agent_with_db(db, session_id, platform="telegram") pending_task = "- [ ] pending-task. Continue after the next compaction" getattr(agent, "context_compressor").compress.return_value = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, {"role": "assistant", "content": "acknowledged"}, { "role": "user", "content": f"{TODO_INJECTION_HEADER}\n{pending_task}", "_todo_snapshot_synthetic": True, }, ] compressed, _ = agent._compress_context( _msgs(), "sys", approx_tokens=120_000 ) combined = "\n".join(str(m.get("content") or "") for m in compressed) assert TODO_INJECTION_HEADER in combined assert pending_task in combined class TestArchivedParentActivityLabelsCleared: def test_parent_labels_cleared_after_rotation_child_lineage_intact( self, tmp_path: Path ): """Round-2 #4: the terminal heartbeat stamp must not stay on the parent. The compression activity heartbeat force-persists "context compression completed" against the PARENT id (agent.session_id at stamp time). After the out-of-place rotation the parent is archived; its activity labels must be cleared so it doesn't advertise a fresh last_activity_at + terminal label forever, while the child keeps its lineage. """ from agent.session_activity import ActivityProvenance db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ACTIVITY_LABELS" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) agent._compress_context(_msgs(), "sys", approx_tokens=120_000) child = agent.session_id assert child != parent # rotation happened # Child lineage intact. child_row = db.get_session(child) assert child_row is not None assert child_row.get("parent_session_id") == parent # Parent archived with cleared activity labels. parent_row = db.get_session(parent) assert parent_row is not None assert parent_row.get("ended_at") is not None assert not parent_row.get("last_activity_description"), ( "archived compression parent kept a stale activity description " f"({parent_row.get('last_activity_description')!r})" ) prov = parent_row.get("last_activity_provenance") assert not prov or prov == ActivityProvenance.UNKNOWN.value, ( f"archived parent kept terminal provenance {prov!r}" ) class TestAbortedRotationDoesNotGrowParent: """#88197 — a rotation that cannot publish must not have already written. The rotation flushes its un-persisted current-turn transcript to the parent (#47202) and only then calls ``publish_compression_child``. The abort handler rolls back memory but not that flush, so every failed rotation leaves the parent transcript longer than it found it. When the failure is STICKY -- a parent row stamped ``ended_at`` by something that ended the process rather than the conversation, e.g. the TUI gateway's ``_shutdown_sessions`` stamping ``end_reason='tui_shutdown'`` while the agent keeps running -- every subsequent auto-compaction repeats it, and the session grows instead of shrinking until the provider rejects the request. """ @staticmethod def _durable_len(db: SessionDB, session_id: str) -> int: return len(db.get_messages_as_conversation(session_id)) def test_automatic_stamp_no_longer_wedges_rotation(self, tmp_path: Path): """Flipped by the #88197 wedge fix: an AUTOMATIC stamp (``tui_shutdown`` — is_automatic_end_reason) is stale by construction for a live rotating writer, so publish now clears it in-transaction and the rotation COMMITS instead of aborting forever. The abort contract below moves to deliberate boundaries.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_AUTOMATIC_STAMP_HEALS" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) # The lie at the heart of #88197: the row says ended, the agent is live. db.end_session(parent, "tui_shutdown") assert db.get_session(parent)["ended_at"] is not None returned, _sp = agent._compress_context(_msgs(), "sys", approx_tokens=120_000) # Rotation went through — no abort loop, no repeated flush growth. assert agent.session_id != parent parent_row = db.get_session(parent) assert parent_row["end_reason"] == "compression", ( "parent must close with its TRUE boundary, not the stale stamp" ) child_row = db.get_session(agent.session_id) assert child_row is not None assert child_row["parent_session_id"] == parent def test_deliberately_ended_parent_aborts_before_the_prepublish_flush( self, tmp_path: Path ): db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_ENDED_NO_GROWTH" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) # A DELIBERATE boundary (another path owns lineage): the guard must # still refuse BEFORE the #47202 flush so aborted attempts cannot # grow the parent (#88411's contract, now scoped to non-automatic # reasons — automatic stamps rotate through, see the test above). db.end_session(parent, "session_reset") assert db.get_session(parent)["ended_at"] is not None before = self._durable_len(db, parent) # Three consecutive auto-compactions, as the reported incident saw. for attempt in range(1, 4): original = _msgs() returned, _sp = agent._compress_context( original, "sys", approx_tokens=120_000 ) assert self._durable_len(db, parent) == before, ( f"attempt {attempt} appended to the parent it could not " "publish; repeated attempts grow the transcript compression " "exists to shrink" ) # Rotation refused: the agent stays on the parent with its # transcript intact, same contract as any other publish failure. assert agent.session_id == parent assert returned is original assert [(m["role"], m["content"]) for m in returned] == [ (m["role"], m["content"]) for m in _msgs() ] assert db.find_live_compression_child(parent) is None def test_live_parent_still_gets_the_prepublish_flush(self, tmp_path: Path): """The guard must not cost a real rotation its #47202 tail.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_LIVE_FLUSH" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) agent._compress_context(_msgs(), "sys", approx_tokens=120_000) assert agent.session_id != parent # rotation happened # The current-turn messages survive in the preserved parent transcript. assert self._durable_len(db, parent) >= len(_msgs()) def test_unreadable_parent_row_fails_open(self, tmp_path: Path): """A guard that cannot read the row must not become a way to lose compression -- an unreadable parent rotates exactly as before.""" db = SessionDB(db_path=tmp_path / "state.db") parent = "PARENT_UNREADABLE_ROW" db.create_session(parent, source="cli") agent = _build_agent_with_db(db, parent) real_get_session = db.get_session calls = {"n": 0} def _flaky_get_session(session_id: str): if session_id == parent and calls["n"] == 0: calls["n"] += 1 raise RuntimeError("simulated read failure") return real_get_session(session_id) with patch.object(db, "get_session", side_effect=_flaky_get_session): agent._compress_context(_msgs(), "sys", approx_tokens=120_000) assert calls["n"] == 1, "the pre-flush guard never read the parent row" assert agent.session_id != parent # rotation still happened