"""Unit tests for the `search_knowledge_base` tool handler (Phase 4)."""
import sys
from pathlib import Path
from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parents[2]
STUDIO_BACKEND = REPO_ROOT / "studio" / "backend"
if str(STUDIO_BACKEND) not in sys.path:
sys.path.insert(0, str(STUDIO_BACKEND))
def _make_hit(chunk_id: str):
"""Minimal stand-in for retrieval.Hit — just needs .chunk_id."""
class _Hit:
pass
h = _Hit()
h.chunk_id = chunk_id
h.score = 1.0
h.kind = "text"
h.document_id = None
h.chunk_index = 0
return h
def test_empty_query_returns_error():
from core.rag.tool import search_knowledge_base
result = search_knowledge_base(query = "", scope_thread_id = "t-1")
assert result.startswith("Error:")
assert "empty" in result.lower()
def test_missing_scope_returns_user_facing_hint():
from core.rag.tool import search_knowledge_base
result = search_knowledge_base(
query = "anything",
scope_kb_id = None,
scope_thread_id = None,
)
assert "No knowledge base" in result
assert "thread documents" in result
def test_kb_takes_precedence_over_thread():
"""When both kb_id and thread_id are passed, kb_id wins."""
from core.rag import tool
captured = {}
def _stub_retrieve(scope, query, *args, **kwargs):
captured["scope"] = scope
return []
with patch("core.rag.retrieval.retrieve_hybrid", _stub_retrieve):
result = tool.search_knowledge_base(
query = "x",
scope_kb_id = "kb-abc",
scope_thread_id = "thread-xyz",
)
assert captured["scope"].startswith("kb_")
assert "kb-abc" in captured["scope"]
assert "thread" not in captured["scope"].split("kb_")[1]
def test_thread_scope_when_only_thread_set():
from core.rag import tool
captured = {}
def _stub_retrieve(scope, query, *args, **kwargs):
captured["scope"] = scope
return []
with patch("core.rag.retrieval.retrieve_hybrid", _stub_retrieve):
tool.search_knowledge_base(
query = "x",
scope_thread_id = "thread-xyz",
)
assert captured["scope"].startswith("thread_")
def test_empty_results_message_is_user_facing():
from core.rag.tool import _format_hits_for_llm
result = _format_hits_for_llm([])
assert "No matching chunks" in result
def test_format_hits_produces_fenced_chunks():
from core.rag.tool import _format_hits_for_llm
hits = [
{
"filename": "alpha.pdf",
"page_number": 3,
"text": "first body",
"score": 0.78,
"chunk_index": 12,
"token_count": 42,
},
{
"filename": "beta.md",
"page_number": None,
"text": "second body",
"score": 0.61,
},
]
result = _format_hits_for_llm(hits)
assert (
''
in result
)
assert 'chunk_index="12"' in result
assert 'tokens="42"' in result
assert "first body\n" in result
assert '' in result
# Blank line between blocks so the model can scan them.
assert "\n\n' in result
assert "\norphan\n" in result
def test_format_hits_offsets_ids_by_start_id():
from core.rag.tool import _format_hits_for_llm
hits = [
{"filename": "a.pdf", "text": "first"},
{"filename": "b.pdf", "text": "second"},
]
result = _format_hits_for_llm(hits, start_id = 5)
assert '