Files
tatlock/tests/agents/librarian/test_tools.py
T
jpmschweitzerandClaude Fable 5 f853db8ccc fix(agents): make librarian failures structured and user-safe
- run_librarian / run_librarian_stream raise AgentError instead of
  returning/yielding error text as normal output; detail stays in logs
- delegate_to_* wrappers now put a curated butler-toned sentence in
  DelegationResult.output on failure and never expose str(e), so
  streaming's error branch is reachable and honest
- _execute_single_delegation propagates success; direct delegation only
  records delegate_to_* as called when the expert actually succeeded
- librarian tools return user-safe messages instead of
  'Error searching: {e}' strings that leaked internal URLs into
  synthesis; coordination stream errors are curated as well
- ruff cleanups (TYPE_CHECKING forward refs, B904, unused locals) in
  the touched files to keep them lint-clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:12:30 +02:00

430 lines
15 KiB
Python

"""
Tests for Librarian tools.
Tests the tool functions that wrap the Library-Desk API,
including the new web search and content extraction tools.
"""
from unittest.mock import AsyncMock, patch
import pytest
from src.agents.librarian.client import (
BatchExtractionResponse,
ContentExtractionResult,
WebSearchResponse,
WebSearchResult,
)
from src.agents.librarian.tools import (
read_url,
read_urls_batch,
search_web,
)
@pytest.fixture
def mock_client():
"""Create a mock LibraryDeskClient."""
client = AsyncMock()
return client
# ============================================================================
# Web Search Tests
# ============================================================================
@pytest.mark.unit
class TestSearchWeb:
"""Tests for search_web tool."""
@pytest.mark.asyncio
async def test_search_web_success(self, mock_client):
"""Test successful web search."""
mock_response = WebSearchResponse(
query="Python async programming",
search_type="web",
results=[
WebSearchResult(
title="Async Python Tutorial",
url="https://example.com/async",
content="Full content about async programming...",
snippet="Learn async programming in Python",
source="example.com",
),
WebSearchResult(
title="AsyncIO Documentation",
url="https://docs.python.org/asyncio",
content="Official asyncio docs content...",
snippet="Python asyncio library reference",
source="docs.python.org",
),
],
total_results=2,
search_time_ms=150,
sources_summary="**Sources:**\n- example.com\n- docs.python.org",
)
mock_client.search_web.return_value = mock_response
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await search_web("Python async programming")
assert "Python async programming" in result
assert "Async Python Tutorial" in result
assert "https://example.com/async" in result
assert "example.com" in result
assert "150ms" in result or "2 results" in result
@pytest.mark.asyncio
async def test_search_web_no_results(self, mock_client):
"""Test web search with no results."""
mock_response = WebSearchResponse(
query="nonexistent query xyz123",
search_type="web",
results=[],
total_results=0,
search_time_ms=50,
)
mock_client.search_web.return_value = mock_response
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await search_web("nonexistent query xyz123")
assert "No results found" in result
@pytest.mark.asyncio
async def test_search_web_error_handling(self, mock_client):
"""Test web search errors return a user-safe message without internals."""
mock_client.search_web.side_effect = Exception(
"Connection failed to http://internal-host:8089"
)
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await search_web("test query")
assert "unable to search the web" in result
# Exception detail (internal URLs etc.) must not leak
assert "Connection failed" not in result
assert "internal-host" not in result
@pytest.mark.asyncio
async def test_search_web_with_news_type(self, mock_client):
"""Test web search with news search type."""
mock_response = WebSearchResponse(
query="latest tech news",
search_type="news",
results=[
WebSearchResult(
title="Tech News Today",
url="https://news.example.com/tech",
snippet="Breaking tech news",
source="news.example.com",
published_date="2024-01-15",
),
],
total_results=1,
search_time_ms=100,
)
mock_client.search_web.return_value = mock_response
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await search_web("latest tech news", search_type="news")
assert "Tech News Today" in result
mock_client.search_web.assert_called_with(
query="latest tech news",
limit=10,
search_type="news",
)
# ============================================================================
# Read URL Tests
# ============================================================================
@pytest.mark.unit
class TestReadUrl:
"""Tests for read_url tool."""
@pytest.mark.asyncio
async def test_read_url_success(self, mock_client):
"""Test successful URL content extraction."""
mock_result = ContentExtractionResult(
url="https://example.com/article",
title="Great Article Title",
content="This is the full article content extracted from the page.",
author="John Doe",
date="2024-01-10",
language="en",
success=True,
)
mock_client.extract_content.return_value = mock_result
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await read_url("https://example.com/article")
assert "Great Article Title" in result
assert "https://example.com/article" in result
assert "John Doe" in result
assert "full article content" in result
@pytest.mark.asyncio
async def test_read_url_failure(self, mock_client):
"""Test URL extraction failure."""
mock_result = ContentExtractionResult(
url="https://example.com/blocked",
success=False,
error="403 Forbidden",
)
mock_client.extract_content.return_value = mock_result
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await read_url("https://example.com/blocked")
assert "Could not read page" in result
assert "403 Forbidden" in result
@pytest.mark.asyncio
async def test_read_url_with_max_length(self, mock_client):
"""Test URL extraction with custom max length."""
mock_result = ContentExtractionResult(
url="https://example.com/long",
title="Long Article",
content="X" * 10000,
success=True,
)
mock_client.extract_content.return_value = mock_result
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
await read_url("https://example.com/long", max_length=2000)
mock_client.extract_content.assert_called_with(
url="https://example.com/long",
include_metadata=True,
max_length=2000,
)
# ============================================================================
# Batch URL Tests
# ============================================================================
@pytest.mark.unit
class TestReadUrlsBatch:
"""Tests for read_urls_batch tool."""
@pytest.mark.asyncio
async def test_batch_success(self, mock_client):
"""Test successful batch extraction."""
mock_response = BatchExtractionResponse(
results=[
ContentExtractionResult(
url="https://example.com/1",
title="Article 1",
content="Content from article 1",
success=True,
),
ContentExtractionResult(
url="https://example.com/2",
title="Article 2",
content="Content from article 2",
success=True,
),
],
total_urls=2,
successful=2,
failed=0,
extraction_time_ms=300,
)
mock_client.extract_content_batch.return_value = mock_response
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await read_urls_batch([
"https://example.com/1",
"https://example.com/2",
])
assert "Article 1" in result
assert "Article 2" in result
assert "2/2" in result or "Extracted 2" in result
@pytest.mark.asyncio
async def test_batch_partial_failure(self, mock_client):
"""Test batch extraction with some failures."""
mock_response = BatchExtractionResponse(
results=[
ContentExtractionResult(
url="https://example.com/good",
title="Good Article",
content="Content extracted successfully",
success=True,
),
ContentExtractionResult(
url="https://example.com/bad",
success=False,
error="Connection timeout",
),
],
total_urls=2,
successful=1,
failed=1,
extraction_time_ms=500,
)
mock_client.extract_content_batch.return_value = mock_response
with patch(
"src.agents.librarian.tools.LibraryDeskClient"
) as mock_client_class:
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await read_urls_batch([
"https://example.com/good",
"https://example.com/bad",
])
# Should contain successful result
assert "Good Article" in result
# Should report failure
assert "Failed" in result
assert "Connection timeout" in result
# ============================================================================
# Response Model Tests
# ============================================================================
@pytest.mark.unit
class TestWebSearchModels:
"""Tests for web search response models."""
def test_web_search_result_model(self):
"""Test WebSearchResult model."""
result = WebSearchResult(
title="Test Title",
url="https://example.com",
content="Full content here",
snippet="Short snippet",
source="example.com",
published_date="2024-01-15",
)
assert result.title == "Test Title"
assert result.url == "https://example.com"
assert result.content == "Full content here"
assert result.source == "example.com"
def test_web_search_result_defaults(self):
"""Test WebSearchResult default values."""
result = WebSearchResult(
title="Title",
url="https://example.com",
)
assert result.content == ""
assert result.snippet == ""
assert result.source == ""
assert result.published_date is None
def test_web_search_response_model(self):
"""Test WebSearchResponse model."""
response = WebSearchResponse(
query="test query",
search_type="web",
results=[
WebSearchResult(title="R1", url="https://example.com/1"),
WebSearchResult(title="R2", url="https://example.com/2"),
],
total_results=2,
search_time_ms=100,
sources_summary="**Sources:** example.com",
)
assert response.query == "test query"
assert len(response.results) == 2
assert response.total_results == 2
def test_content_extraction_result_model(self):
"""Test ContentExtractionResult model."""
result = ContentExtractionResult(
url="https://example.com",
title="Title",
content="Content",
author="Author",
date="2024-01-01",
language="en",
success=True,
)
assert result.url == "https://example.com"
assert result.success is True
assert result.author == "Author"
def test_content_extraction_failure(self):
"""Test ContentExtractionResult for failed extraction."""
result = ContentExtractionResult(
url="https://example.com",
success=False,
error="404 Not Found",
)
assert result.success is False
assert result.error == "404 Not Found"
assert result.content == ""
def test_batch_extraction_response_model(self):
"""Test BatchExtractionResponse model."""
response = BatchExtractionResponse(
results=[
ContentExtractionResult(url="https://1.com", success=True),
ContentExtractionResult(url="https://2.com", success=False),
],
total_urls=2,
successful=1,
failed=1,
extraction_time_ms=500,
)
assert response.total_urls == 2
assert response.successful == 1
assert response.failed == 1