Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-18 02:23:49 -05:00
20 changed files with 1745 additions and 1128 deletions
@@ -155,24 +155,6 @@ class TestSkillsGuardContentNone:
assert content == ""
# ── session_search_tool (line 164) ────────────────────────────────────────
class TestSessionSearchContentNone:
"""tools/session_search_tool.py — _summarize_session() return line"""
def test_none_content_raises_before_fix(self):
response = _make_response(None)
with pytest.raises(AttributeError):
response.choices[0].message.content.strip()
def test_none_content_safe_with_or_guard(self):
response = _make_response(None)
content = (response.choices[0].message.content or "").strip()
assert content == ""
# ── integration: verify the actual source lines are guarded ───────────────
class TestSourceLinesAreGuarded:
@@ -218,13 +200,6 @@ class TestSourceLinesAreGuarded:
".content.strip() — apply `(... or \"\").strip()` guard"
)
def test_session_search_tool_guarded(self):
src = self._read_file("tools/session_search_tool.py")
assert ".message.content.strip()" not in src, (
"tools/session_search_tool.py still has unguarded "
".content.strip() — apply `(... or \"\").strip()` guard"
)
# ── extract_content_or_reasoning() ────────────────────────────────────────
+344 -521
View File
@@ -1,578 +1,401 @@
"""Tests for tools/session_search_tool.py — helper functions and search dispatcher."""
"""Tests for the single-shape session_search tool.
import asyncio
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 (
_format_timestamp,
_format_conversation,
_truncate_around_matches,
_get_session_search_max_concurrency,
_list_recent_sessions,
_HIDDEN_SESSION_SOURCES,
MAX_SESSION_CHARS,
SESSION_SEARCH_SCHEMA,
_HIDDEN_SESSION_SOURCES,
_format_timestamp,
session_search,
)
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _seed_modpack_sessions(db):
"""Create three sessions about a modpack so FTS5 has hits to dedupe."""
now = int(time.time())
# Older session — modpack origin
db.create_session("s_oldest", source="cli")
db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?",
(now - 30000, "Building the Modpack", "s_oldest"))
db.append_message("s_oldest", role="user", content="Let's build a Minecraft modpack")
db.append_message("s_oldest", role="assistant", content="Great. Let me scaffold the modpack repo.")
db.append_message("s_oldest", role="user", content="Use NeoForge 1.21.1")
db.append_message("s_oldest", role="assistant", content="Done. Modpack repo created with NeoForge 1.21.1.")
db.append_message("s_oldest", role="assistant", content="Tier-0 mods installed; modpack smoke test passes.")
# Middle session — modpack quest coverage
db.create_session("s_middle", source="cli")
db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?",
(now - 15000, "Modpack Quest Coverage", "s_middle"))
db.append_message("s_middle", role="user", content="Deep-dive every modpack reference quest guide")
db.append_message("s_middle", role="assistant", content="Surveying ATM10 questbook for modpack inspiration.")
db.append_message("s_middle", role="user", content="Update the modpack version too")
db.append_message("s_middle", role="assistant", content="Modpack version bumped 0.4 → 0.8.5; quest coverage page added.")
# Newest session — modpack mob spawn fix
db.create_session("s_newest", source="cli")
db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?",
(now - 1000, "Modpack Mob Spawn Fix", "s_newest"))
db.append_message("s_newest", role="user", content="Fix the modpack mob spawning")
db.append_message("s_newest", role="assistant", content="Investigating elite mob gating in the modpack KubeJS.")
db.append_message("s_newest", role="assistant", content="Shipped commit b850442. Modpack alternator nerfed too.")
db._conn.commit()
# =========================================================================
# Tool schema guidance
# Schema invariants
# =========================================================================
class TestHiddenSessionSources:
"""Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation."""
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_tool_source_is_hidden(self):
def test_no_mode_parameter(self):
# Mode is inferred from which args are set — no explicit mode param
params = SESSION_SEARCH_SCHEMA["parameters"]["properties"]
assert "mode" not in params
def test_sort_enum(self):
params = SESSION_SEARCH_SCHEMA["parameters"]["properties"]
assert params["sort"]["enum"] == ["newest", "oldest"]
def test_schema_description_teaches_scroll(self):
desc = SESSION_SEARCH_SCHEMA["description"]
assert "SCROLL" in desc
assert "DISCOVERY" in desc
assert "BROWSE" in desc
# Must explain how to scroll
assert "scroll FORWARD" in desc or "messages[-1]" in desc
def test_no_llm_promise_in_description(self):
# The new design never calls an LLM
desc = SESSION_SEARCH_SCHEMA["description"].lower()
assert "no llm" in desc
class TestHiddenSources:
def test_tool_source_hidden(self):
assert "tool" in _HIDDEN_SESSION_SOURCES
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_timestamp(self):
out = _format_timestamp(1700000000)
assert "2023" in out
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):
def test_none(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()
def test_iso_string_passthrough(self):
out = _format_timestamp("not-a-number-string")
assert out == "not-a-number-string"
# =========================================================================
# _format_conversation
# Browse shape (no args)
# =========================================================================
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"
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))
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["count"] == 3
assert max_seen["value"] == 1
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
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"])
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)
# =========================================================================
# session_search (dispatcher)
# Discovery shape (with query)
# =========================================================================
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"))
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
mock_db.search_messages.assert_called_once()
assert result["mode"] == "discover"
assert result["count"] >= 1
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_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_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_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_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,
))
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["count"] == 0
assert result["results"] == []
assert result["count"] == 0
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
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
mock_db = MagicMock()
current_sid = "20260304_120000_abc123"
other_sid = "20260303_100000_def456"
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
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"},
]
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
# 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")):
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(
query="test", db=mock_db, current_session_id=current_sid,
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()
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_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
def test_scroll_invalid_around_message_id_errors(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(
query="test", db=mock_db, current_session_id="child_sid",
session_id="s_oldest", around_message_id="not-an-int", db=db
))
assert result["success"] is False
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
class TestScrollPattern:
"""The forward/backward scroll loop using tool output."""
mock_db = MagicMock()
mock_db.search_messages.return_value = []
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="test", db=mock_db, limit=None,
query="modpack", # would normally trigger discovery
session_id=anchor_sid, around_message_id=anchor_mid, db=db,
))
assert result["success"] is True
assert result["mode"] == "scroll"
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
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"
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'.
"""
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",
"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"),
):
result = json.loads(session_search(query="hello world", db=mock_db))
assert result["success"] is True
assert result["count"] == 1
entry = result["results"][0]
assert entry["session_id"] == "parent_sid", "should report resolved parent session ID"
assert entry["source"] == "api_server", (
f"source should be parent's 'api_server', got {entry['source']!r}"
)
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"