hermes-agent/tests/tools/test_session_search.py
Teknium e907ecccef test(search): prove discovery scope and bookend bounding work together on compacted sessions
Integration test for the two compaction layers landing this sweep:
#63144's discovery-scope fix (archived rows surface from the current
session) and #69334's bookend bounding (summary exclusion + content
caps). A single compacted session exercises both: the archived FTS hit
must surface while the compaction handoff at the session tail stays out
of bookend_end and long messages stay capped.
2026-07-22 16:59:09 -07:00

1221 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
_is_compacted_message,
_is_compression_ended,
_resolve_to_parent,
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
def test_schema_description_enforces_source_first_limit(self):
desc = SESSION_SEARCH_SCHEMA["description"].lower()
assert "source-first limit" in desc
assert "conversation history only" in desc
assert "direct source" in desc
assert "session_search as secondary" in desc
assert "not found" 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_query_can_match_session_title_without_message_hit(self, db):
db.create_session("s_fingerprint", source="cli")
db.set_session_title("s_fingerprint", "fingerprint-login")
db.append_message("s_fingerprint", role="user", content="Let's configure PAM for biometric auth")
db.append_message("s_fingerprint", role="assistant", content="Checking Linux auth settings.")
result = json.loads(session_search(query="fingerprint-login", db=db))
assert result["success"] is True
assert result["count"] == 1
hit = result["results"][0]
assert hit["session_id"] == "s_fingerprint"
assert hit["title"] == "fingerprint-login"
assert hit["matched_role"] == "session_title"
assert "Session title matched" in hit["snippet"]
def test_title_query_strips_common_model_quoting(self, db):
db.create_session("s_fingerprint", source="cli")
db.set_session_title("s_fingerprint", "fingerprint-login")
db.append_message("s_fingerprint", role="user", content="PAM auth setup")
result = json.loads(session_search(query="`fingerprint-login`", db=db))
assert result["success"] is True
assert result["results"][0]["session_id"] == "s_fingerprint"
assert result["results"][0]["matched_role"] == "session_title"
def test_title_match_respects_current_session_filter(self, db):
db.create_session("s_current", source="cli")
db.set_session_title("s_current", "fingerprint-login")
db.append_message("s_current", role="user", content="PAM auth setup")
result = json.loads(session_search(
query="fingerprint-login",
current_session_id="s_current",
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"
def test_session_id_without_anchor_reads(self, db):
_seed_modpack_sessions(db)
# session_id alone (no anchor, no query) → read shape, not browse.
result = json.loads(session_search(session_id="s_oldest", db=db))
assert result["mode"] == "read"
# =========================================================================
# Read shape — dump a whole session by id (serves @session links)
# =========================================================================
class TestReadShape:
def test_read_returns_full_session(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(session_id="s_oldest", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["session_id"] == "s_oldest"
assert result["message_count"] == 5
assert result["truncated"] is False
assert len(result["messages"]) == 5
assert result["session_meta"]["title"] == "Building the Modpack"
def test_read_unknown_session_errors(self, db):
result = json.loads(session_search(session_id="ghost", db=db))
assert result["success"] is False
def test_read_truncates_large_session(self, db):
db.create_session("s_big", source="cli")
for i in range(50):
db.append_message("s_big", role="user" if i % 2 == 0 else "assistant", content=f"m{i}")
db._conn.commit()
result = json.loads(session_search(session_id="s_big", db=db))
assert result["mode"] == "read"
assert result["message_count"] == 50
assert result["truncated"] is True
assert len(result["messages"]) == 30 # head 20 + tail 10
# =========================================================================
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
# =========================================================================
class TestCrossProfileRead:
def _patch_profiles(self, monkeypatch, home, exists=True):
from hermes_cli import profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "validate_profile_name", lambda n: None)
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: exists)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: home)
def test_profile_param_reads_other_db(self, db, tmp_path, monkeypatch):
other_home = tmp_path / "other_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_other", source="cli")
other._conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?", ("Other Profile Chat", "s_other")
)
other.append_message("s_other", role="user", content="hello from the other profile")
other._conn.commit()
self._patch_profiles(monkeypatch, other_home)
# s_other lives only in the other profile; the current `db` lacks it.
result = json.loads(session_search(session_id="s_other", profile="other", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["session_meta"]["title"] == "Other Profile Chat"
def test_bare_id_locates_across_profiles(self, db, tmp_path, monkeypatch):
# The real-world failure: model dropped the owning profile and passed a
# bare id. The tool must scan profiles and find it anyway.
other_home = tmp_path / "asdf_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_far", source="cli")
other.append_message("s_far", role="user", content="hi")
other._conn.commit()
from collections import namedtuple
from hermes_cli import profiles as profiles_mod
Info = namedtuple("Info", "name path")
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: tmp_path / "default_home")
monkeypatch.setattr(profiles_mod, "list_profiles", lambda: [Info("asdf", other_home)])
# `db` (current profile) lacks s_far; no profile passed → scan finds it.
result = json.loads(session_search(session_id="s_far", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["profile"] == "asdf"
def test_unknown_profile_errors(self, db, monkeypatch, tmp_path):
self._patch_profiles(monkeypatch, tmp_path, exists=False)
result = json.loads(session_search(session_id="x", profile="ghost", db=db))
assert result["success"] is False
assert "ghost" in result.get("error", "")
def test_combined_value_autosplits(self, db, tmp_path, monkeypatch):
# Agent passed the raw "@session:<profile>/<id>" value as session_id with
# no separate profile — the tool should recover both.
other_home = tmp_path / "other_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_other", source="cli")
other.append_message("s_other", role="user", content="hi")
other._conn.commit()
self._patch_profiles(monkeypatch, other_home)
# Every permutation the model might send must resolve to (asdf, s_other).
for kwargs in (
{"session_id": "asdf/s_other"}, # full value, no profile
{"session_id": "asdf/s_other", "profile": "asdf"}, # full value AND profile
{"session_id": "s_other", "profile": "asdf"}, # bare id + profile
):
result = json.loads(session_search(db=db, **kwargs))
assert result["success"] is True, kwargs
assert result["mode"] == "read"
assert result["session_id"] == "s_other"
# =========================================================================
# Cron demotion in discover ranking (#19434)
# =========================================================================
class TestCronDemotion:
def _seed_cron_and_interactive(self, db):
"""One interactive (telegram) session and several cron sessions, all
matching the same query. Cron rows accumulate repetitive vocabulary
and out-number the user's single interactive session — the live-data
symptom in #19434.
"""
now = int(time.time())
# Interactive user session — older, so it loses on bare recency too.
db.create_session("s_user", source="telegram")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?",
(now - 90000, "s_user"))
db.append_message("s_user", role="user", content="how is the venom project going")
db.append_message("s_user", role="assistant", content="The venom project shipped its first milestone.")
# Several cron sessions, all newer and all stuffed with the same terms.
for i in range(8):
sid = f"cron_{i}"
db.create_session(sid, source="cron")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?",
(now - 1000 - i, sid))
db.append_message(sid, role="user", content="venom project daily status")
db.append_message(sid, role="assistant", content="venom project venom project venom summary")
db._conn.commit()
def test_interactive_session_surfaces_above_cron(self, db):
self._seed_cron_and_interactive(db)
result = json.loads(session_search(query="venom project", limit=1, db=db))
assert result["success"] is True
assert result["count"] == 1
# With cron drowning FTS, bare BM25/recency would return a cron_* hit.
# Demotion must put the user's interactive session first.
assert result["results"][0]["source"] == "telegram"
assert result["results"][0]["session_id"] == "s_user"
def test_cron_still_reachable_when_only_match(self, db):
"""Demotion must not exclude cron — when only cron matches, it still
comes back."""
now = int(time.time())
db.create_session("cron_only", source="cron")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?",
(now - 500, "cron_only"))
db.append_message("cron_only", role="user", content="quarterly archive sweep")
db.append_message("cron_only", role="assistant", content="Archive sweep complete.")
db._conn.commit()
result = json.loads(session_search(query="archive sweep", db=db))
assert result["success"] is True
assert result["count"] == 1
assert result["results"][0]["source"] == "cron"
def test_order_for_recall_is_stable_within_class(self):
from tools.session_search_tool import _order_for_recall
rows = [
{"id": 1, "source": "cron"},
{"id": 2, "source": "telegram"},
{"id": 3, "source": "cron"},
{"id": 4, "source": "cli"},
{"id": 5, "source": None},
]
ordered = _order_for_recall(rows)
# Interactive rows first, in original relative order; cron last, in
# original relative order.
assert [r["id"] for r in ordered] == [2, 4, 5, 1, 3]
# =========================================================================
# Compaction summary filtering (#43175)
# =========================================================================
class TestCompactionSummaryFiltering:
"""session_search discovery must exclude compaction handoffs from bookends."""
def test_is_compaction_summary_detects_prefix(self):
from tools.session_search_tool import _is_compaction_summary
assert _is_compaction_summary("[CONTEXT COMPACTION — REFERENCE ONLY] foo")
assert _is_compaction_summary("[CONTEXT SUMMARY]: old summary")
assert not _is_compaction_summary("Hello, how can I help?")
assert not _is_compaction_summary("")
assert not _is_compaction_summary(None)
def test_compaction_summary_excluded_from_bookend_start(self, db):
"""Compaction handoff in bookend_start position must be filtered out."""
db.create_session("s_compact", source="cli")
# First message: a compaction handoff (should be filtered)
db.append_message("s_compact", role="user",
content="[CONTEXT COMPACTION — REFERENCE ONLY] "
"Earlier turns were compacted into the summary below. " + "x" * 50000)
# Second message: normal user message
db.append_message("s_compact", role="user", content="Fix the zorgblat rendering bug")
# Padding messages to push window away from session start (so bookend has room)
for i in range(10):
db.append_message("s_compact", role="user", content=f"setup step {i}")
db.append_message("s_compact", role="assistant", content=f"setup done {i}")
# Match target: uses a unique term so FTS5 anchors here, not at the start
db.append_message("s_compact", role="user", content="investigate the frobnitz mob spawning in KubeJS")
db.append_message("s_compact", role="assistant", content="I'll look into the frobnitz mob spawning issue.")
# Tail messages
for i in range(5):
db.append_message("s_compact", role="user", content=f"tail {i}")
db.append_message("s_compact", role="assistant", content=f"done tail {i}")
db._conn.commit()
result = json.loads(session_search(query="frobnitz mob spawning", db=db, limit=1))
assert result["success"] is True
assert len(result["results"]) >= 1
entry = result["results"][0]
# bookend_start must NOT contain the compaction handoff
for msg in entry.get("bookend_start", []):
assert "[CONTEXT COMPACTION" not in (msg.get("content") or "")
# The normal message should still be present in bookend_start
bookend_contents = [m.get("content", "") for m in entry.get("bookend_start", [])]
assert any("zorgblat" in c for c in bookend_contents)
def test_compaction_summary_excluded_from_bookend_end(self, db):
"""Compaction handoff in bookend_end position must be filtered out."""
db.create_session("s_compact_end", source="cli")
# Normal opening
db.append_message("s_compact_end", role="user", content="Build a website")
db.append_message("s_compact_end", role="assistant", content="Sure, let me scaffold it.")
# Match target (early in session so bookend_end has room)
db.append_message("s_compact_end", role="user", content="fix the zorgblat rendering bug")
db.append_message("s_compact_end", role="assistant", content="Investigating the zorgblat rendering issue.")
# Many messages to create distance from the end
for i in range(10):
db.append_message("s_compact_end", role="user", content=f"feature {i}")
db.append_message("s_compact_end", role="assistant", content=f"implemented {i}")
# Last message: compaction handoff (should be filtered from bookend_end)
db.append_message("s_compact_end", role="assistant",
content="[CONTEXT COMPACTION — REFERENCE ONLY] "
"Summary of all work done. " + "y" * 50000)
db._conn.commit()
result = json.loads(session_search(query="zorgblat rendering", db=db, limit=1))
assert result["success"] is True
assert len(result["results"]) >= 1
entry = result["results"][0]
# bookend_end must NOT contain the compaction handoff
for msg in entry.get("bookend_end", []):
assert "[CONTEXT COMPACTION" not in (msg.get("content") or "")
def test_bookend_content_is_capped(self, db):
"""Bookend messages must have content capped at 1200 chars."""
db.create_session("s_long_bookend", source="cli")
# First message: very long normal content
db.append_message("s_long_bookend", role="user",
content="Start the project. " + "z" * 5000)
# Match target
db.append_message("s_long_bookend", role="user", content="deploy to production")
db.append_message("s_long_bookend", role="assistant", content="Deploying now.")
for i in range(10):
db.append_message("s_long_bookend", role="user", content=f"step {i}")
db.append_message("s_long_bookend", role="assistant", content=f"done {i}")
db._conn.commit()
result = json.loads(session_search(query="deploy production", db=db, limit=1))
assert result["success"] is True
entry = result["results"][0]
for msg in entry.get("bookend_start", []):
content = msg.get("content", "")
# Content should be capped (1200 chars + "…" ellipsis)
assert len(content) <= 1210 # 1200 + ellipsis + margin
if msg.get("content_truncated"):
assert msg["original_content_chars"] > 1200
def test_window_content_is_capped(self, db):
"""Window messages must have content capped at 4000 chars."""
db.create_session("s_long_window", source="cli")
db.append_message("s_long_window", role="user", content="search keyword here")
# Very long assistant reply containing the keyword
db.append_message("s_long_window", role="assistant",
content="Found it! keyword " + "a" * 10000)
db._conn.commit()
result = json.loads(session_search(query="keyword", db=db, limit=1))
assert result["success"] is True
entry = result["results"][0]
for msg in entry.get("messages", []):
content = msg.get("content", "")
assert len(content) <= 4010 # 4000 + ellipsis + margin
def test_legacy_context_summary_filtered(self, db):
"""Legacy [CONTEXT SUMMARY]: prefix must also be filtered."""
db.create_session("s_legacy", source="cli")
db.append_message("s_legacy", role="user",
content="[CONTEXT SUMMARY]: old compacted summary here")
db.append_message("s_legacy", role="user", content="new task: build API")
db.append_message("s_legacy", role="assistant", content="Building REST API now.")
for i in range(10):
db.append_message("s_legacy", role="user", content=f"step {i}")
db.append_message("s_legacy", role="assistant", content=f"done {i}")
db._conn.commit()
result = json.loads(session_search(query="build API", db=db, limit=1))
assert result["success"] is True
entry = result["results"][0]
for msg in entry.get("bookend_start", []):
assert "[CONTEXT SUMMARY]" not in (msg.get("content") or "")
# =========================================================================
# Compression-aware discovery (#6256)
#
# After compression (in-place compaction or legacy rotation), pre-compaction
# content is no longer in the live context but MUST stay discoverable via
# session_search. The old code skipped any FTS hit on the current session or
# lineage, creating a "memory black hole". Delegation children must STAY
# excluded — their content is still visible to the parent agent.
# =========================================================================
class TestResolveToParent:
"""Unit tests for _resolve_to_parent's compression-aware tuple return."""
def test_root_session_no_compression(self, db):
db.create_session("s1", source="cli")
root, has_compression = _resolve_to_parent(db, "s1")
assert root == "s1"
assert has_compression is False
def test_empty_session_id(self, db):
root, has_compression = _resolve_to_parent(db, "")
assert root == ""
assert has_compression is False
def test_none_session_id(self, db):
root, has_compression = _resolve_to_parent(db, None)
assert root is None
assert has_compression is False
def test_legacy_rotation_detects_compression(self, db):
"""Parent ended with end_reason='compression', child has parent_session_id."""
db.create_session("s_parent", source="cli")
db.end_session("s_parent", "compression")
db.create_session("s_child", source="cli", parent_session_id="s_parent")
root, has_compression = _resolve_to_parent(db, "s_child")
assert root == "s_parent"
assert has_compression is True
def test_delegation_no_compression(self, db):
"""Delegation child: parent_session_id set but no compression end_reason."""
db.create_session("s_parent", source="cli")
db.create_session("s_child", source="cli", parent_session_id="s_parent")
root, has_compression = _resolve_to_parent(db, "s_child")
assert root == "s_parent"
assert has_compression is False
def test_multi_level_compression_chain(self, db):
"""Grandparent → parent → child, both with compression edges."""
db.create_session("s_gp", source="cli")
db.end_session("s_gp", "compression")
db.create_session("s_p", source="cli", parent_session_id="s_gp")
db.end_session("s_p", "compression")
db.create_session("s_c", source="cli", parent_session_id="s_p")
root, has_compression = _resolve_to_parent(db, "s_c")
assert root == "s_gp"
assert has_compression is True
def test_chain_with_mixed_edges(self, db):
"""Compression parent → delegation-style child (no end_reason on child)."""
db.create_session("s_gp", source="cli")
db.end_session("s_gp", "compression")
db.create_session("s_p", source="cli", parent_session_id="s_gp")
# s_p does NOT end with compression — but ancestor s_gp does
db.create_session("s_c", source="cli", parent_session_id="s_p")
root, has_compression = _resolve_to_parent(db, "s_c")
assert root == "s_gp"
assert has_compression is True
class TestIsCompactedMessage:
"""Unit tests for the _is_compacted_message helper."""
def test_active_message_returns_false(self, db):
db.create_session("s1", source="cli")
mid = db.append_message("s1", role="user", content="hello")
assert _is_compacted_message(db, mid) is False
def test_compacted_message_returns_true(self, db):
db.create_session("s1", source="cli")
mid = db.append_message("s1", role="user", content="archived content")
db.archive_and_compact("s1", [
{"role": "assistant", "content": "compacted summary"},
])
# mid is now active=0, compacted=1
assert _is_compacted_message(db, mid) is True
def test_none_message_id(self, db):
assert _is_compacted_message(db, None) is False
def test_nonexistent_message_id(self, db):
assert _is_compacted_message(db, 999999) is False
class TestInPlaceCompactionDiscovery:
"""In-place compaction: archived turns on the SAME session_id must be
discoverable from the current session."""
def test_archived_content_discoverable_after_compaction(self, db):
"""The core regression: pre-compaction content on the current session
must surface in discovery even though raw_sid == current_session_id."""
db.create_session("s_compact", source="cli")
db.append_message("s_compact", role="user",
content="The spectral phoenix only spawns during full moons")
db.append_message("s_compact", role="assistant",
content="Spectral phoenix requires moonstone bait")
db.archive_and_compact("s_compact", [
{"role": "user", "content": "Summary: spectral phoenix discussed"},
{"role": "assistant", "content": "Acknowledged spectral phoenix info"},
])
result = json.loads(session_search(
query="spectral phoenix", db=db, current_session_id="s_compact",
))
assert result["success"] is True
assert result["count"] >= 1
# The hit should be from the same session (archived rows)
hit = result["results"][0]
assert hit["session_id"] == "s_compact"
def test_live_content_still_filtered_on_current_session(self, db):
"""Non-compacted (active) content on the current session stays filtered."""
db.create_session("s_live", source="cli")
db.append_message("s_live", role="user", content="crystal golem farming route")
result = json.loads(session_search(
query="crystal golem", db=db, current_session_id="s_live",
))
assert result["count"] == 0
def test_mixed_active_and_compacted_on_same_session(self, db):
"""A session that has been compacted: the archived content is
discoverable, but the new (post-compaction) active content is not
(it's in live context)."""
db.create_session("s_mixed", source="cli")
# Pre-compaction content (will be archived)
db.append_message("s_mixed", role="user", content="ancient ruins exploration log")
db.append_message("s_mixed", role="assistant", content="ancient ruins mapped")
# Compact
db.archive_and_compact("s_mixed", [
{"role": "user", "content": "Summary of ancient ruins exploration"},
{"role": "assistant", "content": "Continuing ancient ruins work"},
])
# Archived content should be discoverable
result_archived = json.loads(session_search(
query="ancient ruins exploration", db=db,
current_session_id="s_mixed",
))
assert result_archived["count"] >= 1
class TestLegacyRotationDiscovery:
"""Legacy rotation: parent session ended with end_reason='compression',
child session created. Parent's pre-compaction content must be discoverable
from the child."""
def test_compression_parent_discoverable_from_child(self, db):
db.create_session("s_parent", source="cli")
db.append_message("s_parent", role="user",
content="The void crystal mining requires diamond pickaxe")
db.append_message("s_parent", role="assistant",
content="Void crystal found in the deep caverns")
db.end_session("s_parent", "compression")
db.create_session("s_child", source="cli", parent_session_id="s_parent")
db.append_message("s_child", role="user", content="Continue void crystal work")
result = json.loads(session_search(
query="void crystal", db=db, current_session_id="s_child",
))
assert result["success"] is True
assert result["count"] >= 1
sids = [r["session_id"] for r in result["results"]]
assert "s_parent" in sids
def test_multi_level_compression_chain_discoverable(self, db):
"""Grandparent → parent → child, each compression-rotated. Content from
ancestors must be discoverable."""
db.create_session("s_gp", source="cli")
db.append_message("s_gp", role="user",
content="Project titan initial architecture design")
db.end_session("s_gp", "compression")
db.create_session("s_p", source="cli", parent_session_id="s_gp")
db.append_message("s_p", role="user",
content="Project titan second phase planning")
db.end_session("s_p", "compression")
db.create_session("s_c", source="cli", parent_session_id="s_p")
db.append_message("s_c", role="user", content="Project titan final review")
result = json.loads(session_search(
query="project titan", db=db, current_session_id="s_c",
))
assert result["count"] >= 1
# Should find content from s_gp or s_p (or both, deduped by lineage)
sids = [r["session_id"] for r in result["results"]]
assert any(s in ("s_gp", "s_p") for s in sids)
class TestDelegationExclusion:
"""Delegation children (delegate_task) must STAY excluded — their content
is still visible to the parent agent. parent_session_id is set but the
parent does NOT have end_reason='compression'."""
def test_delegation_parent_excluded_from_child(self, db):
"""Child can see its own content but parent's live content stays
excluded (it's in context via delegation)."""
db.create_session("s_parent", source="cli")
db.append_message("s_parent", role="user",
content="nebula deployment infrastructure setup")
db.append_message("s_parent", role="assistant",
content="Nebula deployment configured successfully")
db.create_session("s_child", source="cli", parent_session_id="s_parent")
db.append_message("s_child", role="user",
content="delegated nebula deployment subtask")
result = json.loads(session_search(
query="nebula deployment", db=db, current_session_id="s_child",
))
assert result["count"] == 0
def test_delegation_child_excluded_from_parent(self, db):
"""Parent searching should not see delegation child content either —
both are in the same lineage with no compression edge."""
db.create_session("s_parent", source="cli")
db.append_message("s_parent", role="user",
content="Working on stellar forge project")
db.create_session("s_child", source="cli", parent_session_id="s_parent")
db.append_message("s_child", role="user",
content="stellar forge delegated subtask execution")
result = json.loads(session_search(
query="stellar forge", db=db, current_session_id="s_parent",
))
assert result["count"] == 0
# =========================================================================
# Both layers together: discovery scope (#63144) × bookend bounding (#69334)
#
# Compaction touches two independent layers of session_search:
# 1. Discovery scope — compaction-archived rows on the current session must
# surface in discovery (this PR).
# 2. Content bounding — bookends must exclude generated compaction handoff
# summaries and cap message content length (#43175 / #69334).
# A compacted session exercises both at once: its archived content is the FTS
# hit, while the compaction summary row it produced sits at the session tail,
# exactly where bookend_end is sampled.
# =========================================================================
class TestCompactionDiscoveryBothLayers:
"""Compacted-session content is discoverable AND its bookends still
exclude compaction summaries / cap content length."""
def _seed_compacted_session(self, db):
db.create_session("s_both", source="cli")
# Long normal opening — exercises the 1200-char bookend cap.
db.append_message("s_both", role="user",
content="Kick off the obsidian gateway migration. " + "o" * 5000)
db.append_message("s_both", role="assistant",
content="Starting the obsidian gateway migration plan.")
# Padding so the anchored window doesn't swallow the bookends.
for i in range(10):
db.append_message("s_both", role="user", content=f"migration step {i}")
db.append_message("s_both", role="assistant", content=f"migration step {i} done")
# The FTS match target — will be archived by compaction below.
db.append_message("s_both", role="user",
content="the obsidian gateway needs a quartz keystone to activate")
db.append_message("s_both", role="assistant",
content="Noted: quartz keystone required for the obsidian gateway.")
for i in range(5):
db.append_message("s_both", role="user", content=f"wrap-up {i}")
db.append_message("s_both", role="assistant", content=f"wrapped {i}")
# Compact in place: everything above becomes active=0/compacted=1 and
# the handoff summary is inserted as the new live tail.
db.archive_and_compact("s_both", [
{"role": "user",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] "
"Earlier turns were compacted into this summary. " + "s" * 50000},
{"role": "assistant", "content": "Continuing after compaction."},
])
db._conn.commit()
def test_archived_hit_surfaces_with_bounded_summary_free_bookends(self, db):
self._seed_compacted_session(db)
result = json.loads(session_search(
query="quartz keystone", db=db, current_session_id="s_both",
))
# Layer 1 — discovery scope: the archived (active=0, compacted=1)
# content on the CURRENT session must surface.
assert result["success"] is True
assert result["count"] >= 1
entry = result["results"][0]
assert entry["session_id"] == "s_both"
# Layer 2a — summary exclusion: the compaction handoff row sits at the
# session tail (freshly inserted by archive_and_compact), exactly where
# bookend_end samples — it must be filtered out.
for msg in entry.get("bookend_start", []) + entry.get("bookend_end", []):
assert "[CONTEXT COMPACTION" not in (msg.get("content") or "")
# Layer 2b — content caps: bookends ≤1200 chars, window ≤4000 chars.
for msg in entry.get("bookend_start", []) + entry.get("bookend_end", []):
assert len(msg.get("content") or "") <= 1210
for msg in entry.get("messages", []):
assert len(msg.get("content") or "") <= 4010
# The long-but-legitimate opening survives (capped, not dropped).
bookend_contents = [m.get("content") or "" for m in entry.get("bookend_start", [])]
assert any("obsidian gateway migration" in c for c in bookend_contents)
# =========================================================================
# Teknium review round 2: rewind exclusion + delegation-under-compression
# =========================================================================
class TestRewindExclusion:
"""Rewind/undo rows (active=0, compacted=0) must STAY hidden — only
compaction archives (active=0, compacted=1) should surface."""
def test_rewind_rows_stay_hidden(self, db):
"""A rewound (active=0, compacted=0) message must not appear in
discovery, even though it's on the current session."""
db.create_session("s_rewind", source="cli")
mid = db.append_message("s_rewind", role="user",
content="secret rewind content alpha")
# Simulate a rewind: active=0, compacted=0 (NOT compaction)
db._conn.execute(
"UPDATE messages SET active = 0, compacted = 0 WHERE id = ?",
(mid,),
)
db._conn.commit()
result = json.loads(session_search(
query="secret rewind content alpha", db=db,
current_session_id="s_rewind",
))
assert result["count"] == 0
def test_compacted_messages_still_surface_alongside_rewind(self, db):
"""On the same session: compacted rows surface, rewind rows don't."""
db.create_session("s_mixed", source="cli")
# Message that will be compacted
db.append_message("s_mixed", role="user",
content="compaction archived content beta")
db.archive_and_compact("s_mixed", [
{"role": "assistant", "content": "Summary of beta"},
])
# Now add a post-compaction message and rewind it
mid2 = db.append_message("s_mixed", role="user",
content="rewound content gamma")
db._conn.execute(
"UPDATE messages SET active = 0, compacted = 0 WHERE id = ?",
(mid2,),
)
db._conn.commit()
# Compacted content should be discoverable
result_compact = json.loads(session_search(
query="compaction archived content beta", db=db,
current_session_id="s_mixed",
))
assert result_compact["count"] >= 1
# Rewound content should NOT be discoverable
result_rewind = json.loads(session_search(
query="rewound content gamma", db=db,
current_session_id="s_mixed",
))
assert result_rewind["count"] == 0
class TestCompressionEndedHelper:
"""Unit tests for _is_compression_ended."""
def test_compression_ended_session(self, db):
db.create_session("s1", source="cli")
db.end_session("s1", "compression")
assert _is_compression_ended(db, "s1") is True
def test_active_session_not_ended(self, db):
db.create_session("s1", source="cli")
assert _is_compression_ended(db, "s1") is False
def test_delegation_child_not_ended(self, db):
"""A delegation child under a compression continuation does NOT have
end_reason='compression' itself."""
db.create_session("s_parent", source="cli")
db.end_session("s_parent", "compression")
db.create_session("s_continuation", source="cli", parent_session_id="s_parent")
db.create_session("s_delegate_child", source="cli", parent_session_id="s_continuation")
assert _is_compression_ended(db, "s_delegate_child") is False
def test_empty_and_nonexistent(self, db):
assert _is_compression_ended(db, "") is False
assert _is_compression_ended(db, "nonexistent") is False
class TestLegacyContinuationPlusDelegation:
"""Regression: a delegation child created under a compression continuation
must stay excluded — its content is still live to the parent agent.
Only the compression-ended ancestor's content should surface."""
def test_compression_parent_surfaces_but_delegate_child_excluded(self, db):
"""Setup: grandparent (compression) → parent (compression) → child
(active, current session). A delegation grandchild is created under
the parent. Searching from the child should find grandparent/parent
content but NOT the delegation grandchild's content."""
# Grandparent: compression-ended, has searchable content
db.create_session("s_gp", source="cli")
db.append_message("s_gp", role="user",
content="grandparent cosmic anomaly research data")
db.end_session("s_gp", "compression")
# Parent: compression-ended continuation
db.create_session("s_p", source="cli", parent_session_id="s_gp")
db.append_message("s_p", role="user",
content="parent cosmic anomaly follow-up notes")
db.end_session("s_p", "compression")
# Current session: active child
db.create_session("s_current", source="cli", parent_session_id="s_p")
# Delegation child under s_p (not compression-ended)
db.create_session("s_delegate", source="cli", parent_session_id="s_p")
db.append_message("s_delegate", role="assistant",
content="delegated cosmic anomaly subtask results")
result = json.loads(session_search(
query="cosmic anomaly", db=db,
current_session_id="s_current",
))
# Compression-ended ancestors should be discoverable
sids = [r["session_id"] for r in result["results"]]
assert "s_gp" in sids or "s_p" in sids
# Delegation child must NOT appear
assert "s_delegate" not in sids