""" Tests for orchestration module. Tests the multi-expert coordination infrastructure including delegation parsing, think updates, result handling, and multi-expert sequential/parallel execution. """ from unittest.mock import AsyncMock, patch import pytest from src.agents.delegation import DelegationResult, DelegationTask from src.agents.orchestration import ( ExecutionMode, MultiExpertResult, OrchestrationContext, _get_display_name, execute_delegation, execute_parallel, execute_sequential, extract_delegation_context, orchestrate_multi_expert, orchestrate_with_think_updates, parse_delegation_from_steward_note, ) @pytest.mark.unit class TestParseDelegation: """Tests for parsing delegation from Steward's note.""" def test_parse_librarian_create(self): """Test parsing librarian create delegation.""" note = """DELEGATE: librarian to create a wiki page about CI/CD pipelines REASON: User wants to document CI/CD concepts COMPLEXITY: moderate CONTEXT: none""" task = parse_delegation_from_steward_note(note) assert task is not None assert task.expert_name == "librarian" assert "create a wiki page about CI/CD pipelines" in task.task def test_parse_librarian_search(self): """Test parsing librarian search delegation.""" note = """DELEGATE: librarian to search for information about Docker networking REASON: User needs Docker documentation COMPLEXITY: simple""" task = parse_delegation_from_steward_note(note) assert task is not None assert task.expert_name == "librarian" assert "search for information about Docker networking" in task.task def test_parse_no_delegation(self): """Test parsing when no delegation needed.""" note = """DELEGATE: none (conversational response only) REASON: Simple greeting requires no tools COMPLEXITY: simple""" task = parse_delegation_from_steward_note(note) assert task is None def test_parse_tatlock_core(self): """Test parsing tatlock_core delegation.""" note = """DELEGATE: tatlock_core to calculate the result REASON: Math calculation needed COMPLEXITY: simple""" task = parse_delegation_from_steward_note(note) assert task is not None assert task.expert_name == "tatlock_core" assert "calculate the result" in task.task def test_parse_case_insensitive(self): """Test parsing is case insensitive.""" note = """delegate: LIBRARIAN to search docs reason: Research query""" task = parse_delegation_from_steward_note(note) assert task is not None assert task.expert_name == "librarian" def test_parse_missing_delegate(self): """Test parsing when DELEGATE line is missing.""" note = """REASON: This has no delegation COMPLEXITY: simple""" task = parse_delegation_from_steward_note(note) assert task is None @pytest.mark.unit class TestExtractDelegationContext: """Tests for extracting context from Steward's note.""" def test_extract_all_fields(self): """Test extracting all context fields.""" note = """DELEGATE: librarian to create wiki page REASON: User wants documentation COMPLEXITY: moderate CONTEXT: Related to previous discussion about DevOps""" context = extract_delegation_context(note) assert context["reason"] == "User wants documentation" assert context["complexity"] == "moderate" assert "Related to previous discussion" in context["context"] def test_extract_partial_fields(self): """Test extracting when some fields missing.""" note = """DELEGATE: librarian to search REASON: Research query COMPLEXITY: simple""" context = extract_delegation_context(note) assert context["reason"] == "Research query" assert context["complexity"] == "simple" assert context["context"] == "" def test_extract_empty_note(self): """Test extracting from empty note.""" context = extract_delegation_context("") assert context["reason"] == "" assert context["complexity"] == "" assert context["context"] == "" @pytest.mark.unit class TestExecuteDelegation: """Tests for executing delegation tasks.""" @pytest.mark.asyncio async def test_execute_librarian_delegation(self): """Test executing delegation to librarian.""" task = DelegationTask( expert_name="librarian", task="search for Docker docs", context="User learning Docker", ) mock_result = DelegationResult( expert_name="librarian", task="search for Docker docs", success=True, output="Found Docker documentation...", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ) as mock_delegate: result = await execute_delegation(task) mock_delegate.assert_called_once_with( task="search for Docker docs", context="User learning Docker", ) assert result.success is True assert "Docker" in result.output @pytest.mark.asyncio async def test_execute_unknown_expert(self): """Test executing delegation to unknown expert.""" task = DelegationTask( expert_name="unknown_expert", task="do something", ) result = await execute_delegation(task) assert result.success is False assert "Unknown expert" in result.error @pytest.mark.unit class TestOrchestrateWithThinkUpdates: """Tests for orchestration with think updates.""" @pytest.mark.asyncio async def test_orchestrate_emits_think_before_delegation(self): """Test that think update is emitted before delegation.""" mock_result = DelegationResult( expert_name="librarian", task="search docs", success=True, output="Found results", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_with_think_updates( user_message="Search for Docker info", steward_note="DELEGATE: librarian to search for Docker info", ): updates.append(update) # First update should be about consulting (no wrappers anymore) assert any("Consulting" in u for u in updates) @pytest.mark.asyncio async def test_orchestrate_emits_think_after_delegation(self): """Test that think update is emitted after delegation.""" mock_result = DelegationResult( expert_name="librarian", task="search docs", success=True, output="Found results", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_with_think_updates( user_message="Search for Docker info", steward_note="DELEGATE: librarian to search for Docker info", ): updates.append(update) # Should have message about completion (no wrappers anymore) assert any("completed" in u for u in updates) @pytest.mark.asyncio async def test_orchestrate_yields_expert_output(self): """Test that expert output is yielded.""" mock_result = DelegationResult( expert_name="librarian", task="search docs", success=True, output="Found Docker documentation with networking details", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_with_think_updates( user_message="Search for Docker info", steward_note="DELEGATE: librarian to search for Docker info", ): updates.append(update) # Should include expert output all_output = "".join(updates) assert "Docker documentation" in all_output @pytest.mark.asyncio async def test_orchestrate_handles_delegation_failure(self): """Test that delegation failure emits warning think update.""" mock_result = DelegationResult( expert_name="librarian", task="search docs", success=False, output="", error="Connection timeout", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_with_think_updates( user_message="Search for info", steward_note="DELEGATE: librarian to search", ): updates.append(update) # Should have warning think update all_output = "".join(updates) assert "⚠️" in all_output or "issue" in all_output.lower() @pytest.mark.asyncio async def test_orchestrate_no_delegation_returns_empty(self): """Test that no delegation yields nothing.""" updates = [] async for update in orchestrate_with_think_updates( user_message="Hello", steward_note="DELEGATE: none (conversational)", ): updates.append(update) assert len(updates) == 0 @pytest.mark.asyncio async def test_orchestrate_with_preparsed_task(self): """Test orchestration with pre-parsed delegation task.""" task = DelegationTask( expert_name="librarian", task="create wiki page", ) mock_result = DelegationResult( expert_name="librarian", task="create wiki page", success=True, output="Wiki page created", ) with patch( "src.agents.orchestration.delegate_to_librarian", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_with_think_updates( user_message="Create wiki page", steward_note="", # Empty note since task is pre-parsed delegation_task=task, ): updates.append(update) assert len(updates) > 0 all_output = "".join(updates) assert "Wiki page created" in all_output @pytest.mark.unit class TestOrchestrationContext: """Tests for OrchestrationContext dataclass.""" def test_context_creation(self): """Test creating orchestration context.""" ctx = OrchestrationContext( user_message="Test message", steward_note="Test note", conversation_id="conv_123", ) assert ctx.user_message == "Test message" assert ctx.steward_note == "Test note" assert ctx.conversation_id == "conv_123" def test_context_defaults(self): """Test orchestration context default values.""" ctx = OrchestrationContext( user_message="Test", steward_note="Note", ) assert ctx.conversation_id is None # ============================================================================ # Multi-Expert Coordination Tests # ============================================================================ @pytest.mark.unit class TestMultiExpertResult: """Tests for MultiExpertResult aggregation.""" def test_result_creation(self): """Test creating empty MultiExpertResult.""" result = MultiExpertResult() assert result.results == {} assert result.all_succeeded is True assert result.failed_experts == [] assert result.combined_output == "" def test_add_successful_result(self): """Test adding a successful result.""" result = MultiExpertResult() delegation_result = DelegationResult( expert_name="librarian", task="search docs", success=True, output="Found docs", ) result.add_result(delegation_result) assert "librarian" in result.results assert result.all_succeeded is True assert result.failed_experts == [] def test_add_failed_result(self): """Test adding a failed result.""" result = MultiExpertResult() delegation_result = DelegationResult( expert_name="librarian", task="search docs", success=False, output="", error="Connection error", ) result.add_result(delegation_result) assert "librarian" in result.results assert result.all_succeeded is False assert "librarian" in result.failed_experts def test_aggregate_outputs(self): """Test aggregating outputs from multiple experts.""" result = MultiExpertResult() result.add_result( DelegationResult( expert_name="librarian", task="search docs", success=True, output="Found Docker docs", ) ) result.add_result( DelegationResult( expert_name="memory", task="get preferences", success=True, output="User prefers dark mode", ) ) combined = result.aggregate_outputs() assert "Librarian" in combined assert "Found Docker docs" in combined assert "Memory" in combined assert "dark mode" in combined def test_aggregate_excludes_failed(self): """Test that failed results are excluded from aggregate.""" result = MultiExpertResult() result.add_result( DelegationResult( expert_name="librarian", task="search", success=True, output="Success output", ) ) result.add_result( DelegationResult( expert_name="memory", task="get", success=False, output="", error="Failed", ) ) combined = result.aggregate_outputs() assert "Success output" in combined assert "Failed" not in combined @pytest.mark.unit class TestExecuteSequential: """Tests for sequential multi-expert execution.""" @pytest.mark.asyncio async def test_sequential_all_succeed(self): """Test sequential execution when all tasks succeed.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult( expert_name="librarian", task="task 1", success=True, output="Result 1" ), DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): result = await execute_sequential(tasks) assert result.all_succeeded is True assert len(result.results) == 2 assert result.failed_experts == [] @pytest.mark.asyncio async def test_sequential_with_failure(self): """Test sequential execution when a task fails.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"), DelegationResult( expert_name="memory", task="task 2", success=False, output="", error="Failed" ), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): result = await execute_sequential(tasks) assert result.all_succeeded is False assert len(result.results) == 2 assert "memory" in result.failed_experts @pytest.mark.asyncio async def test_sequential_stop_on_failure(self): """Test sequential execution stops on failure when configured.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), DelegationTask(expert_name="librarian", task="task 3"), ] mock_results = [ DelegationResult( expert_name="librarian", task="task 1", success=False, output="", error="Error" ), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): result = await execute_sequential(tasks, stop_on_failure=True) # Should only have 1 result (stopped after first failure) assert len(result.results) == 1 assert result.all_succeeded is False @pytest.mark.unit class TestExecuteParallel: """Tests for parallel multi-expert execution.""" @pytest.mark.asyncio async def test_parallel_all_succeed(self): """Test parallel execution when all tasks succeed.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult( expert_name="librarian", task="task 1", success=True, output="Result 1" ), DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): result = await execute_parallel(tasks) assert result.all_succeeded is True assert len(result.results) == 2 @pytest.mark.asyncio async def test_parallel_with_failure(self): """Test parallel execution with partial failure.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult(expert_name="librarian", task="task 1", success=True, output="OK"), DelegationResult( expert_name="memory", task="task 2", success=False, output="", error="Timeout" ), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): result = await execute_parallel(tasks) assert result.all_succeeded is False assert len(result.results) == 2 assert "memory" in result.failed_experts @pytest.mark.asyncio async def test_parallel_handles_exception(self): """Test parallel execution handles exceptions gracefully.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] async def mock_execute(task): if task.expert_name == "memory": raise RuntimeError("Connection lost") return DelegationResult( expert_name=task.expert_name, task=task.task, success=True, output="OK", ) with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_execute, ): result = await execute_parallel(tasks) assert result.all_succeeded is False assert "memory" in result.failed_experts assert "Connection lost" in result.results["memory"].error @pytest.mark.unit class TestOrchestrateMultiExpert: """Tests for multi-expert orchestration with think updates.""" @pytest.mark.asyncio async def test_orchestrate_sequential_emits_think_updates(self): """Test sequential orchestration emits think updates for each task.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult( expert_name="librarian", task="task 1", success=True, output="Result 1" ), DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): updates = [] async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.SEQUENTIAL): updates.append(update) all_output = "".join(updates) # Should have think updates for both experts assert "Consulting" in all_output assert "completed" in all_output assert "Librarian" in all_output @pytest.mark.asyncio async def test_orchestrate_parallel_emits_think_updates(self): """Test parallel orchestration emits appropriate think updates.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), DelegationTask(expert_name="memory", task="task 2"), ] mock_results = [ DelegationResult( expert_name="librarian", task="task 1", success=True, output="Result 1" ), DelegationResult(expert_name="memory", task="task 2", success=True, output="Result 2"), ] with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, side_effect=mock_results, ): updates = [] async for update in orchestrate_multi_expert(tasks, mode=ExecutionMode.PARALLEL): updates.append(update) all_output = "".join(updates) # Should mention parallel execution assert "parallel" in all_output @pytest.mark.asyncio async def test_orchestrate_empty_tasks_yields_nothing(self): """Test orchestration with empty tasks yields nothing.""" updates = [] async for update in orchestrate_multi_expert([]): updates.append(update) assert len(updates) == 0 @pytest.mark.asyncio async def test_orchestrate_success_summary(self): """Test orchestration emits success summary when all succeed.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), ] mock_result = DelegationResult( expert_name="librarian", task="task 1", success=True, output="Done", ) with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_multi_expert(tasks): updates.append(update) all_output = "".join(updates) # Should have success message assert "🎉" in all_output or "successfully" in all_output.lower() @pytest.mark.asyncio async def test_orchestrate_failure_summary(self): """Test orchestration emits failure summary when some fail.""" tasks = [ DelegationTask(expert_name="librarian", task="task 1"), ] mock_result = DelegationResult( expert_name="librarian", task="task 1", success=False, output="", error="Failed", ) with patch( "src.agents.orchestration.execute_delegation", new_callable=AsyncMock, return_value=mock_result, ): updates = [] async for update in orchestrate_multi_expert(tasks): updates.append(update) all_output = "".join(updates) # Should mention failure assert "⚠️" in all_output or "failed" in all_output.lower() @pytest.mark.unit class TestGetDisplayName: """Tests for _get_display_name helper.""" def test_librarian_display_name(self): """Test librarian gets 'The Librarian' display name.""" assert _get_display_name("librarian") == "The Librarian" def test_memory_display_name(self): """Test memory gets 'Memory' display name.""" assert _get_display_name("memory") == "Memory" def test_unknown_expert_title_case(self): """Test unknown expert gets title-cased name.""" assert _get_display_name("some_expert") == "Some_Expert" assert _get_display_name("newagent") == "Newagent"