Expand tests
This commit is contained in:
parent
f5c562db9b
commit
c0ed93da2d
38 changed files with 8209 additions and 92 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
488
tests/cassettes/test_chat_agent/test_run_chat_agent.yaml
Normal file
488
tests/cassettes/test_chat_agent/test_run_chat_agent.yaml
Normal file
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
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
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
86
tests/tools/test_session.py
Normal file
86
tests/tools/test_session.py
Normal 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)
|
||||
Loading…
Reference in a new issue