hermes-agent/tests/agent/test_compression_rotation_state.py
kshitijk4poor d9bd7ce827 test(compression): pin rotation-fallback tests to in_place=False ahead of default flip
These 7 test sites assert rotation behavior (fork, child sessions, lock
contention, logging session-context follows id rotation, boundary hooks fire
on rotation). Pin each builder to in_place=False explicitly so they keep
exercising the retained rotation fallback regardless of the global default
(flipped to True in #38763). Rotation stays a working opt-out fallback and
deserves continued coverage — these are NOT deleted.

Pinned sites:
- test_compression_concurrent_fork._build_agent_with_db
- test_compression_logging_session_context._build_agent_with_db
- test_compression_rotation_state._build_agent_with_db
- test_compression_boundary_hook._make_agent (2 helpers: CompressionBoundaryHook + SessionCompressEvent)
- test_compression_concurrent_sessions._build_agent_with_db
2026-06-25 12:56:05 -07:00

132 lines
5.3 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 os
from pathlib import Path
from unittest.mock import MagicMock, patch
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)]
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)
# Make the CHILD create_session raise, but let the initial parent
# end_session/reopen work. We patch create_session to blow up.
real_create = db.create_session
def _boom(*a, **k):
raise RuntimeError("FOREIGN KEY constraint failed")
with patch.object(db, "create_session", side_effect=_boom):
agent._compress_context(_msgs(), "sys", approx_tokens=120_000)
# The live id must roll back to the still-indexed parent — NOT a
# phantom child id that has no row in state.db.
assert agent.session_id == parent
assert db.get_session(parent) is not None
_ = real_create # silence unused
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"