Files
hermes-agent/tests/agent/test_api_content_row_addressed_backfill.py
T
kshitijk4poor 63c6f9bf14 simplify(agent): sidecar backfill — drop the hasattr guard and the duplicated row-id predicate; tests 7→6
_session_db is always a SessionDB (agent_init / delegate_tool), so the
"fail closed on a store wrapper" hasattr was defense around code that
cannot fail; the store's own guard binds the value into SQL, so the
prologue only needs the sibling idiom isinstance(_row_id, int) that
session_persistence and transcript_repair already use. The positional
hazard is explained once, on set_latest_user_api_content. The in-place
compaction test duplicated test_api_content_sidecar's
test_inplace_compaction_backfills_sidecar_into_db verbatim (its row_id
parameter was never varied); dropped, as was the positional-helper tail
of test_older_identical_row_is_untouched already covered there.
2026-09-09 10:32:01 +05:30

239 lines
10 KiB
Python

"""Row-addressed ``api_content`` backfill (NousResearch/hermes-agent#102194).
The sidecar is stamped by the turn prologue and normally reaches the DB in the
same INSERT as the clean content (the crash persist runs after the stamp). When
another writer materialized the current turn's user row FIRST — in-place
preflight compaction, or a close/early flush that raced the prologue — that
insert never happens: the crash persist marker-skips the message and the row
keeps ``api_content = NULL``, so the next turn replays clean content and the
request prefix diverges exactly at that message.
The prologue therefore backfills, but only when a row provably exists for THIS
dict. ``_row_id`` is that proof and that address: both early writers stamp it on
the live message (``_insert_message_rows`` directly, ``sync_flushed_message_markers``
after the batch commit). A positional "newest active user row" update cannot be
substituted for it — a repeated user turn ("ok", "y", "continue") makes the
previous turn's row compare equal on content, and the backfill would overwrite
that turn's sidecar with this turn's bytes.
"""
from __future__ import annotations
import types
from unittest.mock import MagicMock, patch
import pytest
from agent.session_persistence import SessionPersistenceMixin
from agent.turn_context import _stamp_api_content_sidecar, compose_user_api_content
from hermes_state import SessionDB
from tests.agent.test_api_content_sidecar import _FakeAgent, _build
class TestSetMessageApiContent:
"""The store primitive: addressed by row id, guarded on the rest."""
def _open(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="cli")
return db
def test_older_identical_row_is_untouched(self, tmp_path):
"""Two user turns with the same text — the repeated-"ok" shape.
Addressing the row makes the older turn's sidecar unreachable; the
positional helper cannot tell them apart (asserted on the same DB).
"""
db = self._open(tmp_path)
try:
db.append_message("s1", "user", content="ok", api_content="ok\n\nTURN-1")
db.append_message("s1", "assistant", content="reply")
db.append_message("s1", "user", content="ok")
rows = db.get_messages("s1")
turn_1_id, turn_2_id = rows[0]["id"], rows[2]["id"]
assert db.set_message_api_content("s1", turn_2_id, "ok", "ok\n\nTURN-2") == 1
rows = {r["id"]: r for r in db.get_messages("s1")}
assert rows[turn_1_id]["api_content"] == "ok\n\nTURN-1"
assert rows[turn_2_id]["api_content"] == "ok\n\nTURN-2"
finally:
db.close()
def test_guards_refuse_wrong_session_or_mismatched_content_or_archived_row(self, tmp_path):
db = self._open(tmp_path)
try:
db.create_session("s2", source="cli")
db.append_message("s1", "user", content="hello")
row_id = db.get_messages("s1")[0]["id"]
assert db.set_message_api_content("s2", row_id, "hello", "x") == 0
assert db.set_message_api_content("s1", row_id, "other", "x") == 0
assert db.set_message_api_content("s1", row_id + 999, "hello", "x") == 0
assert db.get_messages("s1")[0]["api_content"] is None
# Archived by compaction: active = 0, so the row is off limits.
db.archive_and_compact("s1", [{"role": "user", "content": "hello"}])
assert db.set_message_api_content("s1", row_id, "hello", "x") == 0
finally:
db.close()
class TestPrologueRowAddressedBackfill:
"""The prologue gate: backfill iff a durable row exists for this dict."""
def test_no_row_id_and_no_compaction_writes_nothing(self):
"""The normal path: the row does not exist yet and the crash persist
writes it WITH the sidecar. A backfill here has no row to address and
would have to guess — so it must not run at all."""
agent = _FakeAgent()
agent._session_db = MagicMock()
with patch(
"hermes_cli.plugins.invoke_hook",
return_value=[{"context": "PLUGIN-CTX"}],
):
ctx = _build(agent)
assert (
ctx.messages[ctx.current_turn_user_idx]["api_content"]
== "hello\n\nPLUGIN-CTX"
)
agent._session_db.set_message_api_content.assert_not_called()
agent._session_db.set_latest_user_api_content.assert_not_called()
class _RealPersistenceAgent(SessionPersistenceMixin, _FakeAgent):
"""Stand-in agent with the real SessionPersistenceMixin flush implementation."""
def __init__(self, db=None, sid="s1"):
_FakeAgent.__init__(self)
self._session_db = db
self.session_id = sid
self._session_db_created = True
self._flushed_db_message_ids = set()
self._last_flushed_db_idx = 0
class TestRealEarlyFlushAndOverrideLifecycle:
"""End-to-end tests exercising real database flushes and API-only overrides."""
def test_real_close_flush_syncs_row_id_and_prologue_backfills(self, tmp_path):
"""Proof that the real _flush_messages_to_session_db path syncs _row_id onto
the live dict (via sync_flushed_message_markers) and the prologue backfills it."""
path = tmp_path / "state.db"
db = SessionDB(db_path=path)
sid = "sess-real-flush"
db.create_session(sid, source="cli")
try:
agent = _RealPersistenceAgent(db, sid)
staged = {"role": "user", "content": "hello"}
agent._pending_cli_user_message = staged
# Simulate the early/close flush racing the prologue
flushed = agent._flush_messages_to_session_db([staged], None)
assert flushed is True
assert staged.get("_db_persisted") is True
assert isinstance(staged.get("_row_id"), int)
assert staged["_row_id"] == db.get_messages(sid)[-1]["id"]
# At this point, the row in SQLite has api_content = None
assert db.get_messages(sid)[-1]["api_content"] is None
# Now build_turn_context runs
with patch(
"hermes_cli.plugins.invoke_hook",
return_value=[{"context": "PLUGIN-CTX"}],
):
ctx = _build(agent)
expected = compose_user_api_content("hello", "", "PLUGIN-CTX")
assert ctx.messages[ctx.current_turn_user_idx]["api_content"] == expected
# Backfilled to the exact row in SQLite!
assert db.get_messages(sid)[-1]["api_content"] == expected
finally:
db.close()
def test_pre_flushed_api_only_turn_without_injections_preserves_sidecar(self, tmp_path):
"""[ehz0ah bug 1]: Pre-flushed clean input where the API turn has an API-only
variant (e.g. voice prefix) and NO memory/plugin injection is composed.
compose_user_api_content returns None, but the differing API-only bytes must
be preserved as api_content and backfilled onto the row."""
path = tmp_path / "state.db"
db = SessionDB(db_path=path)
sid = "sess-api-only-no-inj"
db.create_session(sid, source="cli")
try:
agent = _RealPersistenceAgent(db, sid)
clean_text = "hello"
api_text = "[voice] hello"
staged = {"role": "user", "content": clean_text}
agent._pending_cli_user_message = staged
agent._flush_messages_to_session_db([staged], None)
assert staged.get("_row_id") is not None
# Worker resumes with API-facing message and clean persist override
with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
ctx = _build(
agent,
user_message=api_text,
persist_user_message=clean_text,
)
# Live user message has the API text and api_content
turn_msg = ctx.messages[ctx.current_turn_user_idx]
assert turn_msg["content"] == api_text
assert turn_msg["api_content"] == api_text
# Database row has clean text as content, but api_text as api_content!
db_rows = db.get_messages(sid)
assert len(db_rows) == 1
assert db_rows[0]["content"] == clean_text
assert db_rows[0]["api_content"] == api_text
# Replay via get_messages_as_conversation preserves clean content
# alongside the api_content sidecar.
conv = db.get_messages_as_conversation(sid)
assert conv[0]["content"] == clean_text
assert conv[0]["api_content"] == api_text
from agent.turn_context import substitute_api_content
substitute_api_content(conv[0])
assert conv[0]["content"] == api_text
finally:
db.close()
def test_repeated_prompt_protected_against_positional_overwrite(self, tmp_path):
"""Repeated prompts 'ok' across turns: Turn 1 has sidecar, Turn 2 is pre-flushed.
Row-addressed backfill on Turn 2 never mutates Turn 1's stored sidecar."""
path = tmp_path / "state.db"
db = SessionDB(db_path=path)
sid = "sess-repeated-ok"
db.create_session(sid, source="cli")
try:
# Turn 1
db.append_message(sid, "user", content="ok", api_content="ok\n\nTURN-1-CTX")
db.append_message(sid, "assistant", content="acknowledged")
t1_user_row = db.get_messages(sid)[0]
# Turn 2: staged and pre-flushed
staged_t2 = {"role": "user", "content": "ok"}
agent = _RealPersistenceAgent(db, sid)
agent._pending_cli_user_message = staged_t2
agent._flush_messages_to_session_db([staged_t2], None)
t2_user_row = db.get_messages(sid)[2]
assert t2_user_row["id"] != t1_user_row["id"]
assert t2_user_row["api_content"] is None
# Prologue backfills Turn 2
with patch(
"hermes_cli.plugins.invoke_hook",
return_value=[{"context": "TURN-2-CTX"}],
):
_build(agent, user_message="ok")
rows = {r["id"]: r for r in db.get_messages(sid)}
assert rows[t1_user_row["id"]]["api_content"] == "ok\n\nTURN-1-CTX"
assert rows[t2_user_row["id"]]["api_content"] == "ok\n\nTURN-2-CTX"
finally:
db.close()