Files
aiturk-hermes-ide/tests/hermes_state/test_86366_tail_rewind_semantics.py
T

259 lines
9.9 KiB
Python

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