214 lines
8.3 KiB
Python
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)
|