Files
aiturk-hermes-ide/tests/agent/test_compression_rotation_state.py
T

2072 lines
80 KiB
Python

"""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