Expand tests

This commit is contained in:
Yiorgis Gozadinos 2026-02-13 14:34:24 +02:00
parent f5c562db9b
commit c0ed93da2d
No known key found for this signature in database
38 changed files with 8209 additions and 92 deletions

View file

@ -112,7 +112,7 @@ async def _update_context_background(
except asyncio.CancelledError:
pass
except Exception as e:
except Exception as e: # pragma: no cover
import logging
logging.getLogger(__name__).exception(f"Background summarization failed: {e}")

View file

@ -29,7 +29,7 @@ def create_analysis_toolset(
FunctionToolset with an analyze tool.
"""
async def analyze(
async def analyze( # pragma: no cover
ctx: RunContext[RAGDeps],
task: str,
document_name: str | None = None,

View file

@ -97,7 +97,7 @@ def create_search_toolset(
chunk_id = r.chunk_id or ""
if chunk_id:
index = session_state.get_or_assign_index(chunk_id)
else:
else: # pragma: no cover
index = len(session_state.citation_registry) + 1
citations.append(
Citation(
@ -121,7 +121,7 @@ def create_search_toolset(
snippet += "..."
line = f"[{c.index}] **{title}**"
if c.page_numbers:
if c.page_numbers: # pragma: no cover
line += f" (pages {', '.join(map(str, c.page_numbers))})"
line += f"\n {snippet}"
result_lines.append(line)
@ -140,7 +140,7 @@ def create_search_toolset(
metadata=[state_event],
)
return formatted
return formatted # pragma: no cover
# Format results without citation indexing (standalone use)
total = len(results)

View file

@ -9,6 +9,7 @@ from haiku.rag.agents.chat import (
ChatSessionState,
create_chat_agent,
prepare_chat_context,
run_chat_agent,
)
from haiku.rag.agents.chat.context import _summarization_tasks
from haiku.rag.agents.research.models import Citation
@ -83,6 +84,26 @@ def test_agui_state_key_constant():
assert AGUI_STATE_KEY == "haiku.rag.chat"
def test_chat_deps_state_setter_none(temp_db_path):
"""Test ChatDeps.state setter handles None gracefully."""
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
context.register(QA_SESSION_NAMESPACE, QASessionState())
context.register(SESSION_NAMESPACE, SessionState())
deps = ChatDeps(config=Config, client=client, tool_context=context)
# Setting state to None should be a no-op
deps.state = None
# State should remain unchanged
qa_session_state = context.get(QA_SESSION_NAMESPACE)
assert isinstance(qa_session_state, QASessionState)
assert qa_session_state.session_context is None
client.close()
def test_chat_deps_state_setter_handles_initial_context(temp_db_path):
"""Test ChatDeps.state setter transfers initial_context to qa_session_state."""
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
@ -200,6 +221,24 @@ def test_chat_deps_state_setter_preserves_server_session_context(temp_db_path):
client.close()
def test_trigger_background_summarization_no_qa_history(temp_db_path):
"""Test trigger_background_summarization returns early when qa_history is empty."""
from haiku.rag.agents.chat.agent import trigger_background_summarization
from haiku.rag.agents.chat.context import _summarization_tasks
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
context.register(QA_SESSION_NAMESPACE, QASessionState())
context.register(SESSION_NAMESPACE, SessionState())
deps = ChatDeps(config=Config, client=client, tool_context=context)
tasks_before = len(_summarization_tasks)
trigger_background_summarization(deps)
assert len(_summarization_tasks) == tasks_before
client.close()
def test_chat_session_state():
"""Test ChatSessionState model."""
state = ChatSessionState()
@ -339,6 +378,31 @@ Scanned documents were excluded to avoid rotation and skewing issues.
"""
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_run_chat_agent(allow_model_requests, temp_db_path):
"""Test run_chat_agent returns agent output string."""
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
content=DOCLAYNET_CLASS_LABELS,
uri="doclaynet-labels",
title="DocLayNet Class Labels",
)
context = ToolContext()
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
output = await run_chat_agent(agent, deps, "Search for class labels")
assert isinstance(output, str)
assert len(output) > 0
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_chat_agent_search_tool(allow_model_requests, temp_db_path):

View file

@ -230,6 +230,74 @@ class TestUpdateSessionContext:
assert result.summary == ""
class TestTriggerBackgroundSummarization:
"""Tests for trigger_background_summarization."""
def test_trigger_with_empty_qa_history(self):
"""trigger_background_summarization returns early with empty qa_history."""
from haiku.rag.agents.chat.context import (
_summarization_tasks,
trigger_background_summarization,
)
from haiku.rag.tools.qa import QASessionState
tasks_before = len(_summarization_tasks)
qa_session_state = QASessionState()
assert len(qa_session_state.qa_history) == 0
trigger_background_summarization(qa_session_state, config=Config)
# No new task should have been created
assert len(_summarization_tasks) == tasks_before
@pytest.mark.asyncio
async def test_trigger_cancels_existing_task(self):
"""Second trigger cancels the previous background task."""
import asyncio
from unittest.mock import patch
from haiku.rag.agents.chat.context import (
_summarization_tasks,
trigger_background_summarization,
)
from haiku.rag.tools.qa import QAHistoryEntry, QASessionState
_summarization_tasks.clear()
qa_session_state = QASessionState(
qa_history=[QAHistoryEntry(question="Q1", answer="A1", confidence=0.9)]
)
# Patch _update_context_background to be a slow coroutine
async def slow_background(*args, **kwargs):
await asyncio.sleep(10)
with patch(
"haiku.rag.agents.chat.context._update_context_background",
new=slow_background,
):
# First trigger creates a task
trigger_background_summarization(qa_session_state, config=Config)
key = id(qa_session_state)
assert key in _summarization_tasks
first_task = _summarization_tasks[key]
# Second trigger should cancel the first
trigger_background_summarization(qa_session_state, config=Config)
await asyncio.sleep(0) # Let cancellation propagate
assert first_task.cancelled() or first_task.done()
# Cleanup
if key in _summarization_tasks:
_summarization_tasks[key].cancel()
try:
await _summarization_tasks[key]
except asyncio.CancelledError:
pass
_summarization_tasks.clear()
class TestUpdateSessionContextPassesCurrentContext:
"""Tests for update_session_context current_context forwarding."""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,4 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -9,6 +10,11 @@ from haiku.rag.tools.document import (
)
@pytest.fixture(scope="module")
def vcr_cassette_dir():
return str(Path(__file__).parent.parent / "cassettes" / "test_document_tools")
def make_ctx(client, context=None):
"""Create a lightweight RunContext-like object for direct tool function calls."""
return SimpleNamespace(deps=SimpleNamespace(client=client, tool_context=context))
@ -143,6 +149,45 @@ class TestDocumentToolExecution:
assert result.documents[0].title == "Python Guide"
@pytest.mark.vcr()
class TestFindDocument:
"""Tests for find_document helper function."""
@pytest.mark.asyncio
async def test_find_document_partial_uri(self, doc_client):
"""find_document resolves partial URI match."""
from haiku.rag.tools.document import find_document
doc = await find_document(doc_client, "python")
assert doc is not None
assert doc.uri == "test://python"
@pytest.mark.asyncio
async def test_find_document_partial_title(self, doc_client):
"""find_document resolves partial title match."""
from haiku.rag.tools.document import find_document
doc = await find_document(doc_client, "JavaScript")
assert doc is not None
assert doc.title == "JavaScript Guide"
@pytest.mark.vcr()
class TestSummarizeDocumentTool:
"""Tests for summarize_document tool."""
@pytest.mark.asyncio
async def test_summarize_document_not_found(self, doc_client, doc_config):
"""summarize_document returns not-found message for nonexistent document."""
toolset = create_document_toolset(doc_config)
summarize_tool = toolset.tools["summarize_document"]
ctx = make_ctx(doc_client)
result = await summarize_tool.function(ctx, "nonexistent document")
assert "Document not found" in result
@pytest.fixture
async def doc_client(temp_db_path):
"""Create a HaikuRAG client with test documents."""

View file

@ -1,6 +1,29 @@
import pytest
from pathlib import Path
from types import SimpleNamespace
from haiku.rag.tools.qa import create_qa_toolset
import pytest
from pydantic_ai import ToolReturn
from haiku.rag.tools import ToolContext, prepare_context
from haiku.rag.tools.models import QAResult
from haiku.rag.tools.qa import (
MAX_QA_HISTORY,
QA_SESSION_NAMESPACE,
QASessionState,
create_qa_toolset,
run_qa_core,
)
from haiku.rag.tools.session import SESSION_NAMESPACE, SessionState
@pytest.fixture(scope="module")
def vcr_cassette_dir():
return str(Path(__file__).parent.parent / "cassettes" / "test_qa_tools")
def make_ctx(client, context=None):
"""Create a lightweight RunContext-like object for direct tool function calls."""
return SimpleNamespace(deps=SimpleNamespace(client=client, tool_context=context))
class TestQAToolset:
@ -25,12 +48,201 @@ class TestQAToolset:
assert "ask" not in toolset.tools
@pytest.mark.vcr()
class TestRunQACore:
"""Tests for run_qa_core."""
@pytest.mark.asyncio
async def test_run_qa_core_with_session_state(
self, allow_model_requests, qa_client, qa_config
):
"""run_qa_core with SessionState assigns citation indices via registry."""
context = ToolContext()
prepare_context(context, features=["qa"])
result = await run_qa_core(
client=qa_client,
config=qa_config,
question="What is Python?",
context=context,
)
assert isinstance(result, QAResult)
assert result.answer
session_state = context.get(SESSION_NAMESPACE, SessionState)
assert session_state is not None
# If citations were returned, they should use registry indices
if result.citations:
assert len(session_state.citation_registry) > 0
@pytest.mark.asyncio
async def test_run_qa_core_without_context(
self, allow_model_requests, qa_client, qa_config
):
"""run_qa_core without context uses sequential fallback indices."""
result = await run_qa_core(
client=qa_client,
config=qa_config,
question="What is Python?",
context=None,
)
assert isinstance(result, QAResult)
assert result.answer
# Without context, citation indices are i+1
for i, c in enumerate(result.citations):
assert c.index == i + 1
@pytest.mark.asyncio
async def test_run_qa_core_on_qa_complete_callback(
self, allow_model_requests, qa_client, qa_config
):
"""run_qa_core invokes on_qa_complete callback when context is provided."""
context = ToolContext()
prepare_context(context, features=["qa"])
callback_calls: list[tuple] = []
def on_complete(qa_session_state, config):
callback_calls.append((qa_session_state, config))
await run_qa_core(
client=qa_client,
config=qa_config,
question="What is Python?",
context=context,
on_qa_complete=on_complete,
)
assert len(callback_calls) == 1
assert isinstance(callback_calls[0][0], QASessionState)
@pytest.mark.asyncio
async def test_run_qa_core_fifo_limit(
self, allow_model_requests, qa_client, qa_config
):
"""run_qa_core trims qa_history beyond MAX_QA_HISTORY."""
context = ToolContext()
prepare_context(context, features=["qa"])
qa_session_state = context.get(QA_SESSION_NAMESPACE, QASessionState)
assert qa_session_state is not None
# Pre-fill with MAX_QA_HISTORY entries
from haiku.rag.tools.qa import QAHistoryEntry
qa_session_state.qa_history = [
QAHistoryEntry(question=f"Q{i}", answer=f"A{i}", confidence=0.9)
for i in range(MAX_QA_HISTORY)
]
await run_qa_core(
client=qa_client,
config=qa_config,
question="One more question?",
context=context,
)
# After adding one more, FIFO should trim to MAX_QA_HISTORY
assert len(qa_session_state.qa_history) == MAX_QA_HISTORY
# The oldest entry (Q0) should have been trimmed
assert qa_session_state.qa_history[0].question != "Q0"
@pytest.mark.vcr()
class TestRunQACoreWithPriorAnswers:
"""Tests for run_qa_core prior answer matching."""
@pytest.mark.asyncio
async def test_run_qa_core_matches_prior_answers(
self, allow_model_requests, qa_client, qa_config
):
"""run_qa_core matches prior answers when embedding similarity is high."""
from unittest.mock import AsyncMock, patch
from haiku.rag.tools.qa import QAHistoryEntry
context = ToolContext()
prepare_context(context, features=["qa"])
qa_session_state = context.get(QA_SESSION_NAMESPACE, QASessionState)
assert qa_session_state is not None
# Pre-populate with a prior answer that has a known embedding
prior_embedding = [0.5] * 2560
qa_session_state.qa_history = [
QAHistoryEntry(
question="What is Python?",
answer="A programming language.",
confidence=0.9,
question_embedding=prior_embedding,
)
]
# Mock the embedder to return a near-identical embedding for the new question
mock_embedder = AsyncMock()
mock_embedder.embed_query = AsyncMock(return_value=[0.5] * 2560)
with patch("haiku.rag.tools.qa.get_embedder", return_value=mock_embedder):
result = await run_qa_core(
client=qa_client,
config=qa_config,
question="Tell me about Python",
context=context,
)
assert isinstance(result, QAResult)
assert result.answer
@pytest.mark.vcr()
class TestAskTool:
"""Tests for the ask tool in create_qa_toolset."""
@pytest.mark.asyncio
async def test_ask_without_tool_context(
self, allow_model_requests, qa_client, qa_config
):
"""ask tool without tool context returns raw QAResult."""
toolset = create_qa_toolset(qa_config)
ask_tool = toolset.tools["ask"]
ctx = make_ctx(qa_client, None)
result = await ask_tool.function(ctx, "What is Python?")
assert isinstance(result, QAResult)
assert result.answer
@pytest.mark.asyncio
async def test_ask_with_tool_context_returns_tool_return(
self, allow_model_requests, qa_client, qa_config
):
"""ask tool with tool context returns ToolReturn with state snapshot."""
context = ToolContext()
prepare_context(context, features=["qa"])
toolset = create_qa_toolset(qa_config)
ask_tool = toolset.tools["ask"]
ctx = make_ctx(qa_client, context)
result = await ask_tool.function(ctx, "What is Python?")
assert isinstance(result, ToolReturn)
assert result.metadata is not None
assert len(result.metadata) > 0
@pytest.fixture
async def qa_client_simple(temp_db_path):
"""Create a HaikuRAG client without documents for basic tests."""
async def qa_client(temp_db_path):
"""Create a HaikuRAG client with test documents for QA tests."""
from haiku.rag.client import HaikuRAG
async with HaikuRAG(temp_db_path, create=True) as rag:
await rag.create_document(
"Python is a programming language. It is widely used for web development.",
uri="test://python",
title="Python Guide",
)
yield rag

View file

@ -1,9 +1,17 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from pydantic_ai import ToolReturn
from haiku.rag.tools import ToolContext
from haiku.rag.tools import ToolContext, prepare_context
from haiku.rag.tools.search import SEARCH_NAMESPACE, SearchState, create_search_toolset
from haiku.rag.tools.session import SESSION_NAMESPACE, SessionState
@pytest.fixture(scope="module")
def vcr_cassette_dir():
return str(Path(__file__).parent.parent / "cassettes" / "test_search_tools")
def make_ctx(client, context=None):
@ -214,6 +222,86 @@ async def search_client(temp_db_path):
yield rag
@pytest.mark.vcr()
class TestSearchWithSessionState:
"""Tests for search tool with session state (citation indexing path)."""
@pytest.mark.asyncio
async def test_search_with_session_state_returns_tool_return(
self, search_client, search_config
):
"""Search with SessionState returns ToolReturn with StateDeltaEvent."""
context = ToolContext()
prepare_context(context, features=["search"])
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
ctx = make_ctx(search_client, context)
result = await search_tool.function(ctx, "Python")
assert isinstance(result, ToolReturn)
assert result.metadata is not None
assert len(result.metadata) > 0
@pytest.mark.asyncio
async def test_search_with_session_state_populates_citations(
self, search_client, search_config
):
"""Search populates SessionState.citation_registry and citations."""
context = ToolContext()
prepare_context(context, features=["search"])
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
ctx = make_ctx(search_client, context)
await search_tool.function(ctx, "Python")
session_state = context.get(SESSION_NAMESPACE, SessionState)
assert session_state is not None
assert len(session_state.citation_registry) > 0
assert len(session_state.citations) > 0
@pytest.mark.asyncio
async def test_search_with_session_state_formatted_output(
self, search_client, search_config
):
"""Search with SessionState formats results with [index] **Title**."""
context = ToolContext()
prepare_context(context, features=["search"])
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
ctx = make_ctx(search_client, context)
result = await search_tool.function(ctx, "Python")
assert isinstance(result, ToolReturn)
output = result.return_value
assert "Found" in output
assert "[1]" in output
assert "**" in output
@pytest.mark.asyncio
async def test_search_citations_accumulate_across_calls(
self, search_client, search_config
):
"""Multiple searches accumulate citation indices across calls."""
context = ToolContext()
prepare_context(context, features=["search"])
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
ctx = make_ctx(search_client, context)
await search_tool.function(ctx, "Python")
session_state = context.get(SESSION_NAMESPACE, SessionState)
assert session_state is not None
first_count = len(session_state.citation_registry)
await search_tool.function(ctx, "JavaScript")
# New chunks should get higher indices
assert len(session_state.citation_registry) >= first_count
@pytest.fixture
def search_config():
"""Default AppConfig for search tests."""

View file

@ -0,0 +1,86 @@
from ag_ui.core import EventType, StateDeltaEvent
from haiku.rag.tools.session import (
SessionState,
compute_combined_state_delta,
compute_state_delta,
)
class TestComputeStateDelta:
"""Tests for compute_state_delta."""
def test_returns_delta_on_change(self):
"""compute_state_delta returns StateDeltaEvent when state changed."""
old = SessionState()
new = SessionState(citation_registry={"chunk-a": 1})
result = compute_state_delta(old, new)
assert isinstance(result, StateDeltaEvent)
assert result.type == EventType.STATE_DELTA
assert len(result.delta) > 0
def test_returns_none_on_no_change(self):
"""compute_state_delta returns None when states are identical."""
state = SessionState(document_filter=["doc1"])
result = compute_state_delta(state, state.model_copy(deep=True))
assert result is None
def test_with_state_key(self):
"""compute_state_delta wraps delta under state_key."""
old = SessionState()
new = SessionState(document_filter=["doc1"])
result = compute_state_delta(old, new, state_key="my.key")
assert isinstance(result, StateDeltaEvent)
# The delta paths should be prefixed with /my.key/
paths = [op["path"] for op in result.delta]
assert all(p.startswith("/my.key/") for p in paths)
class TestComputeCombinedStateDelta:
"""Tests for compute_combined_state_delta."""
def test_returns_delta_on_change(self):
"""compute_combined_state_delta returns StateDeltaEvent when snapshots differ."""
old = {"citations": []}
new = {"citations": [{"index": 1, "chunk_id": "c1"}]}
result = compute_combined_state_delta(old, new)
assert isinstance(result, StateDeltaEvent)
assert result.type == EventType.STATE_DELTA
def test_returns_none_on_no_change(self):
"""compute_combined_state_delta returns None when snapshots are identical."""
snapshot = {"citations": [], "document_filter": []}
result = compute_combined_state_delta(snapshot, snapshot.copy())
assert result is None
def test_with_state_key_wraps(self):
"""compute_combined_state_delta wraps under state_key."""
old = {"value": 1}
new = {"value": 2}
result = compute_combined_state_delta(old, new, state_key="ns")
assert isinstance(result, StateDeltaEvent)
paths = [op["path"] for op in result.delta]
assert all(p.startswith("/ns/") for p in paths)
def test_without_state_key(self):
"""compute_combined_state_delta works without state_key."""
old = {"value": 1}
new = {"value": 2}
result = compute_combined_state_delta(old, new)
assert isinstance(result, StateDeltaEvent)
paths = [op["path"] for op in result.delta]
assert any(p == "/value" for p in paths)