Files
hermes-agent/tests/hermes_state/test_fts_projection_integrity.py
teknium1 a28d83b70c refactor(state): drop the retired tool high-water marker on realign; reuse the shared projection SQL
Follow-up to the aligned-projection salvage (#114191):
- the realign migration deletes the now-meaningless state_meta key
  fts_tool_full_content_high_water instead of leaving inert residue, and the
  empty-store and populated-store branches share one do_align() body;
- _fts_tool_prefix_migration_requires_rebuild is dead after the realign
  (the shape probe it guarded is false once do_align ran, and a deferred
  align marks the index stale before that branch is reached) -> removed;
- the legacy inline triggers use _FTS_NEW_INDEXED_CONTENT_SQL again instead
  of four inlined copies of the same CASE expression;
- tests trimmed to two invariants (strict-probe survives churn; a raw-messages
  index realigns once on open, retired marker gone).
2026-09-18 10:38:41 -07:00

137 lines
5.4 KiB
Python

"""Regression for the ``messages_fts`` external-content drift (issue #114169).
The base word index used to read its external content from the raw ``messages``
table while the triggers indexed only a bounded prefix of every long tool row
and the boundary itself moved with a ``state_meta`` high-water marker. FTS5's
strict ``rank=1`` integrity check re-reads the content source and compares it
with the stored token stream, so that construction could not stay consistent: a
long tool row was enough to make the check report ``fts5: checksum mismatch``,
and the delete/update commands sent full content for a truncated row, leaving
index tokens behind.
These are behaviour contracts on the projection, not on its shape: the strict
probe must survive arbitrary churn, and an index left reading raw ``messages``
must realign once, on open.
"""
import sqlite3
import pytest
from hermes_state import SessionDB
from hermes_state_common import FTS_STORAGE_VERSION, FTS_TOOL_CONTENT_PREFIX_CHARS
LONG_TOOL_ROW = "prefix " + ("padding " * ((FTS_TOOL_CONTENT_PREFIX_CHARS // 8) + 64)) + " tailtoken"
def _strict_integrity_probe(db) -> None:
"""FTS5's strict check: re-reads the content source and compares it with the
stored token stream. Raises ``sqlite3.DatabaseError`` when they disagree."""
db._conn.execute(
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
)
@pytest.fixture
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
if not session_db._fts_enabled:
session_db.close()
pytest.skip("SQLite FTS5 unavailable")
session_db.create_session("session", source="cli")
try:
yield session_db
finally:
session_db.close()
def test_strict_integrity_probe_survives_tool_row_churn(db):
"""Every write path must leave the index readable by FTS5's own checker.
Pre-fix, the long-row assertion raises ``DatabaseError: fts5: checksum
mismatch for table "messages_fts"``: the row was indexed as a prefix while
``content='messages'`` kept the full body, so the checker disagreed with the
stored tokens. The UPDATE and DELETE legs are the triggers whose content
command used to be re-evaluated against a moving mark.
"""
_strict_integrity_probe(db)
long_id = db.append_message(
"session", role="tool", content=LONG_TOOL_ROW, tool_name="terminal"
)
_strict_integrity_probe(db)
db._execute_write(
lambda conn: conn.execute(
"UPDATE messages SET content = ? WHERE id = ?", ("short body", long_id)
)
)
_strict_integrity_probe(db)
db.append_message("session", role="user", content="short user row")
_strict_integrity_probe(db)
db._execute_write(
lambda conn: conn.execute("DELETE FROM messages WHERE id = ?", (long_id,))
)
_strict_integrity_probe(db)
def test_index_reading_raw_messages_realigns_once_on_open(tmp_path):
"""A store whose index still reads raw ``messages`` (the shipped shape for
versions before the aligned projection) realigns on the next open, records
the new storage version, and is consistent from then on."""
path = tmp_path / "state.db"
first = SessionDB(db_path=path)
if not first._fts_enabled:
first.close()
pytest.skip("SQLite FTS5 unavailable")
first.create_session("session", source="cli")
row_id = first.append_message(
"session", role="tool", content=LONG_TOOL_ROW, tool_name="terminal"
)
# Rewind to the pre-fix on-disk shape: the base index reads raw `messages`,
# and its tokens were written from the bounded projection the triggers used
# for tool rows. That mixture IS the drift: the checker re-reads the full
# body and disagrees with the stored prefix.
first._conn.execute("DROP TABLE messages_fts")
first._conn.execute(
"CREATE VIRTUAL TABLE messages_fts USING fts5("
"content, tool_name, tool_calls, content='messages', content_rowid='id')"
)
first._conn.execute(
"INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) VALUES(?, ?, ?, ?)",
(row_id, LONG_TOOL_ROW[:FTS_TOOL_CONTENT_PREFIX_CHARS], "terminal", None),
)
first._conn.execute(
"INSERT INTO state_meta(key, value) VALUES('fts_storage_version', '2') "
"ON CONFLICT(key) DO UPDATE SET value = '2'"
)
first._conn.execute(
"INSERT OR REPLACE INTO state_meta(key, value) VALUES('fts_tool_full_content_high_water', ?)",
(str(row_id),),
)
with pytest.raises(sqlite3.DatabaseError):
_strict_integrity_probe(first)
first.close()
migrated = SessionDB(db_path=path)
try:
assert migrated.get_meta("fts_storage_version") == str(FTS_STORAGE_VERSION)
assert migrated.get_meta("fts_tool_full_content_high_water") is None
_strict_integrity_probe(migrated)
index_sql = migrated._conn.execute(
"SELECT sql FROM sqlite_master WHERE name = 'messages_fts'"
).fetchone()[0]
assert "messages_fts_src" in index_sql
# The realigned index answers the prefix; the tail comes from the
# explicit tool path, which reads stored content rather than the index.
assert migrated.search_messages("prefix", role_filter=["tool"])
assert [
row["id"]
for row in migrated.search_messages("tailtoken", role_filter=["tool"])
] == [row_id]
finally:
migrated.close()