2072 lines
80 KiB
Python
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
|