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

214 lines
8.3 KiB
Python

"""Every display projection of a compacted session must agree.
In-place compaction archives earlier turns as ``active=0, compacted=1`` rows.
They are durable display history — the user's own conversation, still on disk.
#80680 taught the REST transcript read to include them, but three GATEWAY
display projections kept filtering ``active = 1``:
- ``get_resume_conversations()`` — what ``session.resume`` ships
- ``get_ancestor_display_prefix()`` — the ancestor lineage prefix
- ``get_messages_as_conversation()`` — the warm-session payload on tab switch
So the same conversation read four ways gave two different answers: REST showed
everything, the gateway cut the transcript off at the compaction boundary. The
user sees their chat "vanish" down to a summary plus a couple of carried-forward
turns, and a resumed agent that cannot see its own completed work starts it over
(#92080, #93618, #68321).
These tests assert the INVARIANT — all display reads of one session return the
same transcript — rather than any particular row count, and pin the two things
that must NOT grow with it: the model-fed projection stays compressed, and
soft-deleted Undo/Rewind rows stay hidden.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _compact_in_place(db, sid, *, epochs=3, turns=4, tail_count=2):
"""Drive *sid* through repeated in-place compaction, like a long chat."""
db.create_session(sid, source="desktop")
for epoch in range(epochs):
for i in range(turns):
db.append_message(sid, "user", f"e{epoch} user {i}")
db.append_message(sid, "assistant", f"e{epoch} assistant {i}")
live = db.get_messages_as_conversation(sid)
db.archive_and_compact(
sid,
[{"role": "user", "content": f"[summary {epoch}]"}] + live[-tail_count:],
tail_count=tail_count,
)
return sid
def _texts(messages):
return [(m["role"], m["content"]) for m in messages]
def _rest_display(db, sid):
"""The read that was already correct — the parity reference."""
return [
{"role": m["role"], "content": m["content"]}
for m in db.get_messages(sid, include_compacted=True)
]
class TestDisplayProjectionParity:
def test_resume_display_matches_the_rest_transcript(self, db):
sid = _compact_in_place(db, "chat")
_, display = db.get_resume_conversations(sid)
assert _texts(display) == _texts(_rest_display(db, sid))
def test_warm_session_display_matches_the_rest_transcript(self, db):
"""The read behind ``_live_visible_history`` (switching back to a tab)."""
sid = _compact_in_place(db, "chat")
warm = db.get_messages_as_conversation(
sid, include_ancestors=True, include_row_ids=True, include_compacted=True
)
assert _texts(warm) == _texts(_rest_display(db, sid))
def test_pre_compaction_turns_survive_in_the_resume_transcript(self, db):
"""The user's own first turn is still there after several compactions."""
sid = _compact_in_place(db, "chat")
_, display = db.get_resume_conversations(sid)
assert ("user", "e0 user 0") in _texts(display)
assert ("assistant", "e0 assistant 0") in _texts(display)
def test_display_read_dedupes_carried_forward_tail(self, db):
"""Each logical message appears once, not once per compaction epoch."""
sid = _compact_in_place(db, "chat", epochs=4, tail_count=2)
_, display = db.get_resume_conversations(sid)
seen = _texts(display)
assert len(seen) == len(set(seen))
class TestModelProjectionStaysCompressed:
def test_model_history_excludes_archived_rows(self, db):
"""Compaction must still do its job: the model gets the compressed set."""
sid = _compact_in_place(db, "chat")
model, display = db.get_resume_conversations(sid)
assert len(model) < len(display)
assert ("user", "e0 user 0") not in _texts(model)
def test_model_history_matches_the_active_only_read(self, db):
sid = _compact_in_place(db, "chat")
model, _ = db.get_resume_conversations(sid)
active_only = db.get_messages_as_conversation(sid, repair_alternation=True)
assert _texts(model) == _texts(active_only)
class TestSoftDeletedRowsStayHidden:
def test_rewound_rows_are_excluded_from_the_display_projections(self, db):
"""Undo/Rewind rows (active=0, compacted=0) are NOT display history."""
sid = "chat"
db.create_session(sid, source="desktop")
db.append_message(sid, "user", "kept")
db.append_message(sid, "assistant", "kept reply")
db.append_message(sid, "user", "taken back")
db.append_message(sid, "assistant", "taken back reply")
rewind_target = next(
m for m in reversed(db.get_messages(sid)) if m["role"] == "user"
)
db.rewind_to_message(sid, rewind_target["id"])
_, display = db.get_resume_conversations(sid)
warm = db.get_messages_as_conversation(
sid, include_ancestors=True, include_compacted=True
)
for projection in (display, warm):
contents = [c for _, c in _texts(projection)]
assert "taken back" not in contents
assert "kept" in contents
class TestAncestorPrefix:
def test_prefix_includes_a_compacted_ancestor_s_archived_rows(self, db):
"""A compression ROTATION's parent still shows its pre-compaction turns."""
parent, child = "parent", "child"
db.create_session(parent, source="desktop")
for i in range(3):
db.append_message(parent, "user", f"P user {i}")
db.append_message(parent, "assistant", f"P assistant {i}")
db.archive_and_compact(parent, [{"role": "user", "content": "[parent summary]"}])
db.create_session(child, source="desktop", parent_session_id=parent)
db.append_message(child, "user", "C user 0")
db.append_message(child, "assistant", "C assistant 0")
prefix = db.get_ancestor_display_prefix(child)
_, display = db.get_resume_conversations(child)
assert ("user", "P user 0") in _texts(prefix)
assert ("user", "P user 0") in _texts(display)
# The child's own turns belong to the tip, never the ancestor prefix.
assert ("user", "C user 0") not in _texts(prefix)
def test_explicit_branch_has_no_ancestor_prefix(self, db):
"""A /branch copy owns its transcript; the live parent must not leak in."""
sid = _compact_in_place(db, "chat")
db.create_session(
"branch",
source="desktop",
parent_session_id=sid,
model_config={"_branched_from": sid},
)
db.append_message("branch", "user", "branch turn")
assert db.get_ancestor_display_prefix("branch") == []
_, display = db.get_resume_conversations("branch")
assert _texts(display) == [("user", "branch turn")]
class TestResumeGuardBoundsWhatResumeLoads:
def test_guard_counts_the_rows_the_display_read_materializes(self, db):
"""The guard must not undercount: it bounds an in-memory materialization."""
sid = _compact_in_place(db, "chat", epochs=4)
_, display = db.get_resume_conversations(sid)
assert db.get_resume_message_count(sid) >= len(display)
def test_guard_rejects_a_lineage_over_the_limit(self, db):
from hermes_state import SessionResumeTooLargeError
sid = _compact_in_place(db, "chat", epochs=4)
with pytest.raises(SessionResumeTooLargeError):
db.assert_resume_safe(sid, max_messages=2)
def test_tip_only_guard_still_bounds_only_the_live_tip(self, db):
"""The #4130 carve-out: a healthy compacted chat must stay resumable.
A well-compressed conversation is exactly the shape compression is
meant to produce. Counting its archive against a tip-sized budget is
what stranded Bot Chats on "Waking up…"; ``tip_only`` callers never
materialize the archive, so they keep the active-only bound.
"""
sid = _compact_in_place(db, "chat", epochs=4)
tip_count = db.get_resume_message_count(sid, tip_only=True)
assert tip_count < db.get_resume_message_count(sid)
assert db.assert_resume_safe(sid, max_messages=tip_count, tip_only=True)