"""Delegate-child (subagent) transcripts stay out of the trigram FTS index (v30). Mirrors ``test_fts_trigram_cron_exclusion.py``: children are canonical rows in ``messages`` and stay searchable through the standard ``messages_fts`` word index; only the trigram (CJK substring) shadow index skips them. """ from __future__ import annotations import pytest from hermes_state import SCHEMA_VERSION, SessionDB from hermes_state_common import FTS_TRIGRAM_EXCLUDED_SOURCES, fts_trigram_session_sql @pytest.fixture def db(tmp_path): session_db = SessionDB(db_path=tmp_path / "state.db") if not session_db._trigram_available: session_db.close() pytest.skip("trigram tokenizer unavailable in this SQLite build") yield session_db session_db.close() def _trigram_rowids(db: SessionDB) -> set[int]: return { row[0] for row in db._conn.execute("SELECT id FROM messages_fts_trigram_docsize").fetchall() } def _fts_rowids(db: SessionDB) -> set[int]: return { row[0] for row in db._conn.execute("SELECT id FROM messages_fts_docsize").fetchall() } def _seed(db: SessionDB) -> dict[str, int]: db.create_session("root", source="cli") # delegate_tool children: source='subagent' via platform, plus the # _delegate_from creation marker. db.create_session( "kid", source="subagent", parent_session_id="root", model_config={"_delegate_from": "root"}, ) # A child spawned under a gateway turn inherits the gateway's source but # still carries the marker. db.create_session( "gw-kid", source="telegram", parent_session_id="root", model_config={"_delegate_from": "root"}, ) # Compression continuation: parent_session_id but NO marker -> indexed. db.create_session("cont", source="cli", parent_session_id="root") return { "root": db.append_message("root", role="user", content="交付状态正常 root-word"), "kid": db.append_message("kid", role="assistant", content="子任务状态正常 kid-word"), "gw-kid": db.append_message("gw-kid", role="assistant", content="网关子任务 gwkid-word"), "cont": db.append_message("cont", role="assistant", content="继续会话内容 cont-word"), } def test_subagent_rows_skip_trigram_but_stay_in_standard_fts(db: SessionDB): ids = _seed(db) assert _trigram_rowids(db) == {ids["root"], ids["cont"]} assert _fts_rowids(db) >= set(ids.values()) def test_subagent_rows_remain_word_searchable(db: SessionDB): _seed(db) assert [r["session_id"] for r in db.search_messages("kid-word")] == ["kid"] assert [r["session_id"] for r in db.search_messages("gwkid-word")] == ["gw-kid"] # Explicit CJK search scoped to the excluded source falls back to LIKE. assert [ r["session_id"] for r in db.search_messages("子任务状态", source_filter=["subagent"]) ] == ["kid"] # Top-level CJK substring search unaffected. assert [r["session_id"] for r in db.search_messages("交付状态")] == ["root"] def test_update_and_delete_of_unindexed_child_row_keep_trigram_consistent(db: SessionDB): ids = _seed(db) db._conn.execute( "UPDATE messages SET content = ? WHERE id = ?", ("改写后的内容", ids["kid"]) ) db._conn.execute("DELETE FROM messages WHERE id = ?", (ids["kid"],)) db._conn.execute( "INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')" ) assert _trigram_rowids(db) == {ids["root"], ids["cont"]} def test_deferred_rebuild_does_not_reintroduce_children(db: SessionDB): ids = _seed(db) with db._lock: db._reset_fts_index_to_empty(db._conn) db._seed_fts_rebuild_markers(db._conn, force=True) db._conn.commit() while db.fts_rebuild_step(): pass assert _trigram_rowids(db) == {ids["root"], ids["cont"]} assert _fts_rowids(db) >= set(ids.values()) def test_full_rebuild_honours_exclusion(db: SessionDB): ids = _seed(db) db.rebuild_fts() assert _trigram_rowids(db) == {ids["root"], ids["cont"]} def test_v29_install_purges_child_rows_on_upgrade(tmp_path): db_path = tmp_path / "state.db" old = SessionDB(db_path=db_path) if not old._trigram_available: old.close() pytest.skip("trigram tokenizer unavailable in this SQLite build") # Recreate the v29 (cron-only) view/trigger boundary. old._conn.executescript( """ DROP TRIGGER messages_fts_trigram_insert; DROP TRIGGER messages_fts_trigram_delete; DROP TRIGGER messages_fts_trigram_update; DROP VIEW messages_fts_trigram_src; CREATE VIEW messages_fts_trigram_src AS SELECT m.id, m.role, m.content, m.tool_name FROM messages AS m JOIN sessions AS s ON s.id = m.session_id WHERE m.role <> 'tool' AND s.source <> 'cron'; CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages WHEN new.role <> 'tool' AND EXISTS (SELECT 1 FROM sessions WHERE id = new.session_id AND source <> 'cron') BEGIN INSERT INTO messages_fts_trigram(rowid, content, tool_name) VALUES (new.id, new.content, new.tool_name); END; """ ) ids = _seed(old) assert _trigram_rowids(old) == set(ids.values()) old._conn.execute("UPDATE schema_version SET version = 29") old._conn.commit() old.close() migrated = SessionDB(db_path=db_path) try: assert _trigram_rowids(migrated) == {ids["root"], ids["cont"]} assert migrated._conn.execute( "SELECT version FROM schema_version" ).fetchone()[0] == SCHEMA_VERSION migrated._conn.execute( "INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('integrity-check')" ) finally: migrated.close() def test_predicate_constants_agree(): assert "subagent" in FTS_TRIGRAM_EXCLUDED_SOURCES assert "cron" in FTS_TRIGRAM_EXCLUDED_SOURCES sql = fts_trigram_session_sql("s") assert sql.startswith("s.source NOT IN (") and "s.model_config" in sql