diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index c90023affd..d033e65dbe 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -320,8 +320,8 @@ class TestSessionSearch: assert result["sessions_searched"] == 1 assert current_sid not in [r.get("session_id") for r in result.get("results", [])] - def test_current_child_session_excludes_parent_lineage(self): - """Compression/delegation parents should be excluded for the active child session.""" + def test_current_child_session_excludes_delegation_parent(self): + """Delegation parents (non-compression) should be excluded for the active child session.""" from unittest.mock import MagicMock from tools.session_search_tool import session_search @@ -335,6 +335,7 @@ class TestSessionSearch: if session_id == "child_sid": return {"parent_session_id": "parent_sid"} if session_id == "parent_sid": + # No end_reason — this is a delegation parent, not compression return {"parent_session_id": None} return None @@ -349,6 +350,71 @@ class TestSessionSearch: assert result["results"] == [] assert result["sessions_searched"] == 0 + def test_compression_parent_searchable(self): + """Compression-ended parents should NOT be excluded — their content is + gone from the agent's context window after compaction.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [ + {"session_id": "parent_sid", "content": "match", "source": "cli", + "session_started": 1709500000, "model": "test"}, + ] + + def _get_session(session_id): + if session_id == "child_sid": + return {"parent_session_id": "parent_sid"} + if session_id == "parent_sid": + return {"parent_session_id": None, "end_reason": "compression"} + return None + + mock_db.get_session.side_effect = _get_session + mock_db.get_messages_as_conversation.return_value = [ + {"role": "user", "content": "test message"}, + ] + + result = json.loads(session_search( + query="test", db=mock_db, current_session_id="child_sid", + )) + + assert result["success"] is True + # Compression parent should appear in results, not be excluded + assert result["sessions_searched"] >= 1 + + def test_multi_level_compression_parent_searchable(self): + """Multi-level compaction: A→B→C, both A and B are compression parents. + When searching from C, both A and B should be searchable.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [ + {"session_id": "session_a", "content": "match", "source": "cli", + "session_started": 1709500000, "model": "test"}, + ] + + def _get_session(session_id): + if session_id == "session_c": + return {"parent_session_id": "session_b"} + if session_id == "session_b": + return {"parent_session_id": "session_a", "end_reason": "compression"} + if session_id == "session_a": + return {"parent_session_id": None, "end_reason": "compression"} + return None + + mock_db.get_session.side_effect = _get_session + mock_db.get_messages_as_conversation.return_value = [ + {"role": "user", "content": "test message"}, + ] + + result = json.loads(session_search( + query="test", db=mock_db, current_session_id="session_c", + )) + + assert result["success"] is True + assert result["sessions_searched"] >= 1 + def test_limit_none_coerced_to_default(self): """Model sends limit=null → should fall back to 3, not TypeError.""" from unittest.mock import MagicMock diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 16aaea109f..d9ffc3c6ad 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -374,16 +374,26 @@ def session_search( # Resolve child sessions to their parent — delegation stores detailed # content in child sessions, but the user's conversation is the parent. - def _resolve_to_parent(session_id: str) -> str: - """Walk delegation chain to find the root parent session ID.""" + def _resolve_to_parent(session_id: str) -> tuple: + """Walk delegation chain to find the root parent session ID. + + Returns (root_session_id, has_compression_hop) — the second flag + is True when at least one session in the chain was ended by context + compression. Compression parents' content is no longer in the + agent's context window, so they should be searchable even if they + belong to the current lineage. + """ visited = set() sid = session_id + has_compression = False while sid and sid not in visited: visited.add(sid) try: session = db.get_session(sid) if not session: break + if session.get("end_reason") == "compression": + has_compression = True parent = session.get("parent_session_id") if parent: sid = parent @@ -397,22 +407,24 @@ def session_search( exc_info=True, ) break - return sid + return sid, has_compression - current_lineage_root = ( - _resolve_to_parent(current_session_id) if current_session_id else None + _current_root, _ = ( + _resolve_to_parent(current_session_id) if current_session_id else (None, False) ) # Group by resolved (parent) session_id, dedup, skip the current - # session lineage. Compression and delegation create child sessions - # that still belong to the same active conversation. + # session lineage — but NOT compression parents whose content is no + # longer in the agent's context window. seen_sessions = {} for result in raw_results: raw_sid = result["session_id"] - resolved_sid = _resolve_to_parent(raw_sid) - # Skip the current session lineage — the agent already has that - # context, even if older turns live in parent fragments. - if current_lineage_root and resolved_sid == current_lineage_root: + resolved_sid, has_compression = _resolve_to_parent(raw_sid) + # Skip the current session lineage only when the content is still + # in the agent's context. Compression-ended sessions have been + # replaced by a compact summary — their original content is only + # reachable via search. + if _current_root and resolved_sid == _current_root and not has_compression: continue if current_session_id and raw_sid == current_session_id: continue