diff --git a/tests/hermes_state/test_display_projection_parity.py b/tests/hermes_state/test_display_projection_parity.py new file mode 100644 index 0000000000..c34c64c17e --- /dev/null +++ b/tests/hermes_state/test_display_projection_parity.py @@ -0,0 +1,213 @@ +"""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)