Files
hermes-agent/tests/test_message_reactions.py
kshitijk4poor 016ba66176 fix: stamp _db_persisted at row load time so resumed transcripts never re-append (#92231)
Resumed sessions loaded message dicts from state.db WITHOUT the
_DB_PERSISTED_MARKER, so any flush that lost the identity boundary
(compression durable-snapshot adoption, incremental tool-call persists,
rotation preflight on cold resume) re-appended the ENTIRE loaded
transcript as new rows. Compression cycles then doubled the copies:
the incident session grew 998 -> 1995 -> 3990 -> 7981 rows across
three aborted rotations (15,962 active rows, only 472 distinct).

Fix at the architectural chokepoint: SessionDB._rows_to_conversation
(shared by get_messages_as_conversation and get_resume_conversations)
now stamps the marker at row materialization time - a dict built FROM
a durable row is persisted by construction, regardless of which caller
loads it or how the list is later handed to a flush.

Safety:
- Wire-safe: every transport strips underscore-prefixed keys before
  the API request (chat_completion_helpers, anthropic_adapter), same
  contract as the existing _row_id stamp in the same function.
- Rotation handoffs still write: compression's assembly copies strip
  the marker (_fresh_compaction_message_copy + the terminal
  _strip_persistence_markers sweep), so compacted transcripts still
  flush to the child session (#57491 invariant preserved).
- Branch/seed copies unaffected: /branch and _persist_branch_seed
  build fresh field-projected dicts and write via append_messages_batch
  directly, not through the marker-gated flush.

Tests: new regression suite (marker sync, load stamping, 3-cycle
amplification repro, new-tail write guard, compaction-copy handoff);
updated the #68454 control test that asserted the old double-write
behavior and the ACP restore shape test.
2026-08-23 13:34:32 +05:30

184 lines
6.8 KiB
Python

"""Message reactions: persistence, tapback semantics, and cache safety.
Behavior contracts, not snapshots — these assert how reactions must RELATE to
the transcript (one per author, announced once, never mutating history), so
they survive refactors of where reactions are stored.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
return SessionDB(db_path=tmp_path / "state.db")
@pytest.fixture
def session(db):
key = db.create_session("react-test", "test")
db.append_message(key, "user", "how do i center a div")
db.append_message(key, "assistant", "use flexbox")
rows = [m["_row_id"] for m in db.get_messages_as_conversation(key, include_row_ids=True)]
return key, rows
def test_conversation_rows_carry_durable_row_id(session, db):
"""Every projected message exposes its messages.id — reactions key off it."""
key, rows = session
assert all(isinstance(r, int) for r in rows)
assert rows == sorted(rows), "row ids must follow insertion order"
assert len(set(rows)) == len(rows), "row ids must be unique"
def test_one_reaction_per_author(session, db):
"""A second emoji from the same author REPLACES the first (iOS Tapback)."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f602", author="user")
assert [r["emoji"] for r in reactions] == ["\U0001f602"]
def test_repeating_an_emoji_retracts_it(session, db):
"""Tapping the live reaction again clears it."""
key, rows = session
db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f44d", author="user")
assert reactions == []
assert db.get_message_reactions(key, rows[0]) == []
def test_authors_are_independent(session, db):
"""User and agent each hold their own slot on the same message."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
reactions = db.set_message_reaction(key, rows[0], "\U0001f525", author="agent")
assert {r["author"] for r in reactions} == {"user", "agent"}
remaining = db.set_message_reaction(key, rows[0], None, author="user")
assert [r["author"] for r in remaining] == ["agent"]
def test_rejects_rows_outside_the_session(session, db):
"""A row id from another conversation is never writable."""
key, rows = session
other = db.create_session("other", "test")
db.append_message(other, "user", "elsewhere")
assert db.set_message_reaction(key, 9999, "\u2764\ufe0f") is None
assert db.set_message_reaction("no-such-session", rows[0], "\u2764\ufe0f") is None
def test_clearing_every_reaction_leaves_no_metadata(session, db):
"""An empty reaction set removes the key instead of persisting `[]`."""
key, rows = session
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
db.set_message_reaction(key, rows[0], None, author="user")
message = db.get_messages_as_conversation(key)[0]
assert "display_metadata" not in message
def test_reactions_survive_reload(session, db):
"""Reactions are durable, not in-memory display state."""
key, rows = session
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
reopened = SessionDB(db_path=db.db_path)
assert [r["emoji"] for r in reopened.get_message_reactions(key, rows[1])] == ["\U0001f525"]
def test_unseen_reactions_are_taken_exactly_once(session, db):
"""The model is told about a reaction on ONE turn, never twice."""
key, rows = session
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
first = db.take_unseen_reactions(key, author="user")
assert [e["emoji"] for e in first] == ["\u2764\ufe0f"]
assert first[0]["row_id"] == rows[1]
assert first[0]["text"] == "use flexbox"
assert db.take_unseen_reactions(key, author="user") == []
def test_a_new_reaction_becomes_unseen_again(session, db):
"""Replacing a seen reaction re-arms the announcement."""
key, rows = session
db.set_message_reaction(key, rows[1], "\u2764\ufe0f", author="user")
db.take_unseen_reactions(key, author="user")
db.set_message_reaction(key, rows[1], "\U0001f525", author="user")
assert [e["emoji"] for e in db.take_unseen_reactions(key, author="user")] == ["\U0001f525"]
def test_take_unseen_filters_by_author(session, db):
"""The agent's own reactions are never fed back to it as user input."""
key, rows = session
db.set_message_reaction(key, rows[0], "\U0001f60a", author="agent")
assert db.take_unseen_reactions(key, author="user") == []
def test_reacting_never_mutates_message_content(session, db):
"""CACHE SAFETY: reacting must not rewrite any already-sent message.
Rewriting a past message would invalidate the provider's cached prefix for
the whole conversation — the reason reactions ride display_metadata and are
announced on the NEXT turn instead.
"""
key, rows = session
before = [m["content"] for m in db.get_messages_as_conversation(key)]
db.set_message_reaction(key, rows[0], "\u2764\ufe0f", author="user")
db.set_message_reaction(key, rows[1], "\U0001f525", author="agent")
db.take_unseen_reactions(key, author="user")
after = [m["content"] for m in db.get_messages_as_conversation(key)]
assert before == after
def test_latest_user_message_is_the_agents_default_target(session, db):
"""The agent reacts to "the message that triggered me" without an id."""
key, rows = session
assert db.latest_user_message_row_id(key) == rows[0]
db.append_message(key, "user", "thanks!")
newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"]
assert db.latest_user_message_row_id(key) == newest
def test_row_id_is_opt_in_and_never_reaches_the_provider(session, db):
"""Only include_row_ids=True consumers see _row_id — and it's underscore-
prefixed so transports strip it before the wire even for them. Default
consumers (ACP restore, export) get the transcript in its historical shape.
``_db_persisted`` is the other sanctioned underscore key: stamped on every
loaded row (#92231) so a flush can never re-append a resumed transcript.
Like ``_row_id`` it is stripped before the wire by every transport.
"""
key, _rows = session
for message in db.get_messages_as_conversation(key):
assert "_row_id" not in message
assert message.get("_db_persisted") is True
for message in db.get_messages_as_conversation(key, include_row_ids=True):
assert "_row_id" in message
assert all(
not k.startswith("_") or k in {"_row_id", "_db_persisted"}
for k in message
)