"""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 '