Files
hermes-agent/tests/hermes_state/test_display_split_heal.py
finn763 3d7058fc83 fix(state): re-fold split display generations at compaction commit
A compaction generation that computed the display identity differently
leaves one logical message in two display_order groups, and GROUP BY
display_order then projects it twice (issue 122167 symptom B). Reconcile
by the recomputed display key before publishing, in both commit paths.

Related to #122167

(cherry picked from commit fb3b9ac0591f9cfc4b06bd8561331d2ea51eaced)
2026-09-27 03:03:46 -05:00

104 lines
4.7 KiB
Python

"""A split display generation must still project as one message (issue #122167).
Symptom B: after a compaction tail move, the server-side display projection
(``get_messages(include_compacted=True)``, the ``GROUP BY display_order`` path
in ``_display_rows_from_conn``) hands the renderer the same assistant reply
twice. The reporter's measurement on the real function: both rowids inside the
returned list, ``display_order == own id`` on each side, only
active/compacted/display_order/display_identity differing.
This test rebuilds that measured end-state (compaction carry + a skewed stored
identity/order on the copy, as left behind by a generation that computed the
identity differently), drives one more compaction, and asserts the behavior
contract: one logical message, one projection entry. The heal lives on the
compaction write path (not the read path) so paged display reads stay bounded
in the size of the page, not the transcript.
"""
from hermes_state import SessionDB
def _carry_and_split(db, sid, reply_text):
ts = 1727000000.0
db.create_session(sid, source="desktop")
db.append_message(sid, "user", "q", timestamp=ts)
db.append_message(sid, "assistant", reply_text, timestamp=ts + 1)
live = db.get_messages_as_conversation(sid)
db.archive_and_compact(
sid, [{"role": "user", "content": "[summary]"}] + live[-2:], tail_count=2)
def _split(conn):
copy_id = conn.execute(
"SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1 AND role = 'assistant'",
(sid,)).fetchone()[0]
# A generation that computed the identity differently leaves the copy
# in its own display_order group: order == own id, foreign identity.
conn.execute(
"UPDATE messages SET display_order = id, display_identity = zeroblob(32) "
"WHERE id = ?", (copy_id,))
# ...while the carried original was archived (still display-visible)
# instead of rewound — the issue's measured end-state: both rowids
# display-visible, one logical message in two display_order groups.
conn.execute(
"UPDATE messages SET compacted = 1 WHERE session_id = ? AND role = 'assistant' "
"AND active = 0 AND id <> ?", (sid, copy_id))
return copy_id
return db._execute_write(_split)
def _compact_again(db, sid, reply_text):
"""One more ordinary compaction; its commit re-folds any split generation.
``tail_count=0`` archives (not rewinds) the skewed copy, so it stays
display-visible: the exact shape that used to project twice."""
live = db.get_messages_as_conversation(sid)
tail = [m for m in live if m["role"] == "assistant" and m["content"] == reply_text][-1:]
db.archive_and_compact(
sid, [{"role": "user", "content": "[summary 2]"}] + tail, tail_count=0)
def _reply_count(db, sid, reply_text):
rows = db.get_messages(sid, include_compacted=True)
return sum(1 for m in rows if m["role"] == "assistant" and m["content"] == reply_text)
class TestSplitDisplayGenerationProjectsOnce:
def test_text_reply_projects_once(self, tmp_path):
from pathlib import Path
db = SessionDB(Path(tmp_path) / "state.db")
_carry_and_split(db, "chat", "same reply")
assert _reply_count(db, "chat", "same reply") == 2
_compact_again(db, "chat", "same reply")
assert _reply_count(db, "chat", "same reply") == 1
# The desktop pages this path (limit/offset/latest): paged reads fold too.
for rows in (db.get_messages("chat", include_compacted=True, latest=True, limit=10),
db.get_messages("chat", include_compacted=True, limit=10, offset=0)):
assert sum(1 for m in rows if m["role"] == "assistant"
and m["content"] == "same reply") == 1
def test_empty_reply_projects_once(self, tmp_path):
from pathlib import Path
db = SessionDB(Path(tmp_path) / "state.db")
_carry_and_split(db, "chat", "")
assert _reply_count(db, "chat", "") == 2
_compact_again(db, "chat", "")
assert _reply_count(db, "chat", "") == 1
def test_heal_is_durable_not_per_read(self, tmp_path):
"""After the healing compaction, the rows share one group on disk."""
from pathlib import Path
db = SessionDB(Path(tmp_path) / "state.db")
_carry_and_split(db, "chat", "same reply")
_compact_again(db, "chat", "same reply")
assert _reply_count(db, "chat", "same reply") == 1
orders = db._read_all(
"SELECT DISTINCT display_order FROM messages WHERE session_id = ? "
"AND role = 'assistant' AND content = ? AND (active = 1 OR compacted = 1)",
("chat", db._encode_content("same reply")))
assert len(orders) == 1