"""Tests for the single-shape session_search tool. Three calling shapes: 1. DISCOVERY — pass query → FTS5 + anchored window + bookends per hit 2. SCROLL — pass session_id + around_message_id → just the window 3. BROWSE — no args → recent sessions chronologically All run zero LLM calls. """ import json import time import pytest from hermes_state import SessionDB from tools.session_search_tool import ( SESSION_SEARCH_SCHEMA, _HIDDEN_SESSION_SOURCES, _format_timestamp, session_search, ) @pytest.fixture def db(tmp_path): return SessionDB(tmp_path / "state.db") def _seed_modpack_sessions(db): """Create three sessions about a modpack so FTS5 has hits to dedupe.""" now = int(time.time()) # Older session — modpack origin db.create_session("s_oldest", source="cli") db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", (now - 30000, "Building the Modpack", "s_oldest")) db.append_message("s_oldest", role="user", content="Let's build a Minecraft modpack") db.append_message("s_oldest", role="assistant", content="Great. Let me scaffold the modpack repo.") db.append_message("s_oldest", role="user", content="Use NeoForge 1.21.1") db.append_message("s_oldest", role="assistant", content="Done. Modpack repo created with NeoForge 1.21.1.") db.append_message("s_oldest", role="assistant", content="Tier-0 mods installed; modpack smoke test passes.") # Middle session — modpack quest coverage db.create_session("s_middle", source="cli") db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", (now - 15000, "Modpack Quest Coverage", "s_middle")) db.append_message("s_middle", role="user", content="Deep-dive every modpack reference quest guide") db.append_message("s_middle", role="assistant", content="Surveying ATM10 questbook for modpack inspiration.") db.append_message("s_middle", role="user", content="Update the modpack version too") db.append_message("s_middle", role="assistant", content="Modpack version bumped 0.4 → 0.8.5; quest coverage page added.") # Newest session — modpack mob spawn fix db.create_session("s_newest", source="cli") db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", (now - 1000, "Modpack Mob Spawn Fix", "s_newest")) db.append_message("s_newest", role="user", content="Fix the modpack mob spawning") db.append_message("s_newest", role="assistant", content="Investigating elite mob gating in the modpack KubeJS.") db.append_message("s_newest", role="assistant", content="Shipped commit b850442. Modpack alternator nerfed too.") db._conn.commit() # ========================================================================= # Schema invariants # ========================================================================= class TestSchema: def test_schema_has_required_params(self): params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] # Discovery shape assert "query" in params assert "limit" in params assert "sort" in params # Scroll shape assert "session_id" in params assert "around_message_id" in params assert "window" in params # Shared assert "role_filter" in params def test_no_mode_parameter(self): # Mode is inferred from which args are set — no explicit mode param params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] assert "mode" not in params def test_sort_enum(self): params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] assert params["sort"]["enum"] == ["newest", "oldest"] def test_schema_description_teaches_scroll(self): desc = SESSION_SEARCH_SCHEMA["description"] assert "SCROLL" in desc assert "DISCOVERY" in desc assert "BROWSE" in desc # Must explain how to scroll assert "scroll FORWARD" in desc or "messages[-1]" in desc def test_no_llm_promise_in_description(self): # The new design never calls an LLM desc = SESSION_SEARCH_SCHEMA["description"].lower() assert "no llm" in desc class TestHiddenSources: def test_tool_source_hidden(self): assert "tool" in _HIDDEN_SESSION_SOURCES class TestFormatTimestamp: def test_unix_timestamp(self): out = _format_timestamp(1700000000) assert "2023" in out def test_none(self): assert _format_timestamp(None) == "unknown" def test_iso_string_passthrough(self): out = _format_timestamp("not-a-number-string") assert out == "not-a-number-string" # ========================================================================= # Browse shape (no args) # ========================================================================= class TestBrowseShape: def test_no_args_returns_recent_sessions(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(db=db)) assert result["success"] is True assert result["mode"] == "browse" assert result["count"] >= 3 def test_browse_excludes_current_session(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(db=db, current_session_id="s_newest")) sids = [r["session_id"] for r in result["results"]] assert "s_newest" not in sids def test_browse_returns_titles(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(db=db)) titles = [r.get("title") for r in result["results"]] assert any("Modpack" in (t or "") for t in titles) # ========================================================================= # Discovery shape (with query) # ========================================================================= class TestDiscoveryShape: def test_query_returns_anchored_windows(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", db=db)) assert result["success"] is True assert result["mode"] == "discover" assert result["count"] >= 1 def test_discovery_result_has_bookends_and_window(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit=3, db=db)) for hit in result["results"]: assert "bookend_start" in hit assert "messages" in hit assert "bookend_end" in hit assert "match_message_id" in hit assert "snippet" in hit assert "messages_before" in hit assert "messages_after" in hit def test_match_message_id_is_anchor_in_window(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit=3, db=db)) for hit in result["results"]: anchor_id = hit["match_message_id"] window_ids = [m["id"] for m in hit["messages"]] assert anchor_id in window_ids def test_no_results_returns_empty_list(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="zzz_no_such_term_zzz", db=db)) assert result["success"] is True assert result["results"] == [] assert result["count"] == 0 def test_limit_clamped_to_max_10(self, db): _seed_modpack_sessions(db) # Pass huge limit; should not error and should cap result = json.loads(session_search(query="modpack", limit=999, db=db)) assert result["count"] <= 10 def test_limit_floor_to_1(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit=0, db=db)) # Result count depends on hits, but the limit must be at least 1 assert result["count"] >= 0 def test_non_int_limit_falls_back(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit="bogus", db=db)) assert result["success"] is True def test_current_session_filtered_out(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", db=db, current_session_id="s_newest")) sids = [r["session_id"] for r in result["results"]] assert "s_newest" not in sids class TestDiscoverySort: def test_sort_newest_orders_by_recency(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit=3, sort="newest", db=db)) # First result should be the most recent session first = result["results"][0] assert first["session_id"] == "s_newest" or "Newest" in (first.get("title") or "") def test_sort_oldest_orders_by_age(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query="modpack", limit=3, sort="oldest", db=db)) first = result["results"][0] assert first["session_id"] == "s_oldest" def test_invalid_sort_silently_ignored(self, db): _seed_modpack_sessions(db) # Should not error result = json.loads(session_search(query="modpack", sort="bogus", db=db)) assert result["success"] is True class TestRoleFilter: def test_default_excludes_tool_role(self, db): db.create_session("s1", source="cli") db.append_message("s1", role="user", content="modpack question") db.append_message("s1", role="tool", content="modpack tool output", tool_name="x") result = json.loads(session_search(query="modpack", db=db)) # The FTS5 match should be on the user message, not the tool message if result["count"] > 0: matched_role = result["results"][0]["matched_role"] assert matched_role in ("user", "assistant") def test_explicit_tool_role_includes_tool(self, db): db.create_session("s1", source="cli") db.append_message("s1", role="tool", content="modpack tool output", tool_name="x") result = json.loads(session_search(query="modpack", role_filter="tool", db=db)) # Should now match the tool message if result["count"] > 0: assert result["results"][0]["matched_role"] == "tool" # ========================================================================= # Scroll shape (session_id + around_message_id) # ========================================================================= class TestScrollShape: def test_scroll_returns_window_without_bookends(self, db): _seed_modpack_sessions(db) # Get an anchor first via discovery disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] # Now scroll result = json.loads(session_search( session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db )) assert result["success"] is True assert result["mode"] == "scroll" assert "messages" in result # Scroll shape has no bookends assert "bookend_start" not in result assert "bookend_end" not in result def test_scroll_window_clamped_to_20(self, db): _seed_modpack_sessions(db) disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( session_id=anchor_sid, around_message_id=anchor_mid, window=999, db=db )) assert result["window"] == 20 def test_scroll_window_floor_to_1(self, db): _seed_modpack_sessions(db) disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( session_id=anchor_sid, around_message_id=anchor_mid, window=-5, db=db )) assert result["window"] == 1 def test_scroll_returns_messages_before_after_counts(self, db): _seed_modpack_sessions(db) disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( session_id=anchor_sid, around_message_id=anchor_mid, window=3, db=db )) assert "messages_before" in result assert "messages_after" in result def test_scroll_anchor_in_window(self, db): _seed_modpack_sessions(db) disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db )) anchor_in_window = [m for m in result["messages"] if m["id"] == anchor_mid] assert len(anchor_in_window) == 1 assert anchor_in_window[0].get("anchor") is True def test_scroll_missing_anchor_errors(self, db): _seed_modpack_sessions(db) result = json.loads(session_search( session_id="s_oldest", around_message_id=999999, db=db )) assert result["success"] is False assert "not in" in result.get("error", "") def test_scroll_missing_session_errors(self, db): result = json.loads(session_search( session_id="nonexistent", around_message_id=1, db=db )) assert result["success"] is False def test_scroll_rejects_current_session_lineage(self, db): _seed_modpack_sessions(db) # Grab some valid id from s_oldest disc = json.loads(session_search(query="modpack", limit=3, db=db)) match = [r for r in disc["results"] if r["session_id"] == "s_oldest"] if match: mid = match[0]["match_message_id"] result = json.loads(session_search( session_id="s_oldest", around_message_id=mid, db=db, current_session_id="s_oldest", )) assert result["success"] is False assert "current session" in result.get("error", "").lower() def test_scroll_invalid_around_message_id_errors(self, db): _seed_modpack_sessions(db) result = json.loads(session_search( session_id="s_oldest", around_message_id="not-an-int", db=db )) assert result["success"] is False class TestScrollPattern: """The forward/backward scroll loop using tool output.""" def test_scroll_forward_from_last_id(self, db): # Long session db.create_session("s_long", source="cli") ids = [] for i in range(20): ids.append(db.append_message("s_long", role="user" if i % 2 == 0 else "assistant", content=f"long session msg {i}")) v1 = json.loads(session_search( session_id="s_long", around_message_id=ids[5], window=3, db=db )) last_id = v1["messages"][-1]["id"] v2 = json.loads(session_search( session_id="s_long", around_message_id=last_id, window=3, db=db )) # Forward scroll: v2 should reach further than v1 assert max(m["id"] for m in v2["messages"]) > max(m["id"] for m in v1["messages"]) # Boundary id appears in both assert last_id in [m["id"] for m in v1["messages"]] assert last_id in [m["id"] for m in v2["messages"]] # ========================================================================= # Shape precedence # ========================================================================= class TestShapePrecedence: def test_scroll_args_beat_query(self, db): _seed_modpack_sessions(db) disc = json.loads(session_search(query="modpack", limit=1, db=db)) anchor_sid = disc["results"][0]["session_id"] anchor_mid = disc["results"][0]["match_message_id"] # Pass both query and scroll args — scroll should win result = json.loads(session_search( query="modpack", # would normally trigger discovery session_id=anchor_sid, around_message_id=anchor_mid, db=db, )) assert result["mode"] == "scroll" def test_empty_query_falls_back_to_browse(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query=" ", db=db)) assert result["mode"] == "browse" def test_non_string_query_falls_back_to_browse(self, db): _seed_modpack_sessions(db) result = json.loads(session_search(query=None, db=db)) # type: ignore assert result["mode"] == "browse"