2bed2124a4
The registry handler hardcoded mode=args.get("mode", "summary") and the
function signature defaulted to "summary", which together made the
tools.session_search.default_mode config knob structurally unreachable
from real tool calls — _resolve_user_default_mode() only fires when
mode is None/empty, but neither path ever delivered None.
Drop both "summary" fallbacks so an omitted mode flows through as None
and the config-resolution branch can run.
Adds two tests: a static guard on the registry handler source pattern
(mirroring the existing run_agent.py one) and an end-to-end regression
that dispatches through the registry with default_mode='fast' configured
and asserts result["mode"] == "fast".
1830 lines
75 KiB
Python
1830 lines
75 KiB
Python
"""Tests for tools/session_search_tool.py — helper functions and search dispatcher."""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
import pytest
|
|
|
|
from tools.session_search_tool import (
|
|
_format_timestamp,
|
|
_format_conversation,
|
|
_truncate_around_matches,
|
|
_get_session_search_max_concurrency,
|
|
_list_recent_sessions,
|
|
_HIDDEN_SESSION_SOURCES,
|
|
MAX_SESSION_CHARS,
|
|
SESSION_SEARCH_SCHEMA,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Tool schema guidance
|
|
# =========================================================================
|
|
|
|
class TestHiddenSessionSources:
|
|
"""Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation."""
|
|
|
|
def test_tool_source_is_hidden(self):
|
|
assert "tool" in _HIDDEN_SESSION_SOURCES
|
|
|
|
def test_standard_sources_not_hidden(self):
|
|
for src in ("cli", "telegram", "discord", "slack", "cron"):
|
|
assert src not in _HIDDEN_SESSION_SOURCES
|
|
|
|
|
|
class TestSessionSearchSchema:
|
|
def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self):
|
|
description = SESSION_SEARCH_SCHEMA["description"]
|
|
assert "past conversations" in description
|
|
assert "recent turns of the current session" not in description
|
|
|
|
|
|
# =========================================================================
|
|
# _format_timestamp
|
|
# =========================================================================
|
|
|
|
class TestFormatTimestamp:
|
|
def test_unix_float(self):
|
|
ts = 1700000000.0 # Nov 14, 2023
|
|
result = _format_timestamp(ts)
|
|
assert "2023" in result or "November" in result
|
|
|
|
def test_unix_int(self):
|
|
result = _format_timestamp(1700000000)
|
|
assert isinstance(result, str)
|
|
assert len(result) > 5
|
|
|
|
def test_iso_string(self):
|
|
result = _format_timestamp("2024-01-15T10:30:00")
|
|
assert isinstance(result, str)
|
|
|
|
def test_none_returns_unknown(self):
|
|
assert _format_timestamp(None) == "unknown"
|
|
|
|
def test_numeric_string(self):
|
|
result = _format_timestamp("1700000000.0")
|
|
assert isinstance(result, str)
|
|
assert "unknown" not in result.lower()
|
|
|
|
|
|
# =========================================================================
|
|
# _format_conversation
|
|
# =========================================================================
|
|
|
|
class TestFormatConversation:
|
|
def test_basic_messages(self):
|
|
msgs = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
]
|
|
result = _format_conversation(msgs)
|
|
assert "[USER]: Hello" in result
|
|
assert "[ASSISTANT]: Hi there!" in result
|
|
|
|
def test_tool_message(self):
|
|
msgs = [
|
|
{"role": "tool", "content": "search results", "tool_name": "web_search"},
|
|
]
|
|
result = _format_conversation(msgs)
|
|
assert "[TOOL:web_search]" in result
|
|
|
|
def test_long_tool_output_truncated(self):
|
|
msgs = [
|
|
{"role": "tool", "content": "x" * 1000, "tool_name": "terminal"},
|
|
]
|
|
result = _format_conversation(msgs)
|
|
assert "[truncated]" in result
|
|
|
|
def test_assistant_with_tool_calls(self):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"function": {"name": "web_search"}},
|
|
{"function": {"name": "terminal"}},
|
|
],
|
|
},
|
|
]
|
|
result = _format_conversation(msgs)
|
|
assert "web_search" in result
|
|
assert "terminal" in result
|
|
|
|
def test_empty_messages(self):
|
|
result = _format_conversation([])
|
|
assert result == ""
|
|
|
|
|
|
# =========================================================================
|
|
# _truncate_around_matches
|
|
# =========================================================================
|
|
|
|
class TestTruncateAroundMatches:
|
|
def test_short_text_unchanged(self):
|
|
text = "Short text about docker"
|
|
result = _truncate_around_matches(text, "docker")
|
|
assert result == text
|
|
|
|
def test_long_text_truncated(self):
|
|
# Create text longer than MAX_SESSION_CHARS with query term in middle
|
|
padding = "x" * (MAX_SESSION_CHARS + 5000)
|
|
text = padding + " KEYWORD_HERE " + padding
|
|
result = _truncate_around_matches(text, "KEYWORD_HERE")
|
|
assert len(result) <= MAX_SESSION_CHARS + 100 # +100 for prefix/suffix markers
|
|
assert "KEYWORD_HERE" in result
|
|
|
|
def test_truncation_adds_markers(self):
|
|
text = "a" * 50000 + " target " + "b" * (MAX_SESSION_CHARS + 5000)
|
|
result = _truncate_around_matches(text, "target")
|
|
assert "truncated" in result.lower()
|
|
|
|
def test_no_match_takes_from_start(self):
|
|
text = "x" * (MAX_SESSION_CHARS + 5000)
|
|
result = _truncate_around_matches(text, "nonexistent")
|
|
# Should take from the beginning
|
|
assert result.startswith("x")
|
|
|
|
def test_match_at_beginning(self):
|
|
text = "KEYWORD " + "x" * (MAX_SESSION_CHARS + 5000)
|
|
result = _truncate_around_matches(text, "KEYWORD")
|
|
assert "KEYWORD" in result
|
|
|
|
def test_multiword_phrase_match_beats_individual_term(self):
|
|
"""Full phrase deep in text should be found even when a single term
|
|
appears much earlier in boilerplate."""
|
|
boilerplate = "The project setup is complex. " * 500 # ~15K, has 'project' early
|
|
filler = "x" * (MAX_SESSION_CHARS + 20000)
|
|
target = "We reviewed the keystone project roadmap in detail."
|
|
text = boilerplate + filler + target + filler
|
|
result = _truncate_around_matches(text, "keystone project")
|
|
assert "keystone project" in result.lower()
|
|
|
|
def test_multiword_proximity_cooccurrence(self):
|
|
"""When exact phrase is absent, terms co-occurring within proximity
|
|
should be preferred over a lone early term."""
|
|
early = "project " + "a" * (MAX_SESSION_CHARS + 20000)
|
|
# Place 'keystone' and 'project' near each other (but not as exact phrase)
|
|
cooccur = "this keystone initiative for the project was pivotal"
|
|
tail = "b" * (MAX_SESSION_CHARS + 20000)
|
|
text = early + cooccur + tail
|
|
result = _truncate_around_matches(text, "keystone project")
|
|
assert "keystone" in result.lower()
|
|
assert "project" in result.lower()
|
|
|
|
def test_multiword_window_maximises_coverage(self):
|
|
"""Sliding window should capture as many match clusters as possible."""
|
|
# Place two phrase matches: one at ~50K, one at ~60K, both should fit
|
|
pre = "z" * 50000
|
|
match1 = " alpha beta "
|
|
gap = "z" * 10000
|
|
match2 = " alpha beta "
|
|
post = "z" * (MAX_SESSION_CHARS + 40000)
|
|
text = pre + match1 + gap + match2 + post
|
|
result = _truncate_around_matches(text, "alpha beta")
|
|
assert result.lower().count("alpha beta") == 2
|
|
|
|
|
|
class TestSessionSearchConcurrency:
|
|
def test_defaults_to_three(self):
|
|
assert _get_session_search_max_concurrency() == 3
|
|
|
|
def test_reads_and_clamps_configured_value(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"auxiliary": {"session_search": {"max_concurrency": 9}}},
|
|
)
|
|
assert _get_session_search_max_concurrency() == 5
|
|
|
|
def test_session_search_respects_configured_concurrency_limit(self, monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"auxiliary": {"session_search": {"max_concurrency": 1}}},
|
|
)
|
|
|
|
max_seen = {"value": 0}
|
|
active = {"value": 0}
|
|
|
|
async def fake_summarize(_text, _query, _meta):
|
|
active["value"] += 1
|
|
max_seen["value"] = max(max_seen["value"], active["value"])
|
|
await asyncio.sleep(0.01)
|
|
active["value"] -= 1
|
|
return "summary", None
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "s1", "source": "cli", "session_started": 1709500000, "model": "test"},
|
|
{"session_id": "s2", "source": "cli", "session_started": 1709500001, "model": "test"},
|
|
{"session_id": "s3", "source": "cli", "session_started": 1709500002, "model": "test"},
|
|
]
|
|
mock_db.get_session.side_effect = lambda sid: {
|
|
"id": sid,
|
|
"parent_session_id": None,
|
|
"source": "cli",
|
|
"started_at": 1709500000,
|
|
}
|
|
mock_db.get_messages_as_conversation.side_effect = lambda sid: [
|
|
{"role": "user", "content": f"message from {sid}"},
|
|
{"role": "assistant", "content": "response"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="message", db=mock_db, limit=3, mode="summary"))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
assert result["count"] == 3
|
|
assert max_seen["value"] == 1
|
|
|
|
|
|
class TestRecentSessionListing:
|
|
def test_recent_mode_requests_last_active_ordering(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.list_sessions_rich.return_value = []
|
|
|
|
result = json.loads(_list_recent_sessions(mock_db, limit=5))
|
|
|
|
assert result["success"] is True
|
|
mock_db.list_sessions_rich.assert_called_once_with(
|
|
limit=10,
|
|
exclude_sources=["tool"],
|
|
order_by_last_active=True,
|
|
)
|
|
|
|
def test_current_child_session_excludes_root_lineage_even_when_child_id_is_longer(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.list_sessions_rich.return_value = [
|
|
{
|
|
"id": "root",
|
|
"title": "Current conversation",
|
|
"source": "cli",
|
|
"started_at": 1709500000,
|
|
"last_active": 1709500100,
|
|
"message_count": 4,
|
|
"preview": "current root",
|
|
"parent_session_id": None,
|
|
},
|
|
{
|
|
"id": "other_session",
|
|
"title": "Other conversation",
|
|
"source": "cli",
|
|
"started_at": 1709400000,
|
|
"last_active": 1709400100,
|
|
"message_count": 3,
|
|
"preview": "other root",
|
|
"parent_session_id": None,
|
|
},
|
|
]
|
|
|
|
def _get_session(session_id):
|
|
if session_id == "child_session_id_that_is_definitely_longer":
|
|
return {"parent_session_id": "root"}
|
|
if session_id == "root":
|
|
return {"parent_session_id": None}
|
|
return None
|
|
|
|
mock_db.get_session.side_effect = _get_session
|
|
|
|
result = json.loads(_list_recent_sessions(
|
|
mock_db,
|
|
limit=5,
|
|
current_session_id="child_session_id_that_is_definitely_longer",
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert [item["session_id"] for item in result["results"]] == ["other_session"]
|
|
assert all(item["session_id"] != "root" for item in result["results"])
|
|
|
|
|
|
# =========================================================================
|
|
# session_search (dispatcher)
|
|
# =========================================================================
|
|
|
|
class TestSessionSearch:
|
|
def test_no_db_lazily_opens_default_session_db(self, monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
class FakeSessionDB:
|
|
def __new__(cls):
|
|
return mock_db
|
|
|
|
import types
|
|
import sys
|
|
|
|
fake_state = types.ModuleType("hermes_state")
|
|
fake_state.SessionDB = FakeSessionDB
|
|
monkeypatch.setitem(sys.modules, "hermes_state", fake_state)
|
|
|
|
result = json.loads(session_search(query="test"))
|
|
assert result["success"] is True
|
|
mock_db.search_messages.assert_called_once()
|
|
|
|
def test_empty_query_returns_error(self):
|
|
from tools.session_search_tool import session_search
|
|
mock_db = object()
|
|
result = json.loads(session_search(query="", db=mock_db))
|
|
assert result["success"] is False
|
|
|
|
def test_whitespace_query_returns_error(self):
|
|
from tools.session_search_tool import session_search
|
|
mock_db = object()
|
|
result = json.loads(session_search(query=" ", db=mock_db))
|
|
assert result["success"] is False
|
|
|
|
def test_current_session_excluded(self):
|
|
"""session_search should never return the current session."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
current_sid = "20260304_120000_abc123"
|
|
|
|
# Simulate FTS5 returning matches only from the current session
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": current_sid, "content": "test match", "source": "cli",
|
|
"session_started": 1709500000, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None}
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, current_session_id=current_sid,
|
|
))
|
|
assert result["success"] is True
|
|
assert result["count"] == 0
|
|
assert result["results"] == []
|
|
|
|
def test_current_session_excluded_keeps_others(self):
|
|
"""Other sessions should still be returned when current is excluded."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
current_sid = "20260304_120000_abc123"
|
|
other_sid = "20260303_100000_def456"
|
|
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": current_sid, "content": "match 1", "source": "cli",
|
|
"session_started": 1709500000, "model": "test"},
|
|
{"session_id": other_sid, "content": "match 2", "source": "telegram",
|
|
"session_started": 1709400000, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "hi there"},
|
|
]
|
|
|
|
# Mock async_call_llm to raise RuntimeError → summarizer returns None
|
|
from unittest.mock import AsyncMock, patch as _patch
|
|
with _patch("tools.session_search_tool.async_call_llm",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("no provider")):
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, current_session_id=current_sid,
|
|
))
|
|
|
|
assert result["success"] is True
|
|
# Current session should be skipped, only other_sid should appear
|
|
assert result["sessions_searched"] == 1
|
|
assert current_sid not in [r.get("session_id") for r in result.get("results", [])]
|
|
|
|
def test_default_search_returns_summary_mode_recap(self, monkeypatch):
|
|
"""Default keyword search should run the LLM summariser path (the recall users want)."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize(text, query, meta):
|
|
assert "full transcript about session_search" in text
|
|
assert query == "session_search"
|
|
return "focused default summary", None
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{
|
|
"id": 123,
|
|
"session_id": "other_sid",
|
|
"role": "user",
|
|
"snippet": "we discussed >>>session_search<<< latency",
|
|
"context": [
|
|
{"role": "user", "content": "session_search is slow"},
|
|
{"role": "assistant", "content": "the LLM summary is the bottleneck"},
|
|
],
|
|
"source": "cli",
|
|
"session_started": 1709400000,
|
|
"model": "test-model",
|
|
},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "title": "Latency debug", "source": "cli", "started_at": 1709400000}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "full transcript about session_search"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
assert result["count"] == 1
|
|
entry = result["results"][0]
|
|
assert entry["summary"] == "focused default summary"
|
|
assert entry["model"] == "test-model"
|
|
# Summary mode does NOT include snippet/context fields — only the metadata + summary
|
|
assert "snippet" not in entry
|
|
assert "context" not in entry
|
|
mock_db.get_messages_as_conversation.assert_called_once_with("other_sid")
|
|
|
|
def test_explicit_fast_mode_returns_snippets_without_llm_or_full_session_load(self, monkeypatch):
|
|
"""mode='fast' stays on the DB/snippet path and avoids LLM latency."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fail_summarize(*_args, **_kwargs):
|
|
raise AssertionError("fast mode must not call the summarizer")
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fail_summarize)
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{
|
|
"id": 123,
|
|
"session_id": "other_sid",
|
|
"role": "user",
|
|
"snippet": "we discussed >>>session_search<<< latency",
|
|
"context": [
|
|
{"role": "user", "content": "session_search is slow"},
|
|
{"role": "assistant", "content": "the LLM summary is the bottleneck"},
|
|
],
|
|
"source": "cli",
|
|
"session_started": 1709400000,
|
|
"model": "test-model",
|
|
},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "title": "Latency debug"}
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode="fast"))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "fast"
|
|
assert result["count"] == 1
|
|
entry = result["results"][0]
|
|
assert entry["summary"] == "[Search hit — summary not generated in fast mode] Use snippet/context fields, or set mode='summary' for LLM-generated recall."
|
|
assert "we discussed" not in entry["summary"]
|
|
assert entry["model"] == "test-model"
|
|
assert entry["snippet"] == "we discussed >>>session_search<<< latency"
|
|
assert entry["context"][1]["content"] == "the LLM summary is the bottleneck"
|
|
mock_db.get_messages_as_conversation.assert_not_called()
|
|
|
|
def test_fast_mode_includes_match_message_id_for_guided_drilldown(self):
|
|
"""Fast-mode results must surface the FTS5 message id as ``match_message_id``
|
|
so the agent can pass it back as ``around_message_id`` for a follow-up
|
|
``mode='guided'`` call. This is the discover → drill composition handle."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{
|
|
"id": 987654,
|
|
"session_id": "other_sid",
|
|
"role": "assistant",
|
|
"snippet": "...the >>>final design<<< was...",
|
|
"context": [],
|
|
"source": "cli",
|
|
"session_started": 1709400000,
|
|
"model": "test-model",
|
|
},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None}
|
|
|
|
result = json.loads(session_search(query="final design", db=mock_db, mode="fast"))
|
|
|
|
entry = result["results"][0]
|
|
assert entry["match_message_id"] == 987654
|
|
# Sanity: still also surfaces session_id so the agent has both pieces
|
|
# needed to compose a guided call
|
|
assert entry["session_id"] == "other_sid"
|
|
|
|
@pytest.mark.parametrize("mode", ["summarized", "summarise", "summarize", "deep"])
|
|
def test_summary_mode_aliases_use_llm_summarization_path(self, monkeypatch, mode):
|
|
"""Common natural-language mode aliases should map to summary mode."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize(_text, _query, _meta):
|
|
return "alias summary", None
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [{"session_id": "sid", "source": "cli"}]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "source": "cli"}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "full transcript"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode=mode))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
assert result["results"][0]["summary"] == "alias summary"
|
|
|
|
@pytest.mark.parametrize("mode", ["", "unknown", 42, True, None])
|
|
def test_invalid_or_empty_mode_falls_back_to_summary(self, monkeypatch, mode):
|
|
"""Loose tool-call args should degrade to summary mode (the safe default)."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize(_text, _query, _meta):
|
|
return "fallback summary", None
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "sid", "snippet": "hit", "context": "not-a-list", "source": "cli"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "source": "cli"}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "transcript"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode=mode))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
assert result["results"][0]["summary"] == "fallback summary"
|
|
|
|
def test_fast_mode_tolerates_session_metadata_lookup_failure(self):
|
|
"""Fast mode should still return the FTS hit when parent metadata is unavailable."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "sid", "snippet": "hit", "source": "cli", "model": None},
|
|
]
|
|
mock_db.get_session.side_effect = RuntimeError("metadata unavailable")
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode="fast"))
|
|
|
|
assert result["success"] is True
|
|
assert result["results"][0]["source"] == "cli"
|
|
assert result["results"][0]["model"] == "unknown"
|
|
|
|
def test_summary_mode_preserves_llm_summarization_path(self, monkeypatch):
|
|
"""Explicit summary mode keeps the previous behavior for deeper recall."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize(text, query, meta):
|
|
assert "full transcript" in text
|
|
assert query == "session_search"
|
|
assert meta["source"] == "cli"
|
|
return "focused session summary", None
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "other_sid", "source": "cli", "session_started": 1709400000, "model": "test-model"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "source": "cli", "started_at": 1709400000}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "full transcript about session_search"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode="summary"))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
assert result["results"][0]["summary"] == "focused session summary"
|
|
mock_db.get_messages_as_conversation.assert_called_once_with("other_sid")
|
|
|
|
def test_summary_mode_surfaces_aux_usage_for_cost_attribution(self, monkeypatch):
|
|
"""Summary-mode aux LLM usage must flow back into the tool payload.
|
|
|
|
Without this, summary-mode spend is invisible to the parent session's
|
|
per-session token / cost accounting — the aux LLM call (up to 28K input
|
|
+ 10K output per session summarised, at the same Opus rate as the main
|
|
loop) gets swallowed silently. The tool surfaces it via per-result
|
|
``aux_usage`` and a top-level ``aux_usage_total`` so callers can
|
|
attribute the real cost.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize(_text, _query, _meta):
|
|
# Match the new (content, usage) signature.
|
|
return "summary", {
|
|
"model": "anthropic/claude-opus-4-7",
|
|
"input_tokens": 27_500,
|
|
"output_tokens": 8_200,
|
|
"cache_read_tokens": 0,
|
|
"cache_creation_tokens": 0,
|
|
}
|
|
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "s1", "source": "cli", "session_started": 1709500000, "model": "test"},
|
|
{"session_id": "s2", "source": "cli", "session_started": 1709500001, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "source": "cli"}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "transcript"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="session_search", db=mock_db, mode="summary"))
|
|
|
|
assert result["success"] is True
|
|
# Per-result usage attached.
|
|
assert result["results"][0]["aux_usage"]["input_tokens"] == 27_500
|
|
assert result["results"][0]["aux_usage"]["output_tokens"] == 8_200
|
|
# Top-level aggregate summed across summarised sessions.
|
|
total = result["aux_usage_total"]
|
|
assert total["call_count"] == 2
|
|
assert total["input_tokens"] == 55_000
|
|
assert total["output_tokens"] == 16_400
|
|
assert total["model"] == "anthropic/claude-opus-4-7"
|
|
|
|
def test_summary_mode_omits_aux_usage_total_when_provider_returns_no_usage(self, monkeypatch):
|
|
"""Test mocks and providers that don't surface usage shouldn't pollute the payload.
|
|
|
|
If every summary call returned ``usage=None``, the aggregator never
|
|
increments ``call_count``, and we deliberately omit ``aux_usage_total``
|
|
from the response so downstream consumers can detect "no data" cleanly
|
|
rather than seeing a misleading all-zeros block.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
async def fake_summarize_no_usage(_text, _query, _meta):
|
|
return "summary", None
|
|
|
|
monkeypatch.setattr(
|
|
"tools.session_search_tool._summarize_session", fake_summarize_no_usage
|
|
)
|
|
monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro))
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "s1", "source": "cli", "session_started": 1709500000, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {"parent_session_id": None, "source": "cli"}
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "transcript"},
|
|
]
|
|
|
|
result = json.loads(session_search(query="x", db=mock_db, mode="summary"))
|
|
|
|
assert result["success"] is True
|
|
assert "aux_usage_total" not in result
|
|
assert "aux_usage" not in result["results"][0]
|
|
|
|
def test_positional_db_argument_remains_backwards_compatible(self):
|
|
"""Keep the historical positional order: query, role_filter, limit, db, current_session_id."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search("session_search", None, 3, mock_db, None))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "summary"
|
|
mock_db.search_messages.assert_called_once()
|
|
|
|
def test_run_agent_special_session_search_paths_forward_mode(self):
|
|
"""run_agent has two direct session_search call sites outside registry dispatch.
|
|
|
|
Both dispatch sites now pass ``mode=function_args.get("mode")`` (no
|
|
hardcoded "summary" fallback) so that an unset mode flows through to
|
|
the tool's normaliser, which resolves the user-configured default via
|
|
``_resolve_user_default_mode()``. Hardcoding "summary" at the dispatch
|
|
layer would silently shadow that config.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
source = (Path(__file__).parent.parent.parent / "run_agent.py").read_text()
|
|
# Both dispatch sites pass mode= as their next-to-last group of kwargs;
|
|
# the new guided-mode kwargs (session_id/around_message_id/window) follow.
|
|
assert source.count('mode=function_args.get("mode")') == 2
|
|
# And both dispatch sites carry the guided-mode handles
|
|
assert source.count('around_message_id=function_args.get("around_message_id")') == 2
|
|
assert source.count('window=function_args.get("window", 5)') == 2
|
|
assert source.count('anchors=function_args.get("anchors")') == 2
|
|
# Guard against a regression to hardcoded "summary" — the config-default
|
|
# plumbing only works if dispatch doesn't shadow None with "summary".
|
|
assert 'mode=function_args.get("mode", "summary")' not in source, (
|
|
"dispatch sites must pass mode=function_args.get(\"mode\") (no default) "
|
|
"so the user-configured default_mode can take effect"
|
|
)
|
|
|
|
def test_registry_handler_forwards_unset_mode_without_default(self):
|
|
"""Registry handler must pass mode=args.get("mode") (no "summary" fallback).
|
|
|
|
If the handler substitutes "summary" when the LLM omits ``mode``, then
|
|
``_resolve_user_default_mode()`` is structurally unreachable from real
|
|
tool calls and the ``tools.session_search.default_mode`` config knob
|
|
becomes dead code. This is the registry-handler counterpart to
|
|
``test_run_agent_special_session_search_paths_forward_mode``.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
source = (
|
|
Path(__file__).parent.parent.parent / "tools" / "session_search_tool.py"
|
|
).read_text()
|
|
assert 'mode=args.get("mode")' in source, (
|
|
"registry handler must pass mode=args.get(\"mode\") (no default) "
|
|
"so the user-configured default_mode can take effect"
|
|
)
|
|
assert 'mode=args.get("mode", "summary")' not in source, (
|
|
"registry handler must not hardcode \"summary\" as the mode default — "
|
|
"it shadows tools.session_search.default_mode in config.yaml"
|
|
)
|
|
|
|
def test_unset_mode_via_registry_honours_configured_default(self, monkeypatch):
|
|
"""End-to-end: unset mode through the registry handler resolves to config.
|
|
|
|
With ``tools.session_search.default_mode: fast`` configured, an LLM tool
|
|
call that omits ``mode`` must run in fast mode (no aux LLM), not summary.
|
|
This is the regression test for the bug where three layers of hardcoded
|
|
``"summary"`` defaults made the config knob unreachable.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
from tools.registry import registry
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "fast"}}},
|
|
)
|
|
# Sanity: the resolver itself sees fast.
|
|
assert _resolve_user_default_mode() == "fast"
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
# Invoke through the registry exactly as the agent loop would, with no
|
|
# "mode" in args — simulates the LLM omitting the parameter.
|
|
result = json.loads(registry.dispatch("session_search", {"query": "anything"}, db=mock_db))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "fast", (
|
|
f"expected fast (from config), got {result['mode']!r} — "
|
|
"the registry handler is shadowing the configured default"
|
|
)
|
|
|
|
# -----------------------------------------------------------------
|
|
# User-configurable default mode (tools.session_search.default_mode
|
|
# in ~/.hermes/config.yaml). Lets a user opt into fast-as-default
|
|
# without having to pass mode= on every call.
|
|
# -----------------------------------------------------------------
|
|
|
|
def _clear_default_mode_cache(self):
|
|
"""Reset the lru_cache between tests so config changes are honoured."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
_resolve_user_default_mode.cache_clear()
|
|
|
|
def test_unset_mode_falls_back_to_summary_when_config_missing(self, monkeypatch):
|
|
"""With no config, an unset mode resolves to 'summary'."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
self._clear_default_mode_cache()
|
|
# Force load_config import to fail → fallback path.
|
|
import sys
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.config", None)
|
|
assert _resolve_user_default_mode() == "summary"
|
|
|
|
def test_user_can_configure_fast_as_default(self, monkeypatch):
|
|
"""tools.session_search.default_mode: fast → unset mode resolves to 'fast'."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "fast"}}},
|
|
)
|
|
assert _resolve_user_default_mode() == "fast"
|
|
|
|
def test_user_can_configure_summary_as_default_explicitly(self, monkeypatch):
|
|
"""Explicit summary in config behaves identically to the implicit default."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "summary"}}},
|
|
)
|
|
assert _resolve_user_default_mode() == "summary"
|
|
|
|
def test_invalid_default_mode_warns_and_falls_back(self, monkeypatch, caplog):
|
|
"""Typo'd / unknown value logs a warning and falls back to 'summary'."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
import logging
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "smary"}}},
|
|
)
|
|
with caplog.at_level(logging.WARNING):
|
|
assert _resolve_user_default_mode() == "summary"
|
|
# User sees feedback about the typo.
|
|
assert any("smary" in rec.message for rec in caplog.records)
|
|
|
|
def test_guided_as_default_mode_is_rejected(self, monkeypatch):
|
|
"""guided requires anchors and can't be a standalone default — falls back to 'summary'."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "guided"}}},
|
|
)
|
|
assert _resolve_user_default_mode() == "summary"
|
|
|
|
def test_non_string_default_mode_falls_back(self, monkeypatch):
|
|
"""Bogus types (int, dict, etc.) in YAML fall back gracefully, no crash."""
|
|
from tools.session_search_tool import _resolve_user_default_mode
|
|
self._clear_default_mode_cache()
|
|
for bad in (42, ["fast"], {"mode": "fast"}, True):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda b=bad: {"tools": {"session_search": {"default_mode": b}}},
|
|
)
|
|
self._clear_default_mode_cache()
|
|
assert _resolve_user_default_mode() == "summary", f"bad value {bad!r} should fall back"
|
|
|
|
def test_explicit_mode_argument_overrides_user_default(self, monkeypatch):
|
|
"""User config sets fast-as-default, but explicit mode='summary' still wins."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "fast"}}},
|
|
)
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search(query="anything", db=mock_db, mode="fast"))
|
|
assert result["mode"] == "fast"
|
|
# ...and explicit summary still produces summary even when default is fast.
|
|
result = json.loads(session_search(query="anything", db=mock_db, mode="summary"))
|
|
assert result["mode"] == "summary"
|
|
|
|
def test_unset_mode_with_config_default_fast_runs_fast_path(self, monkeypatch):
|
|
"""End-to-end: config says default=fast, caller passes mode=None → fast hits returned, no LLM."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
self._clear_default_mode_cache()
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"tools": {"session_search": {"default_mode": "fast"}}},
|
|
)
|
|
|
|
async def fail_summarize(*_args, **_kwargs):
|
|
raise AssertionError("fast mode must not invoke the summariser")
|
|
monkeypatch.setattr("tools.session_search_tool._summarize_session", fail_summarize)
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "sid", "id": 7, "content": "match", "source": "cli",
|
|
"session_started": 1709400000, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {"id": "sid", "parent_session_id": None,
|
|
"source": "cli", "started_at": 1709400000}
|
|
|
|
# mode=None mimics what the dispatcher passes when the LLM omits 'mode'.
|
|
result = json.loads(session_search(query="match", db=mock_db, mode=None))
|
|
assert result["mode"] == "fast"
|
|
assert result["count"] == 1
|
|
|
|
def test_current_child_session_excludes_parent_lineage(self):
|
|
"""Compression/delegation parents should be excluded for the active child session."""
|
|
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}
|
|
return None
|
|
|
|
mock_db.get_session.side_effect = _get_session
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, current_session_id="child_sid",
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["count"] == 0
|
|
assert result["results"] == []
|
|
assert result["sessions_searched"] == 0
|
|
|
|
def test_limit_none_coerced_to_default(self):
|
|
"""Model sends limit=null → should fall back to 3, not TypeError."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, limit=None,
|
|
))
|
|
assert result["success"] is True
|
|
|
|
def test_limit_type_object_coerced_to_default(self):
|
|
"""Model sends limit as a type object → should fall back to 3, not TypeError."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, limit=int,
|
|
))
|
|
assert result["success"] is True
|
|
|
|
def test_limit_string_coerced(self):
|
|
"""Model sends limit as string '2' → should coerce to int."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, limit="2",
|
|
))
|
|
assert result["success"] is True
|
|
|
|
def test_limit_clamped_to_range(self):
|
|
"""Negative or zero limit should be clamped to 1."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = []
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, limit=-5,
|
|
))
|
|
assert result["success"] is True
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, limit=0,
|
|
))
|
|
assert result["success"] is True
|
|
|
|
def test_current_root_session_excludes_child_lineage(self):
|
|
"""Delegation child hits should be excluded when they resolve to the current root session."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "child_sid", "content": "match", "source": "cli",
|
|
"session_started": 1709500000, "model": "test"},
|
|
]
|
|
|
|
def _get_session(session_id):
|
|
if session_id == "root_sid":
|
|
return {"parent_session_id": None}
|
|
if session_id == "child_sid":
|
|
return {"parent_session_id": "root_sid"}
|
|
return None
|
|
|
|
mock_db.get_session.side_effect = _get_session
|
|
|
|
result = json.loads(session_search(
|
|
query="test", db=mock_db, current_session_id="root_sid",
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["count"] == 0
|
|
assert result["results"] == []
|
|
assert result["sessions_searched"] == 0
|
|
|
|
def test_source_from_resolved_parent_not_fts5_child(self):
|
|
"""source in output must reflect the resolved parent session, not the child that matched FTS5.
|
|
|
|
Regression test for #15909: when a delegation child session (source='telegram')
|
|
resolves to a parent (source='api_server'), the result entry must report
|
|
'api_server', not 'telegram'.
|
|
|
|
Note: as of the match_message_id pairing fix, the result's ``session_id``
|
|
is now the raw FTS5 sid (the only sid that pairs with match_message_id),
|
|
and the lineage root is exposed as ``parent_session_id``. The ``source``
|
|
promotion still comes from the resolved parent — that part of #15909 is
|
|
unchanged.
|
|
"""
|
|
from unittest.mock import MagicMock, AsyncMock, patch as _patch
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
# FTS5 hit is in the child delegation session which carries source='telegram'
|
|
mock_db.search_messages.return_value = [
|
|
{
|
|
"session_id": "child_sid",
|
|
"id": 42,
|
|
"content": "hello world",
|
|
"source": "telegram", # child session source — wrong value to surface
|
|
"session_started": 1709400000,
|
|
"model": "gpt-4o-mini",
|
|
},
|
|
]
|
|
|
|
def _get_session(session_id):
|
|
if session_id == "child_sid":
|
|
return {
|
|
"id": "child_sid",
|
|
"parent_session_id": "parent_sid",
|
|
"source": "telegram",
|
|
"started_at": 1709400000,
|
|
"model": "gpt-4o-mini",
|
|
}
|
|
if session_id == "parent_sid":
|
|
return {
|
|
"id": "parent_sid",
|
|
"parent_session_id": None,
|
|
"source": "api_server", # correct parent source
|
|
"started_at": 1709300000,
|
|
"model": "gpt-4o-mini",
|
|
}
|
|
return None
|
|
|
|
mock_db.get_session.side_effect = _get_session
|
|
mock_db.get_messages_as_conversation.return_value = [
|
|
{"role": "user", "content": "hello world"},
|
|
{"role": "assistant", "content": "hi there"},
|
|
]
|
|
|
|
with _patch(
|
|
"tools.session_search_tool.async_call_llm",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("no provider"),
|
|
):
|
|
# Use mode='fast' to exercise the per-result (session_id,
|
|
# match_message_id) pair shape. Summary mode doesn't return
|
|
# match_message_id so the regression vector is fast-only.
|
|
result = json.loads(session_search(query="hello world", db=mock_db, mode="fast"))
|
|
|
|
assert result["success"] is True
|
|
assert result["count"] == 1
|
|
entry = result["results"][0]
|
|
# Raw FTS5 sid is preserved (this is the sid that pairs with match_message_id).
|
|
assert entry["session_id"] == "child_sid", (
|
|
"session_id should be the raw FTS5 row's sid so it pairs with match_message_id"
|
|
)
|
|
# Lineage root is exposed separately.
|
|
assert entry["parent_session_id"] == "parent_sid", (
|
|
"parent_session_id should expose the resolved lineage root"
|
|
)
|
|
# match_message_id is the row's id (which lives in child_sid, not parent_sid).
|
|
assert entry["match_message_id"] == 42
|
|
# #15909 invariant: source still promotes from the resolved parent.
|
|
assert entry["source"] == "api_server", (
|
|
f"source should be parent's 'api_server', got {entry['source']!r}"
|
|
)
|
|
|
|
def test_fast_pair_session_id_with_match_message_id(self):
|
|
"""fast mode must emit (session_id, match_message_id) as a self-consistent pair.
|
|
|
|
Before this fix the result reused the lineage root as session_id but
|
|
kept the FTS5 row's id as match_message_id. The pair was unusable for
|
|
guided drill-down because the message lives in a child session, not
|
|
the parent. This test pins the contract: session_id is the raw sid of
|
|
the row that contains match_message_id; parent_session_id is exposed
|
|
separately when there's a lineage above it.
|
|
"""
|
|
from unittest.mock import MagicMock, AsyncMock, patch as _patch
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{
|
|
"session_id": "child_sid",
|
|
"id": 4242,
|
|
"content": "...query match...",
|
|
"source": "tui",
|
|
"session_started": 1709400000,
|
|
"model": "test",
|
|
},
|
|
]
|
|
|
|
def _get_session(sid):
|
|
if sid == "child_sid":
|
|
return {"id": "child_sid", "parent_session_id": "root_sid",
|
|
"source": "tui", "started_at": 1709400000}
|
|
if sid == "root_sid":
|
|
return {"id": "root_sid", "parent_session_id": None,
|
|
"source": "tui", "started_at": 1709300000}
|
|
return None
|
|
|
|
mock_db.get_session.side_effect = _get_session
|
|
|
|
result = json.loads(session_search(query="query match", db=mock_db, mode="fast"))
|
|
entry = result["results"][0]
|
|
|
|
# The pair the agent will hand back to mode='guided' must be valid.
|
|
assert entry["session_id"] == "child_sid"
|
|
assert entry["match_message_id"] == 4242
|
|
# And the user-facing lineage is still discoverable.
|
|
assert entry["parent_session_id"] == "root_sid"
|
|
|
|
def test_fast_no_parent_session_id_field_when_session_is_already_root(self):
|
|
"""When the matching session has no parent, parent_session_id is omitted (tidy output)."""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
mock_db = MagicMock()
|
|
mock_db.search_messages.return_value = [
|
|
{"session_id": "root_only", "id": 7, "content": "hit",
|
|
"source": "cli", "session_started": 1709400000, "model": "test"},
|
|
]
|
|
mock_db.get_session.return_value = {
|
|
"id": "root_only", "parent_session_id": None, "source": "cli",
|
|
"started_at": 1709400000,
|
|
}
|
|
|
|
result = json.loads(session_search(query="hit", db=mock_db, mode="fast"))
|
|
entry = result["results"][0]
|
|
assert entry["session_id"] == "root_only"
|
|
assert entry["match_message_id"] == 7
|
|
assert "parent_session_id" not in entry, (
|
|
"parent_session_id should be absent when session has no lineage above it"
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Guided mode — anchored drill-down
|
|
# =========================================================================
|
|
|
|
class TestGuidedMode:
|
|
"""Tests for mode='guided': drill into a specific session at a specific
|
|
message id, returning a window of messages around the anchor. The
|
|
composition flow is: agent calls fast → reads the match_message_id /
|
|
session_id off a hit → calls back with mode='guided' to read the actual
|
|
conversation around that point.
|
|
"""
|
|
|
|
def _make_db(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
db = MagicMock()
|
|
# Default session metadata; tests can override via side_effect
|
|
db.get_session.return_value = {
|
|
"id": "sid",
|
|
"parent_session_id": None,
|
|
"source": "cli",
|
|
"started_at": 1709400000,
|
|
"model": "test-model",
|
|
"title": "Some title",
|
|
}
|
|
return db
|
|
|
|
def test_returns_window_around_anchor_with_metadata(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
# 5 messages around the anchor (id=200)
|
|
db.get_messages_around.return_value = [
|
|
{"id": 198, "role": "user", "content": "before-2"},
|
|
{"id": 199, "role": "assistant", "content": "before-1"},
|
|
{"id": 200, "role": "user", "content": "anchor"},
|
|
{"id": 201, "role": "assistant", "content": "after-1"},
|
|
{"id": 202, "role": "tool", "content": "after-2", "tool_name": "echo"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=200,
|
|
window=2,
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "guided"
|
|
assert result["session_id"] == "sid"
|
|
assert result["around_message_id"] == 200
|
|
assert result["window"] == 2
|
|
assert result["session_meta"]["source"] == "cli"
|
|
assert result["session_meta"]["model"] == "test-model"
|
|
assert result["session_meta"]["title"] == "Some title"
|
|
# Messages preserved in order
|
|
assert [m["id"] for m in result["messages"]] == [198, 199, 200, 201, 202]
|
|
# Anchor flagged exactly once on the right row
|
|
anchor_rows = [m for m in result["messages"] if m.get("anchor")]
|
|
assert len(anchor_rows) == 1 and anchor_rows[0]["id"] == 200
|
|
# Boundary counts
|
|
assert result["messages_before"] == 2
|
|
assert result["messages_after"] == 2
|
|
# Crucially: no FTS5, no aux LLM
|
|
db.search_messages.assert_not_called()
|
|
db.get_messages_around.assert_called_once_with("sid", 200, window=2)
|
|
|
|
def test_missing_session_id_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id=None,
|
|
around_message_id=200,
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "session_id" in result["error"].lower()
|
|
db.get_messages_around.assert_not_called()
|
|
|
|
def test_missing_around_message_id_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=None,
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "around_message_id" in result["error"].lower()
|
|
db.get_messages_around.assert_not_called()
|
|
|
|
def test_window_clamps_to_one_when_zero_or_negative(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
db.get_messages_around.return_value = [
|
|
{"id": 200, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=200,
|
|
window=0,
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["window"] == 1
|
|
# Confirm the clamp propagated to the DB call
|
|
db.get_messages_around.assert_called_once_with("sid", 200, window=1)
|
|
|
|
def test_window_clamps_to_twenty_when_too_large(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
db.get_messages_around.return_value = [
|
|
{"id": 200, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=200,
|
|
window=999,
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["window"] == 20
|
|
db.get_messages_around.assert_called_once_with("sid", 200, window=20)
|
|
|
|
def test_session_id_not_found_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
db.get_session.return_value = None # session doesn't exist
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="missing_sid",
|
|
around_message_id=200,
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "not found" in result["error"].lower()
|
|
# No drill attempted on a non-existent session
|
|
db.get_messages_around.assert_not_called()
|
|
|
|
def test_around_message_id_not_in_session_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
db.get_messages_around.return_value = [] # anchor not in session
|
|
# Make sure the rebind safety net finds no owning session either.
|
|
db.get_session_id_for_message = lambda mid: None
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=999999,
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "around_message_id" in result["error"].lower()
|
|
|
|
def test_guided_rebinds_anchor_when_message_lives_in_descendant_session(self):
|
|
"""Safety net: if (parent_sid, child_message_id) is passed (the broken
|
|
shape that fast emitted before the pairing fix, and that may still
|
|
appear from memory / legacy callers), guided locates the descendant
|
|
session that actually owns the message and rebinds transparently.
|
|
Rebind only happens within the same lineage; cross-lineage stays an
|
|
error.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = MagicMock()
|
|
|
|
# Lineage: parent_sid is root; child_sid is a delegation/compression child.
|
|
def _get_session(sid):
|
|
if sid == "parent_sid":
|
|
return {"id": "parent_sid", "parent_session_id": None,
|
|
"source": "tui", "started_at": 1709400000, "title": "parent"}
|
|
if sid == "child_sid":
|
|
return {"id": "child_sid", "parent_session_id": "parent_sid",
|
|
"source": "tui", "started_at": 1709500000, "title": "child"}
|
|
return None
|
|
db.get_session.side_effect = _get_session
|
|
|
|
# Message 4242 lives in child_sid, not parent_sid.
|
|
def _get_messages_around(sid, mid, window):
|
|
if sid == "child_sid" and mid == 4242:
|
|
return [
|
|
{"id": 4240, "role": "user", "content": "before-2"},
|
|
{"id": 4241, "role": "assistant", "content": "before-1"},
|
|
{"id": 4242, "role": "tool", "content": "ANCHOR"},
|
|
{"id": 4243, "role": "assistant", "content": "after-1"},
|
|
{"id": 4244, "role": "user", "content": "after-2"},
|
|
]
|
|
return []
|
|
db.get_messages_around.side_effect = _get_messages_around
|
|
|
|
# Safety-net lookup: which session owns message 4242? child_sid.
|
|
db.get_session_id_for_message = lambda mid: "child_sid" if mid == 4242 else None
|
|
|
|
# Agent passes the BROKEN pair (parent sid, child message id) — should rebind.
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="parent_sid",
|
|
around_message_id=4242,
|
|
))
|
|
|
|
assert result["success"] is True, result
|
|
windows = result["windows"]
|
|
assert len(windows) == 1
|
|
w = windows[0]
|
|
assert w["success"] is True
|
|
assert w["session_id"] == "child_sid", "should rebind to the owning session"
|
|
assert w["around_message_id"] == 4242
|
|
assert "warning" in w, "rebind must be surfaced via a warning field"
|
|
assert "rebound" in w["warning"].lower()
|
|
# Anchor flag is set on the right row.
|
|
anchor = next(m for m in w["messages"] if m.get("anchor"))
|
|
assert anchor["id"] == 4242
|
|
|
|
def test_guided_does_not_rebind_across_lineages(self):
|
|
"""Cross-lineage rebind is rejected — only same-lineage descendants count.
|
|
Protects against silently drilling into an unrelated session if some
|
|
ID collision happens.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = MagicMock()
|
|
|
|
def _get_session(sid):
|
|
if sid == "lineage_A":
|
|
return {"id": "lineage_A", "parent_session_id": None,
|
|
"started_at": 1709400000}
|
|
if sid == "lineage_B":
|
|
return {"id": "lineage_B", "parent_session_id": None,
|
|
"started_at": 1709500000}
|
|
return None
|
|
db.get_session.side_effect = _get_session
|
|
|
|
# Anchor 4242 is empty in A but owned by B (different lineage).
|
|
db.get_messages_around.return_value = []
|
|
db.get_session_id_for_message = lambda mid: "lineage_B" if mid == 4242 else None
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="lineage_A",
|
|
around_message_id=4242,
|
|
))
|
|
|
|
# Single-anchor failure surfaces as top-level tool_error (legacy shape).
|
|
assert result["success"] is False
|
|
assert "not in session_id" in result["error"]
|
|
|
|
def test_at_session_boundary_returns_partial_window(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
# Anchor near start of session — only 2 messages available before
|
|
db.get_messages_around.return_value = [
|
|
{"id": 1, "role": "user", "content": "first"},
|
|
{"id": 2, "role": "assistant", "content": "second"},
|
|
{"id": 3, "role": "user", "content": "anchor"},
|
|
{"id": 4, "role": "assistant", "content": "after-1"},
|
|
{"id": 5, "role": "user", "content": "after-2"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="sid",
|
|
around_message_id=3,
|
|
window=5, # asked for 5 each side, only 2 exist on the before side
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["messages_before"] == 2
|
|
assert result["messages_after"] == 2
|
|
# Anchor is on the right row even with partial window
|
|
anchor_rows = [m for m in result["messages"] if m.get("anchor")]
|
|
assert len(anchor_rows) == 1 and anchor_rows[0]["id"] == 3
|
|
|
|
def test_rejects_drill_into_current_session_lineage(self):
|
|
"""If the agent asks to drill into the very session it's running in,
|
|
return tool_error — those messages are already in its active context.
|
|
Same convention as fast/summary's _resolve_to_parent skip.
|
|
"""
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
# current session = "child", parent = "root"
|
|
# request drill into "root" → should be rejected (same lineage)
|
|
def _get_session(sid):
|
|
if sid == "child":
|
|
return {"id": "child", "parent_session_id": "root"}
|
|
if sid == "root":
|
|
return {"id": "root", "parent_session_id": None,
|
|
"source": "cli", "started_at": 1709400000, "model": "test-model"}
|
|
return None
|
|
|
|
db.get_session.side_effect = _get_session
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
session_id="root",
|
|
around_message_id=200,
|
|
current_session_id="child",
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "current session" in result["error"].lower()
|
|
db.get_messages_around.assert_not_called()
|
|
|
|
def test_aliases_drill_drilldown_anchor_around_normalize_to_guided(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
db.get_messages_around.return_value = [
|
|
{"id": 200, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
for alias in ("drill", "drilldown", "drill-down", "anchor", "around"):
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode=alias,
|
|
session_id="sid",
|
|
around_message_id=200,
|
|
))
|
|
assert result["mode"] == "guided", f"alias {alias!r} did not map to guided"
|
|
|
|
def test_schema_advertises_guided_mode(self):
|
|
from tools.session_search_tool import SESSION_SEARCH_SCHEMA
|
|
|
|
mode_param = SESSION_SEARCH_SCHEMA["parameters"]["properties"]["mode"]
|
|
assert "guided" in mode_param["enum"]
|
|
# Description teaches the discover→drill flow
|
|
desc = SESSION_SEARCH_SCHEMA["description"]
|
|
assert "guided" in desc.lower()
|
|
# match_message_id pairing guidance now lives on the anchors parameter
|
|
# (the only LLM-facing input to guided), not the top-level description.
|
|
props = SESSION_SEARCH_SCHEMA["parameters"]["properties"]
|
|
assert "match_message_id" in props["anchors"]["description"]
|
|
# Guided-mode parameters: anchors + window. Single-anchor session_id /
|
|
# around_message_id were removed from the schema as part of the
|
|
# parameter-surface cleanup — guided always takes anchors=[...]
|
|
# from the LLM's perspective (1 anchor for single, N for multi).
|
|
# The Python function still accepts them as kwargs for back-compat.
|
|
assert "anchors" in props
|
|
assert "window" in props
|
|
assert "session_id" not in props, (
|
|
"session_id was removed from the schema as part of the param-surface cleanup; "
|
|
"guided takes anchors=[...] only from the LLM"
|
|
)
|
|
assert "around_message_id" not in props, (
|
|
"around_message_id was removed from the schema as part of the param-surface cleanup"
|
|
)
|
|
|
|
|
|
class TestGuidedModeMultiAnchor:
|
|
"""Tests for the multi-anchor guided shape (anchors=[...]).
|
|
|
|
The agent calls fast with a wider limit, picks the most promising K hits,
|
|
and drills into all of them in a single guided call. Each anchor produces
|
|
its own window in the response's 'windows' array.
|
|
"""
|
|
|
|
def _make_db(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
db = MagicMock()
|
|
return db
|
|
|
|
def _stub_session(self, db, session_id):
|
|
"""Configure db.get_session to return valid metadata for this session_id."""
|
|
existing = db.get_session.side_effect
|
|
configured = getattr(self, "_configured_sessions", {})
|
|
configured[session_id] = {
|
|
"id": session_id,
|
|
"parent_session_id": None,
|
|
"source": "cli",
|
|
"started_at": 1709400000,
|
|
"model": "test-model",
|
|
"title": f"Session {session_id}",
|
|
}
|
|
self._configured_sessions = configured
|
|
|
|
def lookup(sid):
|
|
return configured.get(sid)
|
|
|
|
db.get_session.side_effect = lookup
|
|
|
|
def test_two_anchors_both_succeed_returns_two_windows(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
self._stub_session(db, "sid_A")
|
|
self._stub_session(db, "sid_B")
|
|
|
|
# Distinct windows per anchor
|
|
def get_messages_around(session_id, around_id, window):
|
|
if session_id == "sid_A" and around_id == 100:
|
|
return [
|
|
{"id": 99, "role": "user", "content": "A-before"},
|
|
{"id": 100, "role": "assistant", "content": "A-anchor"},
|
|
{"id": 101, "role": "tool", "content": "A-after"},
|
|
]
|
|
if session_id == "sid_B" and around_id == 200:
|
|
return [
|
|
{"id": 199, "role": "user", "content": "B-before"},
|
|
{"id": 200, "role": "assistant", "content": "B-anchor"},
|
|
{"id": 201, "role": "tool", "content": "B-after"},
|
|
]
|
|
return []
|
|
|
|
db.get_messages_around.side_effect = get_messages_around
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[
|
|
{"session_id": "sid_A", "around_message_id": 100},
|
|
{"session_id": "sid_B", "around_message_id": 200},
|
|
],
|
|
window=1,
|
|
))
|
|
|
|
assert result["success"] is True
|
|
assert result["mode"] == "guided"
|
|
assert result["anchor_count"] == 2
|
|
assert len(result["windows"]) == 2
|
|
|
|
# Both windows succeeded, each with the right anchor flagged
|
|
win_a = next(w for w in result["windows"] if w["session_id"] == "sid_A")
|
|
win_b = next(w for w in result["windows"] if w["session_id"] == "sid_B")
|
|
assert win_a["success"] is True and win_b["success"] is True
|
|
assert win_a["around_message_id"] == 100
|
|
assert win_b["around_message_id"] == 200
|
|
# Anchor flag on the right row in each window
|
|
anchors_a = [m for m in win_a["messages"] if m.get("anchor")]
|
|
anchors_b = [m for m in win_b["messages"] if m.get("anchor")]
|
|
assert len(anchors_a) == 1 and anchors_a[0]["id"] == 100
|
|
assert len(anchors_b) == 1 and anchors_b[0]["id"] == 200
|
|
# Multi-anchor responses do NOT mirror a top-level session_id
|
|
assert "session_id" not in result
|
|
assert "messages" not in result
|
|
|
|
# DB called once per anchor with the shared window
|
|
assert db.get_messages_around.call_count == 2
|
|
|
|
def test_one_anchor_fails_other_succeeds_does_not_abort(self):
|
|
"""Per-anchor failures become inline error entries; valid anchors still drill."""
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
# Only sid_A exists; sid_BAD is not stubbed
|
|
self._stub_session(db, "sid_A")
|
|
|
|
def get_messages_around(session_id, around_id, window):
|
|
if session_id == "sid_A":
|
|
return [{"id": 100, "role": "user", "content": "anchor"}]
|
|
return []
|
|
|
|
db.get_messages_around.side_effect = get_messages_around
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[
|
|
{"session_id": "sid_A", "around_message_id": 100},
|
|
{"session_id": "sid_BAD", "around_message_id": 999},
|
|
],
|
|
))
|
|
|
|
# Top-level reports overall success because at least one anchor drilled
|
|
assert result["success"] is True
|
|
assert result["anchor_count"] == 2
|
|
|
|
# First window succeeded, second window has an error entry
|
|
win_a = next(w for w in result["windows"] if w.get("session_id") == "sid_A")
|
|
win_bad = next(w for w in result["windows"] if w.get("session_id") == "sid_BAD")
|
|
assert win_a["success"] is True
|
|
assert win_bad["success"] is False
|
|
assert "not found" in win_bad["error"].lower()
|
|
|
|
def test_single_anchor_via_anchors_list_normalises_to_legacy_shape(self):
|
|
"""anchors=[{...}] with one entry should produce the same top-level
|
|
response shape as the legacy session_id+around_message_id call."""
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
self._stub_session(db, "sid_A")
|
|
db.get_messages_around.return_value = [
|
|
{"id": 100, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[{"session_id": "sid_A", "around_message_id": 100}],
|
|
))
|
|
|
|
# Single-anchor mirroring: legacy fields present at the top level
|
|
assert result["success"] is True
|
|
assert result["session_id"] == "sid_A"
|
|
assert result["around_message_id"] == 100
|
|
assert "messages" in result
|
|
assert "session_meta" in result
|
|
assert result["anchor_count"] == 1
|
|
# And the windows array is also present
|
|
assert len(result["windows"]) == 1
|
|
|
|
def test_empty_anchors_list_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[],
|
|
session_id=None,
|
|
around_message_id=None,
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "anchor" in result["error"].lower()
|
|
|
|
def test_anchors_non_list_returns_tool_error(self):
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors="not_a_list",
|
|
))
|
|
|
|
assert result["success"] is False
|
|
assert "list" in result["error"].lower()
|
|
|
|
def test_window_clamp_shared_across_anchors(self):
|
|
"""Window is shared across all anchors and clamped once."""
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
self._stub_session(db, "sid_A")
|
|
self._stub_session(db, "sid_B")
|
|
db.get_messages_around.return_value = [
|
|
{"id": 100, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[
|
|
{"session_id": "sid_A", "around_message_id": 100},
|
|
{"session_id": "sid_B", "around_message_id": 200},
|
|
],
|
|
window=999, # clamped to 20
|
|
))
|
|
|
|
assert result["window"] == 20
|
|
# Both DB calls used the clamped window
|
|
for call_args in db.get_messages_around.call_args_list:
|
|
assert call_args.kwargs.get("window") == 20
|
|
|
|
def test_per_anchor_current_lineage_rejection(self):
|
|
"""One anchor can be in the current lineage and rejected while another succeeds."""
|
|
from tools.session_search_tool import session_search
|
|
|
|
db = self._make_db()
|
|
# sid_root is the parent of the current session; drilling there should be rejected
|
|
# sid_other is unrelated and should succeed
|
|
configured = {
|
|
"child": {"id": "child", "parent_session_id": "sid_root"},
|
|
"sid_root": {"id": "sid_root", "parent_session_id": None,
|
|
"source": "cli", "started_at": 1709400000, "model": "test-model"},
|
|
"sid_other": {"id": "sid_other", "parent_session_id": None,
|
|
"source": "cli", "started_at": 1709400000, "model": "test-model"},
|
|
}
|
|
db.get_session.side_effect = configured.get
|
|
db.get_messages_around.return_value = [
|
|
{"id": 100, "role": "user", "content": "anchor"},
|
|
]
|
|
|
|
result = json.loads(session_search(
|
|
query="",
|
|
db=db,
|
|
mode="guided",
|
|
anchors=[
|
|
{"session_id": "sid_root", "around_message_id": 100},
|
|
{"session_id": "sid_other", "around_message_id": 100},
|
|
],
|
|
current_session_id="child",
|
|
))
|
|
|
|
assert result["success"] is True # at least one anchor drilled
|
|
rejected = next(w for w in result["windows"] if w.get("session_id") == "sid_root")
|
|
accepted = next(w for w in result["windows"] if w.get("session_id") == "sid_other")
|
|
assert rejected["success"] is False
|
|
assert "current session" in rejected["error"].lower()
|
|
assert accepted["success"] is True
|