diff --git a/hermes_state_common.py b/hermes_state_common.py index 7de25e6db5..d482862808 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -367,6 +367,28 @@ SCHEMA_VERSION = 28 # tool-row-excluded trigram) FTS_STORAGE_VERSION = 1 +# Tool results are often multi-megabyte machine payloads. Index a useful +# prefix for new tool rows instead of tokenizing the entire body while the +# canonical message write holds SQLite's single writer lock. The high-water +# marker lets upgraded databases retain the exact token stream already stored +# for historical rows, so external-content delete/update commands stay valid +# without an eager full-index rebuild. +FTS_TOOL_CONTENT_PREFIX_CHARS = 8_192 +FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY = "fts_tool_full_content_high_water" + + +def _fts_indexed_content_sql(alias: str) -> str: + return f"""CASE WHEN {alias}.role = 'tool' + AND {alias}.id > COALESCE((SELECT CAST(value AS INTEGER) + FROM state_meta + WHERE key = '{FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY}'), -1) + THEN substr(COALESCE({alias}.content, ''), 1, {FTS_TOOL_CONTENT_PREFIX_CHARS}) + ELSE {alias}.content END""" + + +_FTS_NEW_INDEXED_CONTENT_SQL = _fts_indexed_content_sql("new") +_FTS_OLD_INDEXED_CONTENT_SQL = _fts_indexed_content_sql("old") + # Cap on user-controlled FTS5 query input before regex/sanitizer processing. # Search queries do not need to be arbitrarily large, and bounding them keeps @@ -672,7 +694,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_effective_activity # predicate into a tautology (id > -1 OR id <= -1), i.e. normal operation. # The two state_meta PK probes per write are negligible next to the FTS # insert itself. -FTS_SQL = """ +FTS_SQL = f""" CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, tool_name, @@ -688,7 +710,12 @@ WHEN (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) - VALUES (new.id, new.content, new.tool_name, new.tool_calls); + VALUES ( + new.id, + {_FTS_NEW_INDEXED_CONTENT_SQL}, + new.tool_name, + new.tool_calls + ); END; CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages @@ -698,26 +725,44 @@ WHEN (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) - VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls); + VALUES ( + 'delete', + old.id, + {_FTS_OLD_INDEXED_CONTENT_SQL}, + old.tool_name, + old.tool_calls + ); END; -- UPDATE OF skips the trigger entirely for non-content column writes -- (status/compacted/observed/etc.), which is stronger than the WHEN gate -- alone and avoids FTS I/O saturation on large state.db (#68858 / #73639). CREATE TRIGGER IF NOT EXISTS messages_fts_update -AFTER UPDATE OF content, tool_name, tool_calls ON messages +AFTER UPDATE OF content, tool_name, tool_calls, role ON messages WHEN (old.content IS NOT new.content OR old.tool_name IS NOT new.tool_name - OR old.tool_calls IS NOT new.tool_calls) + OR old.tool_calls IS NOT new.tool_calls + OR old.role IS NOT new.role) AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_high_water'), -1) OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta WHERE key = 'fts_rebuild_progress'), -1)) BEGIN INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) - VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls); + VALUES ( + 'delete', + old.id, + {_FTS_OLD_INDEXED_CONTENT_SQL}, + old.tool_name, + old.tool_calls + ); INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) - VALUES (new.id, new.content, new.tool_name, new.tool_calls); + VALUES ( + new.id, + {_FTS_NEW_INDEXED_CONTENT_SQL}, + new.tool_name, + new.tool_calls + ); END; """ @@ -830,7 +875,7 @@ FTS_REBUILD_DEFERRAL_KEY = "fts_rebuild_deferral" # (which would create the external-content trigram source VIEW and leave the # DB in a mixed, broken state). `optimize_fts_storage()` is what migrates a # legacy DB to the v23 shape. -LEGACY_FTS_SQL = """ +LEGACY_FTS_SQL = f""" CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content ); @@ -838,7 +883,8 @@ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES ( new.id, - COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') + COALESCE({_FTS_NEW_INDEXED_CONTENT_SQL}, '') + || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; @@ -847,17 +893,18 @@ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN END; CREATE TRIGGER IF NOT EXISTS messages_fts_update -AFTER UPDATE OF content, tool_name, tool_calls ON messages BEGIN +AFTER UPDATE OF content, tool_name, tool_calls, role ON messages BEGIN DELETE FROM messages_fts WHERE rowid = old.id; INSERT INTO messages_fts(rowid, content) VALUES ( new.id, - COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') + COALESCE({_FTS_NEW_INDEXED_CONTENT_SQL}, '') + || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; """ -LEGACY_FTS_TRIGRAM_SQL = """ +LEGACY_FTS_TRIGRAM_SQL = f""" CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( content, tokenize='trigram' @@ -866,7 +913,8 @@ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5( CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts_trigram(rowid, content) VALUES ( new.id, - COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') + COALESCE({_FTS_NEW_INDEXED_CONTENT_SQL}, '') + || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; @@ -875,11 +923,12 @@ CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON message END; CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update -AFTER UPDATE OF content, tool_name, tool_calls ON messages BEGIN +AFTER UPDATE OF content, tool_name, tool_calls, role ON messages BEGIN DELETE FROM messages_fts_trigram WHERE rowid = old.id; INSERT INTO messages_fts_trigram(rowid, content) VALUES ( new.id, - COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') + COALESCE({_FTS_NEW_INDEXED_CONTENT_SQL}, '') + || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '') ); END; """ diff --git a/hermes_state_schema.py b/hermes_state_schema.py index 01801a4870..de6c383334 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -26,6 +26,7 @@ from hermes_state_common import ( FTS_STALE_KEY, FTS_SQL, FTS_STORAGE_VERSION, + FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_SQL, LEGACY_FTS_SQL, LEGACY_FTS_TRIGRAM_SQL, @@ -299,6 +300,83 @@ class SessionSchemaMixin: ) return len(to_drop) + @staticmethod + def _execute_ddl_script_transactional( + cursor: sqlite3.Cursor, ddl: str + ) -> None: + """Execute a DDL script without ``executescript``'s implicit commit.""" + statement = "" + for line in ddl.splitlines(): + statement += line + "\n" + if sqlite3.complete_statement(statement): + cursor.execute(statement) + statement = "" + if statement.strip(): + raise sqlite3.OperationalError("incomplete FTS DDL statement") + + def _migrate_bounded_tool_fts_triggers( + self, cursor: sqlite3.Cursor, *, legacy: bool + ) -> None: + """Replace FTS triggers without rebuilding historical indexes. + + Existing rows keep their original full-content token stream. The + durable high-water id makes new tool rows use the bounded prefix in + both INSERT and matching external-content delete/update operations. + Trigger replacement is one savepoint so no concurrent writer can land + in a trigger gap. + """ + marker = cursor.execute( + "SELECT 1 FROM state_meta WHERE key = ? LIMIT 1", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY,), + ).fetchone() + if marker is not None: + return + + trigram_present = cursor.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'messages_fts_trigram'" + ).fetchone() is not None + names = _FTS_BASE_TRIGGERS + if legacy and trigram_present: + names += _FTS_TRIGRAM_TRIGGERS + existing = self._fts_trigger_count(cursor, names) + has_messages = cursor.execute( + "SELECT 1 FROM messages LIMIT 1" + ).fetchone() is not None + table_present = cursor.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'messages_fts'" + ).fetchone() is not None + self._fts_tool_prefix_migration_requires_rebuild = bool( + table_present and has_messages and existing < len(names) + ) + + cursor.execute("SAVEPOINT bounded_tool_fts") + try: + high_water = cursor.execute( + "SELECT COALESCE(MAX(id), 0) FROM messages" + ).fetchone()[0] + cursor.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)), + ) + for name in names: + cursor.execute(f"DROP TRIGGER IF EXISTS {name}") + if legacy: + self._execute_ddl_script_transactional(cursor, LEGACY_FTS_SQL) + if trigram_present: + self._execute_ddl_script_transactional( + cursor, LEGACY_FTS_TRIGRAM_SQL + ) + else: + self._execute_ddl_script_transactional(cursor, FTS_SQL) + cursor.execute("RELEASE SAVEPOINT bounded_tool_fts") + except BaseException: + cursor.execute("ROLLBACK TO SAVEPOINT bounded_tool_fts") + cursor.execute("RELEASE SAVEPOINT bounded_tool_fts") + raise + def _cjk_update_trigger_is_narrowed(self, cursor: sqlite3.Cursor) -> bool: """True when messages_fts_cjk_update exists with AFTER UPDATE OF.""" row = cursor.execute( @@ -343,6 +421,14 @@ class SessionSchemaMixin: *, include_trigram: bool = True, ) -> None: + high_water = cursor.execute( + "SELECT COALESCE(MAX(id), 0) FROM messages" + ).fetchone()[0] + cursor.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)), + ) # Both FTS tables are external-content (v23+): the special 'rebuild' # command wipes the inverted index and repopulates it from the # content source (messages for the standard index, the tool-row- @@ -373,6 +459,14 @@ class SessionSchemaMixin: 'rebuild' source, so we DELETE + reinsert the concatenated content the legacy triggers produced. Never touches the v23 shape. """ + high_water = cursor.execute( + "SELECT COALESCE(MAX(id), 0) FROM messages" + ).fetchone()[0] + cursor.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)), + ) cursor.execute("DELETE FROM messages_fts") cursor.execute( "INSERT INTO messages_fts(rowid, content) " @@ -1488,6 +1582,10 @@ class SessionSchemaMixin: # v23 view/external tables entirely. Fresh installs and opted-in # DBs have no legacy inline FTS, so they get the v23 DDL. legacy_fts = self._db_has_legacy_inline_fts(cursor) + if not self._fts_stale: + self._migrate_bounded_tool_fts_triggers( + cursor, legacy=legacy_fts + ) if self._fts_stale: if self._recover_stale_fts(cursor, legacy=legacy_fts): # CJK was detached alongside the corrupt base indexes and @@ -1506,6 +1604,8 @@ class SessionSchemaMixin: base_triggers_missing = ( self._fts_trigger_count(cursor, _FTS_BASE_TRIGGERS) < len(_FTS_BASE_TRIGGERS) + ) or getattr( + self, "_fts_tool_prefix_migration_requires_rebuild", False ) trigram_triggers_missing = ( self._fts_trigger_count(cursor, _FTS_TRIGRAM_TRIGGERS) @@ -1533,6 +1633,8 @@ class SessionSchemaMixin: base_triggers_missing = ( self._fts_trigger_count(cursor, _FTS_BASE_TRIGGERS) < len(_FTS_BASE_TRIGGERS) + ) or getattr( + self, "_fts_tool_prefix_migration_requires_rebuild", False ) trigram_triggers_missing = ( self._fts_trigger_count(cursor, _FTS_TRIGRAM_TRIGGERS) diff --git a/hermes_state_search.py b/hermes_state_search.py index 5ff68b60ce..7c17b49ae4 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -22,6 +22,8 @@ from hermes_state_common import ( FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, + FTS_TOOL_CONTENT_PREFIX_CHARS, + FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_SQL, MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, @@ -152,11 +154,14 @@ class SessionSearchMixin: lo, hi = hw - 1000, hw + 1000 conn.execute( "INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) " - "SELECT m.id, m.content, m.tool_name, m.tool_calls " + "SELECT m.id, " + "CASE WHEN m.role = 'tool' AND m.id > ? " + "THEN substr(COALESCE(m.content, ''), 1, ?) " + "ELSE m.content END, m.tool_name, m.tool_calls " "FROM messages m " "WHERE m.id > ? AND m.id <= ? " "AND NOT EXISTS (SELECT 1 FROM messages_fts_docsize d WHERE d.id = m.id)", - (lo, hi), + (hw, FTS_TOOL_CONTENT_PREFIX_CHARS, lo, hi), ) if include_trigram: conn.execute( @@ -568,6 +573,11 @@ class SessionSearchMixin: "('fts_rebuild_progress', '0') " "ON CONFLICT(key) DO UPDATE SET value = excluded.value" ) + conn.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(hw)), + ) return hw hw = conn.execute( @@ -576,6 +586,7 @@ class SessionSearchMixin: for k, v in ( ("fts_rebuild_high_water", str(hw)), ("fts_rebuild_progress", "0"), + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(hw)), ): conn.execute( "INSERT INTO state_meta (key, value) VALUES (?, ?) " @@ -1760,6 +1771,24 @@ class SessionSearchMixin: if not query: return [] + # New oversized tool results only index a bounded prefix to keep the + # foreground write transaction short. An explicit tool-role search is + # the opt-in full-body path and scans canonical rows via LIKE. + if role_filter and "tool" in role_filter: + matches = self._search_messages_like_fallback( + query, + source_filter=source_filter, + exclude_sources=exclude_sources, + role_filter=role_filter, + limit=limit, + offset=offset, + sort=sort, + include_inactive=include_inactive, + ) + return self._finalize_search_matches( + matches, result_fields=result_fields + ) + self._refresh_fts_stale_state() if self._fts_stale: matches = self._search_messages_like_fallback( @@ -2398,6 +2427,14 @@ class SessionSearchMixin: ) return 0 with self._lock: + high_water = self._conn.execute( + "SELECT COALESCE(MAX(id), 0) FROM messages" + ).fetchone()[0] + self._conn.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)), + ) for tbl in self._FTS_TABLES: if not self._fts_table_exists(tbl): continue diff --git a/tests/test_fts_tool_write_bounds.py b/tests/test_fts_tool_write_bounds.py new file mode 100644 index 0000000000..fb38bd60d4 --- /dev/null +++ b/tests/test_fts_tool_write_bounds.py @@ -0,0 +1,204 @@ +import sqlite3 + +import pytest + +from hermes_state import SessionDB +from hermes_state_common import ( + FTS_TOOL_CONTENT_PREFIX_CHARS, + FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, + LEGACY_FTS_SQL, + _FTS_TRIGGERS, +) + + +def _long_message(prefix: str, tail: str) -> str: + padding = "padding " * (FTS_TOOL_CONTENT_PREFIX_CHARS // len("padding ") + 8) + return f"{prefix} {padding} {tail}" + + +@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_new_tool_rows_bound_fts_content_but_explicit_tool_search_is_complete(db): + tool_id = db.append_message( + "session", + role="tool", + content=_long_message("indexed-prefix-token", "tool-tail-token"), + tool_name="terminal", + ) + user_id = db.append_message( + "session", + role="user", + content=_long_message("user-prefix-token", "user-tail-token"), + ) + + assert [row["id"] for row in db.search_messages("indexed-prefix-token")] == [ + tool_id + ] + assert db.search_messages("tool-tail-token") == [] + assert [ + row["id"] + for row in db.search_messages("tool-tail-token", role_filter=["tool"]) + ] == [tool_id] + assert [row["id"] for row in db.search_messages("user-tail-token")] == [ + user_id + ] + + +def test_trigger_migration_preserves_historical_tool_tokens_without_rebuild(tmp_path): + 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") + + # Model the pre-migration trigger contract: every id through this artificial + # boundary receives full-content indexing. + first.set_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(2**62)) + old_id = first.append_message( + "session", + role="tool", + content=_long_message("old-prefix-token", "old-tail-token"), + ) + assert [row["id"] for row in first.search_messages("old-tail-token")] == [old_id] + first._conn.execute( + "DELETE FROM state_meta WHERE key = ?", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY,), + ) + first.close() + + migrated = SessionDB(db_path=path) + try: + assert int(migrated.get_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY)) == old_id + assert [ + row["id"] for row in migrated.search_messages("old-tail-token") + ] == [old_id] + + new_id = migrated.append_message( + "session", + role="tool", + content=_long_message("new-prefix-token", "new-tail-token"), + ) + assert migrated.search_messages("new-tail-token") == [] + assert [ + row["id"] + for row in migrated.search_messages( + "new-tail-token", role_filter=["tool"] + ) + ] == [new_id] + + # Historical rows still use their full old token stream for the FTS5 + # external-content delete command; redaction must remove the tail token. + migrated._execute_write( + lambda conn: conn.execute( + "UPDATE messages SET content = '' WHERE id = ?", (old_id,) + ) + ) + assert migrated.search_messages("old-tail-token") == [] + + # New bounded rows use the same prefix for delete as insert. A mismatch + # corrupts external-content FTS and makes this delete or later write fail. + migrated._execute_write( + lambda conn: conn.execute("DELETE FROM messages WHERE id = ?", (new_id,)) + ) + migrated.append_message("session", role="assistant", content="fts-still-healthy") + assert migrated.search_messages("fts-still-healthy") + finally: + migrated.close() + + +def test_full_rebuild_moves_boundary_before_future_tool_writes(db): + before_id = db.append_message( + "session", + role="tool", + content=_long_message("before-prefix-token", "before-tail-token"), + ) + assert db.search_messages("before-tail-token") == [] + + assert db.rebuild_fts() >= 1 + assert int(db.get_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY)) == before_id + assert [row["id"] for row in db.search_messages("before-tail-token")] == [ + before_id + ] + + after_id = db.append_message( + "session", + role="tool", + content=_long_message("after-prefix-token", "after-tail-token"), + ) + assert db.search_messages("after-tail-token") == [] + assert [ + row["id"] + for row in db.search_messages("after-tail-token", role_filter=["tool"]) + ] == [after_id] + + +def test_role_changes_switch_between_bounded_and_full_indexing(db): + message_id = db.append_message( + "session", + role="tool", + content=_long_message("role-prefix-token", "role-tail-token"), + ) + assert db.search_messages("role-tail-token") == [] + + db._execute_write( + lambda conn: conn.execute( + "UPDATE messages SET role = 'assistant' WHERE id = ?", (message_id,) + ) + ) + assert [row["id"] for row in db.search_messages("role-tail-token")] == [ + message_id + ] + + db._execute_write( + lambda conn: conn.execute( + "UPDATE messages SET role = 'tool' WHERE id = ?", (message_id,) + ) + ) + assert db.search_messages("role-tail-token") == [] + + +def test_legacy_inline_fts_also_bounds_new_tool_rows(tmp_path): + path = tmp_path / "legacy.db" + initial = SessionDB(db_path=path) + initial.create_session("session", source="cli") + for trigger in _FTS_TRIGGERS: + initial._conn.execute(f"DROP TRIGGER IF EXISTS {trigger}") + initial._conn.execute("DROP TABLE IF EXISTS messages_fts_trigram") + initial._conn.execute("DROP VIEW IF EXISTS messages_fts_trigram_src") + initial._conn.execute("DROP TABLE IF EXISTS messages_fts") + initial._conn.executescript(LEGACY_FTS_SQL) + initial._conn.execute( + "DELETE FROM state_meta WHERE key IN (?, 'fts_storage_version')", + (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY,), + ) + initial.close() + + legacy = SessionDB(db_path=path) + try: + assert legacy._db_has_legacy_inline_fts(legacy._conn.cursor()) is True + message_id = legacy.append_message( + "session", + role="tool", + content=_long_message("legacy-prefix-token", "legacy-tail-token"), + ) + assert legacy.search_messages("legacy-tail-token") == [] + assert [ + row["id"] + for row in legacy.search_messages( + "legacy-tail-token", role_filter=["tool"] + ) + ] == [message_id] + finally: + legacy.close()