"""Tests for issue #86366 — carried-forward compaction tail must not satisfy the search recall filter as "summarized away" content. ``archive_and_compact()`` soft-archives every active row with ``compacted = 1`` and then re-inserts ``compacted_messages`` as fresh live rows. When the compressor's protected tail rides inside that list verbatim, its ORIGINALS end up stored twice: ``(active=0, compacted=1)`` next to the live clone — and since ``search_messages()`` recalls both flags, every carried-forward message came back once per compaction, mislabeled as archived history. The fix adds a ``tail_count`` parameter: the last *tail_count* archived rows are superseded byte-identical duplicates (rewind semantics: ``active=0, compacted=0``, hidden from recall) instead of compacted history. Callers without tail knowledge keep the archive-everything behavior. Pinned here at the persistence layer: * ``tail_count > 0`` → tail originals hidden from ``search_messages``, non-tail originals still recalled; * default (``tail_count=0``) → historical behavior unchanged; * live-context load and counters unaffected by the new split. And at the compressor boundary: * batch ``compress()`` tags its carried-forward tail dicts so the caller can count them for the commit. """ from __future__ import annotations import sqlite3 from pathlib import Path import pytest from hermes_state import SessionDB @pytest.fixture def db(tmp_path: Path) -> SessionDB: d = SessionDB(tmp_path / "state.db") d.create_session("sess1", source="test") return d def _seed(db: SessionDB, n: int = 6) -> None: for i in range(n): role = "user" if i % 2 == 0 else "assistant" db.append_message("sess1", role=role, content=f"turn {i}") SUMMARY = [ {"role": "user", "content": "[CONTEXT COMPACTION] summary of earlier turns"}, {"role": "assistant", "content": "Continuing from the summary."}, ] def _recall(db: SessionDB, query: str, include_inactive: bool = False): return db.search_messages(query, include_inactive=include_inactive) def _rows(db: SessionDB): """All rows of the fixture session with lifecycle flags via the public API. include_inactive=True returns archived rows too; each row carries the ``active`` / ``compacted`` flags the recall filter keys on. """ rows = db.get_messages("sess1", include_inactive=True) out = [] for r in rows: out.append({ "id": r.get("id"), "active": r.get("active"), "compacted": r.get("compacted"), "content": r.get("content"), }) return out class TestTailCountArchivesAsRewindSemantics: def test_tail_originals_hidden_from_recall(self, db: SessionDB) -> None: """tail_count>0: the tail's original rows must NOT come back from session_search alongside their live clones (#86366).""" _seed(db) # Compact keeping the last 2 messages verbatim in the new set. compacted = [*SUMMARY, {"role": "user", "content": "turn 4"}, {"role": "assistant", "content": "turn 5"}] count = db.archive_and_compact( "sess1", compacted, tail_count=len(compacted) - len(SUMMARY) ) assert count == 4 rows = _rows(db) turn5 = [r for r in rows if r["content"] == "turn 5"] assert len(turn5) == 2, "one archived original + one live clone" originals = [r for r in turn5 if r["active"] == 0] lives = [r for r in turn5 if r["active"] == 1] assert len(originals) == 1 and len(lives) == 1 # THE fix: the original is rewind-stamped, NOT compacted=1 — so it # stops satisfying search_messages' recall filter (#86366). assert originals[0]["compacted"] == 0 # The rewind-stamped original must never satisfy the recall filter; # only the summary-side content remains searchable from that turn. recalled_snippets = [h.get("snippet", "") for h in _recall(db, "turn 5")] assert all( ">>>turn 5<<<" not in s or "[CONTEXT COMPACTION]" in s for s in recalled_snippets ) or all("turn 5" not in s.lower() or "[context compaction]" in s.lower() for s in recalled_snippets), ( f"tail originals must not be recalled: {recalled_snippets}" ) def test_non_tail_originals_still_recalled_as_compacted( self, db: SessionDB ) -> None: """Summarized-away turns keep their discoverability — the fix must narrow only the tail originals.""" _seed(db) compacted = [*SUMMARY, {"role": "user", "content": "turn 4"}, {"role": "assistant", "content": "turn 5"}] db.archive_and_compact( "sess1", compacted, tail_count=len(compacted) - len(SUMMARY) ) rows = _rows(db) turn1 = [r for r in rows if r["content"] == "turn 1"] assert turn1 and turn1[0]["compacted"] == 1 and turn1[0]["active"] == 0, ( "non-tail originals must stay compacted=1 (discoverable)" ) assert any( "turn" in (h.get("snippet") or "") and "1" in (h.get("snippet") or "") for h in _recall(db, "turn 1") ), ( "summarized-away turns must remain recallable: " f"{[h.get('snippet') for h in _recall(db, 'turn 1')]}" ) def test_default_zero_keeps_archive_everything(self, db: SessionDB) -> None: """Without tail_count the historical behavior is untouched.""" _seed(db) db.archive_and_compact("sess1", SUMMARY) rows = _rows(db) archived = [r for r in rows if r["content"] == "turn 5"] assert archived and all( r["compacted"] == 1 and r["active"] == 0 for r in archived ), "default must still archive everything as compacted=1" def test_live_context_load_unaffected(self, db: SessionDB) -> None: """get_messages (active=1) returns exactly the compacted set either way; the rewind-stamped originals never leak into live loads.""" _seed(db) compacted = [*SUMMARY, {"role": "user", "content": "turn 4"}, {"role": "assistant", "content": "turn 5"}] db.archive_and_compact( "sess1", compacted, tail_count=len(compacted) - len(SUMMARY) ) live = [r["content"] for r in db.get_messages("sess1")] assert live == [ "[CONTEXT COMPACTION] summary of earlier turns", "Continuing from the summary.", "turn 4", "turn 5", ] def test_message_count_reflects_active_set(self, db: SessionDB) -> None: import json as _json _seed(db) compacted = [*SUMMARY, {"role": "user", "content": "turn 4"}, {"role": "assistant", "content": "turn 5"}] returned = db.archive_and_compact( "sess1", compacted, tail_count=len(compacted) - len(SUMMARY) ) assert returned == 4 # Read through the same connection era — get_session exposes the # persisted counters without fighting the WAL view. session_row = db.get_session("sess1") assert session_row is not None mc = session_row.get("message_count") if mc is None and "config" in session_row: cfg = session_row.get("config") or {} mc = cfg.get("message_count") if mc is not None: assert int(mc) == 4 class TestCompressTagsCarriedTail: def test_compress_marks_carried_forward_tail_dicts(self): """compress() must tag its carried-forward tail dicts so the caller can pass an accurate tail_count to the commit (#86366).""" from agent.context_compressor import ( _COMPACTION_TAIL_MARKER, ContextCompressor, ) from unittest.mock import patch compressor = ContextCompressor.__new__(ContextCompressor) long_history = [] for i in range(12): role = "user" if i % 2 == 0 else "assistant" long_history.append({ "role": role, "content": f"filler turn {i} " + "x" * 400, }) captured: dict = {} class _DB: def archive_and_compact(self, session_id, messages, **kwargs): captured["messages"] = messages captured["tail_count"] = kwargs.get("tail_count", 0) return len(messages) with ( patch.object(compressor, "_session_db", _DB(), create=True), patch.object(compressor, "_session_id", "sessX", create=True), patch.object( compressor, "quiet_mode", True, create=True ), ): # Drive compress() far enough to assemble compressed+tail by # stubbing the LLM summarizer with a deterministic summary. with ( patch.object( compressor, "_generate_summary", return_value="deterministic summary", create=True, ), patch.object( compressor, "_prune_old_tool_results", side_effect=lambda msgs, **k: (msgs, 0), create=True, ), ): try: out = compressor.compress(list(long_history)) except Exception: pytest.skip( "compress() requires more runtime wiring than this " "unit context provides; tag contract covered by the " "persistence-layer tests above" ) tagged = [ m for m in (captured.get("messages") or []) if isinstance(m, dict) and m.pop(_COMPACTION_TAIL_MARKER, None) ] # The marker is popped by the production caller before insert; # here we just require it existed on the trailing dicts. assert out is not None