diff --git a/tests/agents/librarian/__init__.py b/tests/agents/librarian/__init__.py new file mode 100644 index 0000000..3ce8490 --- /dev/null +++ b/tests/agents/librarian/__init__.py @@ -0,0 +1 @@ +"""Tests for The Librarian agent.""" diff --git a/tests/agents/librarian/test_capability.py b/tests/agents/librarian/test_capability.py new file mode 100644 index 0000000..8dca733 --- /dev/null +++ b/tests/agents/librarian/test_capability.py @@ -0,0 +1,126 @@ +""" +Tests for Librarian capability registration. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from src.agents.librarian.capability import ( + LIBRARIAN_CAPABILITY, + get_librarian_capability, + register_librarian, + unregister_librarian, +) +from src.core.household_registry import HouseholdCapability + + +@pytest.mark.unit +class TestLibrarianCapability: + """Tests for the Librarian capability definition.""" + + def test_capability_is_household_capability(self): + """Test capability is correct type.""" + assert isinstance(LIBRARIAN_CAPABILITY, HouseholdCapability) + + def test_capability_name(self): + """Test capability has correct name.""" + assert LIBRARIAN_CAPABILITY.name == "librarian" + + def test_capability_role(self): + """Test capability has correct role.""" + assert LIBRARIAN_CAPABILITY.role == "The Librarian" + + def test_capability_category(self): + """Test capability is in research category.""" + assert LIBRARIAN_CAPABILITY.category == "research" + + def test_capability_domains(self): + """Test capability covers expected domains.""" + domains = LIBRARIAN_CAPABILITY.domains + + assert "research" in domains + assert "knowledge" in domains + assert "wiki" in domains + assert "search" in domains + + def test_capability_requires_network(self): + """Test capability requires network access.""" + assert LIBRARIAN_CAPABILITY.requires_network is True + + def test_get_librarian_capability(self): + """Test getter returns same capability.""" + cap = get_librarian_capability() + + assert cap is LIBRARIAN_CAPABILITY + + +@pytest.mark.unit +class TestLibrarianRegistration: + """Tests for Librarian registration functions.""" + + def test_register_librarian(self): + """Test registering librarian with registry.""" + mock_registry = MagicMock() + mock_registry.__contains__ = MagicMock(return_value=False) + + with patch( + "src.agents.librarian.capability.get_household_registry", + return_value=mock_registry, + ): + with patch( + "src.agents.librarian.capability.get_librarian_agent" + ) as mock_get_agent: + mock_agent = MagicMock() + mock_get_agent.return_value = mock_agent + + register_librarian() + + mock_registry.register.assert_called_once() + call_kwargs = mock_registry.register.call_args[1] + + assert call_kwargs["name"] == "librarian" + assert call_kwargs["capability"] is LIBRARIAN_CAPABILITY + assert call_kwargs["agent"] is mock_agent + + def test_register_librarian_already_registered(self): + """Test registering when already registered does nothing.""" + mock_registry = MagicMock() + mock_registry.__contains__ = MagicMock(return_value=True) + + with patch( + "src.agents.librarian.capability.get_household_registry", + return_value=mock_registry, + ): + register_librarian() + + # Should not call register since already registered + mock_registry.register.assert_not_called() + + def test_unregister_librarian(self): + """Test unregistering librarian from registry.""" + mock_registry = MagicMock() + + with patch( + "src.agents.librarian.capability.get_household_registry", + return_value=mock_registry, + ): + unregister_librarian() + + mock_registry.unregister.assert_called_once_with("librarian") + + +@pytest.mark.unit +class TestCapabilityDescription: + """Tests for capability description.""" + + def test_description_mentions_library_desk(self): + """Test description mentions library-desk API.""" + assert "library-desk" in LIBRARIAN_CAPABILITY.description.lower() + + def test_description_mentions_search(self): + """Test description mentions search capability.""" + assert "search" in LIBRARIAN_CAPABILITY.description.lower() + + def test_description_mentions_wiki(self): + """Test description mentions wiki access.""" + assert "wiki" in LIBRARIAN_CAPABILITY.description.lower() diff --git a/tests/agents/librarian/test_client.py b/tests/agents/librarian/test_client.py new file mode 100644 index 0000000..b8086a7 --- /dev/null +++ b/tests/agents/librarian/test_client.py @@ -0,0 +1,598 @@ +""" +Tests for the Library-Desk HTTP client. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import httpx + +from src.agents.librarian.client import ( + LibraryDeskClient, + HybridRAGResponse, + HybridSearchResult, + WikiPage, + WikiSearchResult, + VectorSearchResult, + GraphNode, + Dossier, + SmartCreateResponse, + ResearchSummary, + EntityLinking, +) + + +@pytest.fixture +def mock_httpx_client(): + """Create a mock httpx client.""" + return AsyncMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def client_with_mock(mock_httpx_client): + """Create a LibraryDeskClient with mocked httpx client.""" + client = LibraryDeskClient( + base_url="http://test:8089", + api_key="test-key", + ) + client._client = mock_httpx_client + return client + + +@pytest.mark.unit +class TestLibraryDeskClientInit: + """Tests for client initialization.""" + + def test_default_initialization(self): + """Test client initializes with defaults from config.""" + client = LibraryDeskClient() + + assert client.base_url is not None + assert client.timeout == 60 + assert client._client is None + + def test_custom_initialization(self): + """Test client with custom parameters.""" + client = LibraryDeskClient( + base_url="http://custom:9000", + api_key="my-api-key", + timeout=120, + ) + + assert client.base_url == "http://custom:9000" + assert client.api_key == "my-api-key" + assert client.timeout == 120 + + def test_ensure_client_not_initialized(self): + """Test _ensure_client raises when not in context.""" + client = LibraryDeskClient() + + with pytest.raises(RuntimeError) as exc_info: + client._ensure_client() + + assert "not initialized" in str(exc_info.value) + + +@pytest.mark.unit +class TestContextManager: + """Tests for async context manager.""" + + @pytest.mark.asyncio + async def test_context_manager_creates_client(self): + """Test context manager creates httpx client.""" + async with LibraryDeskClient( + base_url="http://test:8089", + api_key="test-key", + ) as client: + assert client._client is not None + + @pytest.mark.asyncio + async def test_context_manager_closes_client(self): + """Test context manager closes client on exit.""" + client = LibraryDeskClient(base_url="http://test:8089") + + async with client: + assert client._client is not None + + # After exit, client should be None + assert client._client is None + + +@pytest.mark.unit +class TestHybridSearch: + """Tests for hybrid search.""" + + @pytest.mark.asyncio + async def test_hybrid_search_success(self, client_with_mock, mock_httpx_client): + """Test successful hybrid search.""" + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "results": [ + { + "source": "vector", + "title": "Docker Guide", + "content": "Docker networking basics...", + "score": 0.95, + "page_id": 123, + } + ], + "keywords": ["docker", "networking"], + "synonyms": ["container"], + "formatted_context": "Context here", + "timing": {"total": 1.5}, + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.hybrid_search( + query="Docker networking", + user="testuser", + ) + + assert isinstance(result, HybridRAGResponse) + assert len(result.results) == 1 + assert result.results[0].title == "Docker Guide" + assert result.results[0].source == "vector" + assert "docker" in result.keywords + + @pytest.mark.asyncio + async def test_hybrid_search_empty_results( + self, client_with_mock, mock_httpx_client + ): + """Test hybrid search with no results.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "results": [], + "keywords": [], + "formatted_context": "", + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.hybrid_search("nonexistent query") + + assert len(result.results) == 0 + + +@pytest.mark.unit +class TestWikiOperations: + """Tests for wiki operations.""" + + @pytest.mark.asyncio + async def test_search_wiki(self, client_with_mock, mock_httpx_client): + """Test wiki search.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "results": [ + { + "id": 1, + "path": "/docs/docker", + "title": "Docker Documentation", + "description": "Docker docs", + } + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + results = await client_with_mock.search_wiki("docker") + + assert len(results) == 1 + assert isinstance(results[0], WikiSearchResult) + assert results[0].title == "Docker Documentation" + + @pytest.mark.asyncio + async def test_get_wiki_page(self, client_with_mock, mock_httpx_client): + """Test getting a wiki page.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": 123, + "path": "/docs/docker", + "title": "Docker Guide", + "content": "# Docker\n\nFull content here...", + "tags": ["docker", "devops"], + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + page = await client_with_mock.get_wiki_page(123) + + assert isinstance(page, WikiPage) + assert page.id == 123 + assert page.title == "Docker Guide" + assert "docker" in page.tags + + @pytest.mark.asyncio + async def test_list_wiki_pages(self, client_with_mock, mock_httpx_client): + """Test listing wiki pages.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "pages": [ + {"id": 1, "path": "/page1", "title": "Page 1"}, + {"id": 2, "path": "/page2", "title": "Page 2"}, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + pages = await client_with_mock.list_wiki_pages() + + assert len(pages) == 2 + assert pages[0].title == "Page 1" + + @pytest.mark.asyncio + async def test_list_dossiers(self, client_with_mock, mock_httpx_client): + """Test listing dossiers.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "dossiers": [ + {"name": "docker", "page_count": 10}, + {"name": "kubernetes", "page_count": 5}, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + dossiers = await client_with_mock.list_dossiers() + + assert len(dossiers) == 2 + assert isinstance(dossiers[0], Dossier) + assert dossiers[0].name == "docker" + assert dossiers[0].page_count == 10 + + +@pytest.mark.unit +class TestSemanticSearch: + """Tests for semantic/vector search.""" + + @pytest.mark.asyncio + async def test_semantic_search(self, client_with_mock, mock_httpx_client): + """Test semantic search.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "results": [ + { + "page_id": 1, + "page_path": "/docs/networking", + "page_title": "Networking Guide", + "chunk_text": "Container networking...", + "score": 0.92, + "chunk_index": 0, + } + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + results = await client_with_mock.semantic_search("container networking") + + assert len(results) == 1 + assert isinstance(results[0], VectorSearchResult) + assert results[0].score == 0.92 + + +@pytest.mark.unit +class TestGraphOperations: + """Tests for knowledge graph operations.""" + + @pytest.mark.asyncio + async def test_query_graph(self, client_with_mock, mock_httpx_client): + """Test executing a Cypher query.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "records": [ + {"name": "Docker", "type": "Technology"}, + {"name": "Kubernetes", "type": "Technology"}, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + records = await client_with_mock.query_graph( + "MATCH (n:Technology) RETURN n.name as name, n.type as type" + ) + + assert len(records) == 2 + assert records[0]["name"] == "Docker" + + @pytest.mark.asyncio + async def test_list_graph_nodes(self, client_with_mock, mock_httpx_client): + """Test listing graph nodes.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "nodes": [ + { + "id": "node1", + "labels": ["Technology"], + "properties": {"name": "Docker"}, + } + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + nodes = await client_with_mock.list_graph_nodes() + + assert len(nodes) == 1 + assert isinstance(nodes[0], GraphNode) + assert nodes[0].id == "node1" + + +@pytest.mark.unit +class TestHealthCheck: + """Tests for health check.""" + + @pytest.mark.asyncio + async def test_health_check_healthy(self, client_with_mock, mock_httpx_client): + """Test health check returns true when healthy.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_httpx_client.get.return_value = mock_response + + result = await client_with_mock.health_check() + + assert result is True + + @pytest.mark.asyncio + async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client): + """Test health check returns false on error.""" + mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused") + + result = await client_with_mock.health_check() + + assert result is False + + +@pytest.mark.unit +class TestResponseModels: + """Tests for response model validation.""" + + def test_wiki_page_model(self): + """Test WikiPage model.""" + page = WikiPage( + id=1, + path="/test", + title="Test Page", + content="Content here", + tags=["tag1"], + ) + + assert page.id == 1 + assert page.title == "Test Page" + + def test_wiki_page_optional_fields(self): + """Test WikiPage with minimal fields.""" + page = WikiPage(id=1, path="/test", title="Test") + + assert page.content is None + assert page.tags == [] + + def test_hybrid_search_result_model(self): + """Test HybridSearchResult model.""" + result = HybridSearchResult( + source="vector", + title="Title", + content="Content", + score=0.9, + ) + + assert result.source == "vector" + assert result.url is None + assert result.metadata == {} + + def test_vector_search_result_model(self): + """Test VectorSearchResult model.""" + result = VectorSearchResult( + page_id=1, + page_path="/doc", + page_title="Doc", + chunk_text="Text chunk", + score=0.85, + chunk_index=0, + ) + + assert result.score == 0.85 + assert result.chunk_index == 0 + + +@pytest.mark.unit +class TestUpdateWikiPage: + """Tests for update_wiki_page method.""" + + @pytest.mark.asyncio + async def test_update_wiki_page_content(self, client_with_mock, mock_httpx_client): + """Test updating wiki page content.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": 42, + "path": "/docs/test", + "title": "Test Page", + "content": "# Updated\n\nNew content", + "tags": ["test"], + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.put.return_value = mock_response + + page = await client_with_mock.update_wiki_page( + page_id=42, + content="# Updated\n\nNew content", + ) + + assert isinstance(page, WikiPage) + assert page.id == 42 + assert "Updated" in page.content + mock_httpx_client.put.assert_called_once() + + @pytest.mark.asyncio + async def test_update_wiki_page_tags_only(self, client_with_mock, mock_httpx_client): + """Test updating only tags (partial update).""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": 42, + "path": "/docs/test", + "title": "Test Page", + "tags": ["projects", "devops"], + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.put.return_value = mock_response + + page = await client_with_mock.update_wiki_page( + page_id=42, + tags=["projects", "devops"], + ) + + assert page.tags == ["projects", "devops"] + + @pytest.mark.asyncio + async def test_update_wiki_page_multiple_fields( + self, client_with_mock, mock_httpx_client + ): + """Test updating multiple fields at once.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": 42, + "path": "/docs/test", + "title": "New Title", + "description": "New description", + "tags": ["updated"], + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.put.return_value = mock_response + + page = await client_with_mock.update_wiki_page( + page_id=42, + title="New Title", + description="New description", + tags=["updated"], + ) + + assert page.title == "New Title" + assert page.description == "New description" + + +@pytest.mark.unit +class TestSmartCreateWikiPage: + """Tests for smart_create_wiki_page method.""" + + @pytest.mark.asyncio + async def test_smart_create_basic(self, client_with_mock, mock_httpx_client): + """Test basic smart create.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "page": { + "id": 123, + "path": "/users/test/technology/docker-compose", + "title": "Docker Compose", + "content": "# Docker Compose\n\nContent...", + "tags": ["technology", "devops"], + }, + "research_summary": { + "wiki_results": 3, + "web_results": 8, + "graph_entities": 5, + "keywords_extracted": 12, + "timing_ms": 4500, + }, + "sources_used": 11, + "search_id": "uuid-123", + "entity_linking": { + "forward_links": 5, + "backward_links": 3, + "pages_updated": 2, + }, + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.smart_create_wiki_page( + topic="Docker Compose", + tags=["technology", "devops"], + ) + + assert isinstance(result, SmartCreateResponse) + assert result.page.id == 123 + assert result.page.title == "Docker Compose" + assert result.sources_used == 11 + assert result.research_summary.wiki_results == 3 + assert result.research_summary.web_results == 8 + assert result.entity_linking.forward_links == 5 + + @pytest.mark.asyncio + async def test_smart_create_with_options(self, client_with_mock, mock_httpx_client): + """Test smart create with custom options.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "page": { + "id": 456, + "path": "/custom/path", + "title": "Custom Topic", + "tags": ["custom"], + }, + "research_summary": { + "wiki_results": 5, + "web_results": 0, # Web disabled + "timing_ms": 2000, + }, + "sources_used": 5, + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.smart_create_wiki_page( + topic="Custom Topic", + tags=["custom"], + path="/custom/path", + include_web_research=False, + ) + + assert result.page.path == "/custom/path" + assert result.research_summary.web_results == 0 + + +@pytest.mark.unit +class TestNewResponseModels: + """Tests for new response models.""" + + def test_research_summary_model(self): + """Test ResearchSummary model.""" + summary = ResearchSummary( + wiki_results=3, + web_results=5, + graph_entities=2, + keywords_extracted=10, + timing_ms=3000, + ) + + assert summary.wiki_results == 3 + assert summary.timing_ms == 3000 + + def test_research_summary_defaults(self): + """Test ResearchSummary default values.""" + summary = ResearchSummary() + + assert summary.wiki_results == 0 + assert summary.timing_ms == 0 + + def test_entity_linking_model(self): + """Test EntityLinking model.""" + linking = EntityLinking( + forward_links=5, + backward_links=3, + pages_updated=2, + ) + + assert linking.forward_links == 5 + assert linking.pages_updated == 2 + + def test_smart_create_response_model(self): + """Test SmartCreateResponse model.""" + page = WikiPage(id=1, path="/test", title="Test") + response = SmartCreateResponse( + page=page, + sources_used=10, + search_id="uuid-456", + ) + + assert response.page.id == 1 + assert response.sources_used == 10 + assert response.search_id == "uuid-456" diff --git a/tests/agents/test_coordination.py b/tests/agents/test_coordination.py new file mode 100644 index 0000000..e057ac0 --- /dev/null +++ b/tests/agents/test_coordination.py @@ -0,0 +1,339 @@ +""" +Tests for multi-agent coordination engine. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.agents.coordination import ( + CoordinationEngine, + get_coordination_engine, + delegate_to_librarian, +) +from src.agents.protocol import ( + AgentResponse, + AgentUnavailableError, + DelegationIntent, + DelegationReason, +) + + +@pytest.fixture +def coordination_engine(): + """Create a fresh coordination engine for testing.""" + return CoordinationEngine() + + +@pytest.fixture +def mock_registry(): + """Mock the household registry.""" + with patch("src.agents.coordination.get_household_registry") as mock: + registry = MagicMock() + mock.return_value = registry + yield registry + + +@pytest.fixture +def librarian_intent(): + """Create a standard librarian delegation intent.""" + return DelegationIntent( + target_agent="librarian", + task="Find information about Docker networking", + reason=DelegationReason.DOMAIN_EXPERTISE, + expected_outcome="Documentation and examples", + ) + + +@pytest.mark.unit +class TestCoordinationEngine: + """Tests for CoordinationEngine class.""" + + def test_initialization(self, coordination_engine): + """Test engine initializes correctly.""" + assert coordination_engine is not None + assert coordination_engine.registry is not None + + def test_get_available_agents_empty(self, mock_registry): + """Test getting available agents when none have agents.""" + mock_registry.list_members.return_value = ["tatlock_core"] + mock_member = MagicMock() + mock_member.agent = None # No agent + mock_registry.get_member.return_value = mock_member + + engine = CoordinationEngine() + available = engine.get_available_agents() + + assert available == [] + + def test_get_available_agents_with_librarian(self, mock_registry): + """Test getting available agents with librarian registered.""" + mock_registry.list_members.return_value = ["tatlock_core", "librarian"] + + # tatlock_core has no agent + core_member = MagicMock() + core_member.agent = None + + # librarian has an agent + librarian_member = MagicMock() + librarian_member.agent = MagicMock() + + def get_member_side_effect(name): + if name == "tatlock_core": + return core_member + elif name == "librarian": + return librarian_member + return None + + mock_registry.get_member.side_effect = get_member_side_effect + + engine = CoordinationEngine() + available = engine.get_available_agents() + + assert "librarian" in available + assert "tatlock_core" not in available + + def test_can_delegate_to_unknown_agent(self, mock_registry): + """Test checking delegation to unknown agent.""" + mock_registry.get_member.return_value = None + + engine = CoordinationEngine() + + assert engine.can_delegate_to("unknown_agent") is False + + def test_can_delegate_to_librarian(self, mock_registry): + """Test checking delegation to librarian.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() # Has an agent + mock_registry.get_member.return_value = mock_member + + engine = CoordinationEngine() + + assert engine.can_delegate_to("librarian") is True + + +@pytest.mark.unit +class TestDelegationExecution: + """Tests for delegation execution.""" + + @pytest.mark.asyncio + async def test_execute_delegation_unavailable_agent( + self, mock_registry, librarian_intent + ): + """Test delegation fails for unavailable agent.""" + mock_registry.get_member.return_value = None + + engine = CoordinationEngine() + + with pytest.raises(AgentUnavailableError) as exc_info: + await engine.execute_delegation(librarian_intent) + + assert "librarian" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_execute_delegation_success( + self, mock_registry, librarian_intent + ): + """Test successful delegation execution.""" + # Setup mock member with agent + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + # Mock the executor + with patch( + "src.agents.coordination.AGENT_EXECUTORS", + {"librarian": AsyncMock(return_value="Research results here")}, + ): + engine = CoordinationEngine() + response = await engine.execute_delegation(librarian_intent) + + assert response.success is True + assert response.result == "Research results here" + # Duration might be 0 for very fast mock execution + assert response.duration_ms >= 0 + + @pytest.mark.asyncio + async def test_execute_delegation_error( + self, mock_registry, librarian_intent + ): + """Test delegation handles executor errors.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + # Mock executor that raises + async def failing_executor(**kwargs): + raise ValueError("API connection failed") + + with patch( + "src.agents.coordination.AGENT_EXECUTORS", + {"librarian": failing_executor}, + ): + engine = CoordinationEngine() + response = await engine.execute_delegation(librarian_intent) + + assert response.success is False + assert "API connection failed" in response.error_message + + +@pytest.mark.unit +class TestCoordinate: + """Tests for multi-agent coordination.""" + + @pytest.mark.asyncio + async def test_coordinate_single_intent(self, mock_registry, librarian_intent): + """Test coordinating a single delegation.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + with patch( + "src.agents.coordination.AGENT_EXECUTORS", + {"librarian": AsyncMock(return_value="Found docs")}, + ): + engine = CoordinationEngine() + result = await engine.coordinate([librarian_intent]) + + assert result.final_response == "Found docs" + assert "librarian" in result.agents_consulted + # Duration might be 0 for very fast mock execution + assert result.total_duration_ms >= 0 + + @pytest.mark.asyncio + async def test_coordinate_empty_intents(self, mock_registry): + """Test coordinating with no intents.""" + engine = CoordinationEngine() + result = await engine.coordinate([]) + + assert result.final_response == "" + assert result.agents_consulted == [] + + @pytest.mark.asyncio + async def test_coordinate_multiple_intents(self, mock_registry): + """Test coordinating multiple delegations.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + intents = [ + DelegationIntent( + target_agent="librarian", + task="Task 1", + reason=DelegationReason.DOMAIN_EXPERTISE, + expected_outcome="Result 1", + priority=1, + ), + DelegationIntent( + target_agent="librarian", + task="Task 2", + reason=DelegationReason.DOMAIN_EXPERTISE, + expected_outcome="Result 2", + priority=2, + ), + ] + + call_count = 0 + + async def mock_executor(**kwargs): + nonlocal call_count + call_count += 1 + return f"Result {call_count}" + + with patch( + "src.agents.coordination.AGENT_EXECUTORS", + {"librarian": mock_executor}, + ): + engine = CoordinationEngine() + result = await engine.coordinate(intents) + + # Both intents were executed (check agents_consulted count) + assert len(result.agents_consulted) == 2 + # Current implementation replaces same-agent responses in dict + # So final_response has the last result (or combined if different agents) + assert len(result.final_response) > 0 + + +@pytest.mark.unit +class TestDelegateToLibrarian: + """Tests for convenience delegation function.""" + + @pytest.mark.asyncio + async def test_delegate_to_librarian(self, mock_registry): + """Test the delegate_to_librarian helper.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + with patch( + "src.agents.coordination.AGENT_EXECUTORS", + {"librarian": AsyncMock(return_value="Wiki search results")}, + ): + # Reset global engine + with patch( + "src.agents.coordination._coordination_engine", + None, + ): + response = await delegate_to_librarian( + task="Search for Docker docs", + context="Setting up homelab", + ) + + assert response.success is True + assert response.result == "Wiki search results" + + +@pytest.mark.unit +class TestGetCoordinationEngine: + """Tests for engine singleton.""" + + def test_get_coordination_engine_singleton(self): + """Test engine is singleton.""" + with patch("src.agents.coordination._coordination_engine", None): + engine1 = get_coordination_engine() + engine2 = get_coordination_engine() + + # Should be same instance + assert engine1 is engine2 + + +@pytest.mark.unit +class TestDelegationStreaming: + """Tests for streaming delegation.""" + + @pytest.mark.asyncio + async def test_execute_delegation_stream_unavailable( + self, mock_registry, librarian_intent + ): + """Test streaming fails for unavailable agent.""" + engine = CoordinationEngine() + + # Change target to an agent that doesn't have a stream executor + librarian_intent.target_agent = "nonexistent_agent" + + with pytest.raises(AgentUnavailableError): + async for _ in engine.execute_delegation_stream(librarian_intent): + pass + + @pytest.mark.asyncio + async def test_execute_delegation_stream_success( + self, mock_registry, librarian_intent + ): + """Test successful streaming delegation.""" + mock_member = MagicMock() + mock_member.agent = MagicMock() + mock_registry.get_member.return_value = mock_member + + async def mock_stream(**kwargs): + yield "Hello " + yield "world" + + with patch( + "src.agents.coordination.AGENT_STREAM_EXECUTORS", + {"librarian": mock_stream}, + ): + engine = CoordinationEngine() + chunks = [] + async for chunk in engine.execute_delegation_stream(librarian_intent): + chunks.append(chunk) + + assert chunks == ["Hello ", "world"] diff --git a/tests/agents/test_protocol.py b/tests/agents/test_protocol.py new file mode 100644 index 0000000..e3df1a0 --- /dev/null +++ b/tests/agents/test_protocol.py @@ -0,0 +1,256 @@ +""" +Tests for agent communication protocol. +""" + +import pytest + +from src.agents.protocol import ( + AgentError, + AgentRequest, + AgentResponse, + AgentTimeoutError, + AgentUnavailableError, + CoordinationResult, + DelegationIntent, + DelegationReason, + ToolCallRecord, +) + + +@pytest.mark.unit +class TestAgentRequest: + """Tests for AgentRequest model.""" + + def test_basic_request(self): + """Test creating a basic agent request.""" + request = AgentRequest(task="Find information about Docker") + + assert request.task == "Find information about Docker" + assert request.context == "" + assert request.timeout_seconds == 60 + + def test_request_with_context(self): + """Test request with additional context.""" + request = AgentRequest( + task="Find Docker networking docs", + context="User is setting up a homelab", + delegation_reason=DelegationReason.DOMAIN_EXPERTISE, + ) + + assert request.task == "Find Docker networking docs" + assert request.context == "User is setting up a homelab" + assert request.delegation_reason == DelegationReason.DOMAIN_EXPERTISE + + def test_request_serialization(self): + """Test request can be serialized to dict.""" + request = AgentRequest( + task="Research task", + context="Some context", + ) + + data = request.model_dump() + + assert data["task"] == "Research task" + assert data["context"] == "Some context" + + +@pytest.mark.unit +class TestAgentResponse: + """Tests for AgentResponse model.""" + + def test_successful_response(self): + """Test creating a successful response.""" + response = AgentResponse( + success=True, + result="Here are the findings...", + reasoning="Searched wiki and found relevant docs", + duration_ms=1500, + ) + + assert response.success is True + assert response.result == "Here are the findings..." + assert response.reasoning == "Searched wiki and found relevant docs" + assert response.duration_ms == 1500 + assert response.error_message is None + + def test_failed_response(self): + """Test creating a failed response.""" + response = AgentResponse( + success=False, + result="", + error_message="Connection timeout", + duration_ms=30000, + ) + + assert response.success is False + assert response.result == "" + assert response.error_message == "Connection timeout" + + def test_response_with_tool_calls(self): + """Test response tracking tool calls.""" + tool_call = ToolCallRecord( + tool_name="hybrid_search", + arguments={"query": "Docker networking"}, + result="Found 5 results", + duration_ms=500, + ) + + response = AgentResponse( + success=True, + result="Based on search...", + tool_calls=[tool_call], + ) + + assert len(response.tool_calls) == 1 + assert response.tool_calls[0].tool_name == "hybrid_search" + + +@pytest.mark.unit +class TestDelegationIntent: + """Tests for DelegationIntent model.""" + + def test_basic_intent(self): + """Test creating a basic delegation intent.""" + intent = DelegationIntent( + target_agent="librarian", + task="Research Docker networking", + reason=DelegationReason.DOMAIN_EXPERTISE, + expected_outcome="Documentation and examples", + ) + + assert intent.target_agent == "librarian" + assert intent.task == "Research Docker networking" + assert intent.reason == DelegationReason.DOMAIN_EXPERTISE + assert intent.priority == 1 # Default + + def test_intent_with_priority(self): + """Test intent with custom priority.""" + intent = DelegationIntent( + target_agent="librarian", + task="Urgent research", + reason=DelegationReason.RESOURCE_EFFICIENCY, + expected_outcome="Quick answer", + priority=1, + ) + + assert intent.priority == 1 + + +@pytest.mark.unit +class TestDelegationReason: + """Tests for DelegationReason enum.""" + + def test_all_reasons_have_values(self): + """Test all delegation reasons are defined.""" + reasons = list(DelegationReason) + + assert DelegationReason.DOMAIN_EXPERTISE in reasons + assert DelegationReason.TOOL_ACCESS in reasons + assert DelegationReason.RESOURCE_EFFICIENCY in reasons + assert DelegationReason.USER_PREFERENCE in reasons + + +@pytest.mark.unit +class TestCoordinationResult: + """Tests for CoordinationResult model.""" + + def test_single_agent_result(self): + """Test coordination with single agent.""" + agent_response = AgentResponse( + success=True, + result="Research findings", + duration_ms=1000, + ) + + intent = DelegationIntent( + target_agent="librarian", + task="Research task", + reason=DelegationReason.DOMAIN_EXPERTISE, + expected_outcome="Findings", + ) + + result = CoordinationResult( + final_response="Research findings", + agent_responses={"librarian": agent_response}, + delegation_intents=[intent], + total_duration_ms=1200, + agents_consulted=["librarian"], + ) + + assert result.final_response == "Research findings" + assert len(result.agent_responses) == 1 + assert result.agents_consulted == ["librarian"] + + def test_empty_result(self): + """Test coordination with no delegations.""" + result = CoordinationResult( + final_response="", + agent_responses={}, + delegation_intents=[], + total_duration_ms=0, + agents_consulted=[], + ) + + assert result.final_response == "" + assert len(result.agents_consulted) == 0 + + +@pytest.mark.unit +class TestAgentErrors: + """Tests for agent error types.""" + + def test_agent_error(self): + """Test base AgentError.""" + error = AgentError("Something went wrong") + + assert "Something went wrong" in str(error) + assert error.agent_name == "unknown" + + def test_agent_timeout_error(self): + """Test AgentTimeoutError.""" + error = AgentTimeoutError( + "Timed out after 60s", + agent_name="librarian", + ) + + assert "Timed out" in str(error) + assert error.agent_name == "librarian" + + def test_agent_unavailable_error(self): + """Test AgentUnavailableError.""" + error = AgentUnavailableError( + "Agent not registered", + agent_name="unknown_agent", + ) + + assert "not registered" in str(error) + assert error.agent_name == "unknown_agent" + + +@pytest.mark.unit +class TestToolCallRecord: + """Tests for ToolCallRecord model.""" + + def test_tool_call_record(self): + """Test creating a tool call record.""" + record = ToolCallRecord( + tool_name="semantic_search", + arguments={"query": "networking concepts", "limit": 10}, + result="Found 10 relevant documents", + duration_ms=250, + ) + + assert record.tool_name == "semantic_search" + assert record.arguments["query"] == "networking concepts" + assert record.duration_ms == 250 + + def test_tool_call_with_empty_result(self): + """Test tool call with empty result.""" + record = ToolCallRecord( + tool_name="query_graph", + arguments={"cypher": "MATCH (n) RETURN n"}, + result="", + duration_ms=100, + ) + + assert record.result == ""