"""Tests for the agent dispatcher (timmy.dispatcher).""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch from timmy.dispatcher import ( AGENT_REGISTRY, AgentType, DispatchResult, DispatchStatus, TaskType, _dispatch_local, _dispatch_via_api, _dispatch_via_gitea, dispatch_task, infer_task_type, select_agent, wait_for_completion, ) # --------------------------------------------------------------------------- # Agent registry # --------------------------------------------------------------------------- class TestAgentRegistry: def test_all_agents_present(self): for member in AgentType: assert member in AGENT_REGISTRY, f"AgentType.{member.name} missing from registry" def test_agent_specs_have_display_names(self): for agent, spec in AGENT_REGISTRY.items(): assert spec.display_name, f"{agent} has empty display_name" def test_gitea_agents_have_labels(self): for agent, spec in AGENT_REGISTRY.items(): if spec.interface == "gitea": assert spec.gitea_label, f"{agent} is gitea interface but has no label" def test_non_gitea_agents_have_no_labels(self): for _agent, spec in AGENT_REGISTRY.items(): if spec.interface not in ("gitea",): # api and local agents may have no label assert spec.gitea_label is None or spec.interface == "gitea" def test_max_concurrent_positive(self): for agent, spec in AGENT_REGISTRY.items(): assert spec.max_concurrent >= 1, f"{agent} has max_concurrent < 1" # --------------------------------------------------------------------------- # select_agent # --------------------------------------------------------------------------- class TestSelectAgent: def test_architecture_routes_to_claude(self): assert select_agent(TaskType.ARCHITECTURE) == AgentType.CLAUDE_CODE def test_refactoring_routes_to_claude(self): assert select_agent(TaskType.REFACTORING) == AgentType.CLAUDE_CODE def test_code_review_routes_to_claude(self): assert select_agent(TaskType.CODE_REVIEW) == AgentType.CLAUDE_CODE def test_routine_coding_routes_to_kimi(self): assert select_agent(TaskType.ROUTINE_CODING) == AgentType.KIMI_CODE def test_fast_iteration_routes_to_kimi(self): assert select_agent(TaskType.FAST_ITERATION) == AgentType.KIMI_CODE def test_research_routes_to_agent_api(self): assert select_agent(TaskType.RESEARCH) == AgentType.AGENT_API def test_triage_routes_to_timmy(self): assert select_agent(TaskType.TRIAGE) == AgentType.TIMMY def test_planning_routes_to_timmy(self): assert select_agent(TaskType.PLANNING) == AgentType.TIMMY # --------------------------------------------------------------------------- # infer_task_type # --------------------------------------------------------------------------- class TestInferTaskType: def test_architecture_keyword(self): assert infer_task_type("Design the LLM router architecture") == TaskType.ARCHITECTURE def test_refactor_keyword(self): assert infer_task_type("Refactor the auth middleware") == TaskType.REFACTORING def test_code_review_keyword(self): assert infer_task_type("Review PR for cascade router") == TaskType.CODE_REVIEW def test_research_keyword(self): assert infer_task_type("Research embedding models") == TaskType.RESEARCH def test_triage_keyword(self): assert infer_task_type("Triage open issues") == TaskType.TRIAGE def test_planning_keyword(self): assert infer_task_type("Plan the v2.0 roadmap") == TaskType.PLANNING def test_fallback_returns_routine_coding(self): assert infer_task_type("Do the thing") == TaskType.ROUTINE_CODING def test_description_contributes_to_inference(self): result = infer_task_type("Implement feature", "We need to refactor the old code") assert result == TaskType.REFACTORING def test_case_insensitive(self): assert infer_task_type("ARCHITECTURE DESIGN") == TaskType.ARCHITECTURE # --------------------------------------------------------------------------- # DispatchResult # --------------------------------------------------------------------------- class TestDispatchResult: def test_success_when_assigned(self): r = DispatchResult( task_type=TaskType.ROUTINE_CODING, agent=AgentType.KIMI_CODE, issue_number=1, status=DispatchStatus.ASSIGNED, ) assert r.success is True def test_success_when_completed(self): r = DispatchResult( task_type=TaskType.ROUTINE_CODING, agent=AgentType.KIMI_CODE, issue_number=1, status=DispatchStatus.COMPLETED, ) assert r.success is True def test_not_success_when_failed(self): r = DispatchResult( task_type=TaskType.ROUTINE_CODING, agent=AgentType.KIMI_CODE, issue_number=1, status=DispatchStatus.FAILED, ) assert r.success is False def test_not_success_when_escalated(self): r = DispatchResult( task_type=TaskType.ROUTINE_CODING, agent=AgentType.KIMI_CODE, issue_number=1, status=DispatchStatus.ESCALATED, ) assert r.success is False # --------------------------------------------------------------------------- # _dispatch_local # --------------------------------------------------------------------------- class TestDispatchLocal: async def test_returns_assigned(self): result = await _dispatch_local( title="Plan the migration", description="We need a plan.", acceptance_criteria=["Plan is documented"], issue_number=42, ) assert result.status == DispatchStatus.ASSIGNED assert result.agent == AgentType.TIMMY assert result.issue_number == 42 async def test_infers_task_type(self): result = await _dispatch_local( title="Plan the sprint", description="", acceptance_criteria=[], ) assert result.task_type == TaskType.PLANNING async def test_no_issue_number(self): result = await _dispatch_local(title="Do something", description="") assert result.issue_number is None # --------------------------------------------------------------------------- # _dispatch_via_api # --------------------------------------------------------------------------- class TestDispatchViaApi: async def test_no_endpoint_returns_failed(self): result = await _dispatch_via_api( agent=AgentType.AGENT_API, title="Analyse logs", description="", acceptance_criteria=[], ) assert result.status == DispatchStatus.FAILED assert "No API endpoint" in (result.error or "") async def test_successful_api_call(self): mock_resp = MagicMock() mock_resp.status_code = 202 mock_resp.content = b'{"ok": true}' mock_resp.json.return_value = {"ok": True} mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) with patch("httpx.AsyncClient", return_value=mock_client): result = await _dispatch_via_api( agent=AgentType.AGENT_API, title="Analyse logs", description="Look at the logs", acceptance_criteria=["Report produced"], endpoint="http://fake-agent/dispatch", ) assert result.status == DispatchStatus.ASSIGNED assert result.agent == AgentType.AGENT_API async def test_api_error_returns_failed(self): mock_resp = MagicMock() mock_resp.status_code = 500 mock_resp.text = "Internal Server Error" mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) with patch("httpx.AsyncClient", return_value=mock_client): result = await _dispatch_via_api( agent=AgentType.AGENT_API, title="Analyse logs", description="", acceptance_criteria=[], endpoint="http://fake-agent/dispatch", ) assert result.status == DispatchStatus.FAILED assert "500" in (result.error or "") # --------------------------------------------------------------------------- # _dispatch_via_gitea # --------------------------------------------------------------------------- _GITEA_SETTINGS = MagicMock( gitea_enabled=True, gitea_token="test-token", gitea_url="http://gitea.test", gitea_repo="owner/repo", ) class TestDispatchViaGitea: def _make_client(self, label_list=None, label_create_status=201, comment_status=201): """Build a mock httpx.AsyncClient for Gitea interactions.""" label_resp = MagicMock() label_resp.status_code = 200 label_resp.json.return_value = label_list or [] create_label_resp = MagicMock() create_label_resp.status_code = label_create_status create_label_resp.json.return_value = {"id": 99} apply_label_resp = MagicMock() apply_label_resp.status_code = 201 comment_resp = MagicMock() comment_resp.status_code = comment_status comment_resp.json.return_value = {"id": 7} client = AsyncMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(return_value=label_resp) client.post = AsyncMock(side_effect=[create_label_resp, apply_label_resp, comment_resp]) return client async def test_successful_gitea_dispatch(self): client = self._make_client() with ( patch("httpx.AsyncClient", return_value=client), patch("timmy.dispatcher.settings", _GITEA_SETTINGS), ): result = await _dispatch_via_gitea( agent=AgentType.CLAUDE_CODE, issue_number=1072, title="Design the router", description="We need a cascade router.", acceptance_criteria=["Failover works"], ) assert result.success assert result.agent == AgentType.CLAUDE_CODE assert result.issue_number == 1072 assert result.status == DispatchStatus.ASSIGNED async def test_no_gitea_token_returns_failed(self): bad_settings = MagicMock( gitea_enabled=True, gitea_token="", gitea_url="http://x", gitea_repo="a/b" ) with patch("timmy.dispatcher.settings", bad_settings): result = await _dispatch_via_gitea( agent=AgentType.CLAUDE_CODE, issue_number=1, title="Some task", description="", acceptance_criteria=[], ) assert result.status == DispatchStatus.FAILED assert "not configured" in (result.error or "").lower() async def test_gitea_disabled_returns_failed(self): bad_settings = MagicMock( gitea_enabled=False, gitea_token="tok", gitea_url="http://x", gitea_repo="a/b" ) with patch("timmy.dispatcher.settings", bad_settings): result = await _dispatch_via_gitea( agent=AgentType.CLAUDE_CODE, issue_number=1, title="Some task", description="", acceptance_criteria=[], ) assert result.status == DispatchStatus.FAILED async def test_existing_label_reused(self): """When the label already exists, it should be reused (no creation call).""" label_resp = MagicMock() label_resp.status_code = 200 label_resp.json.return_value = [{"name": "claude-ready", "id": 55}] apply_resp = MagicMock() apply_resp.status_code = 201 comment_resp = MagicMock() comment_resp.status_code = 201 comment_resp.json.return_value = {"id": 8} client = AsyncMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) client.get = AsyncMock(return_value=label_resp) client.post = AsyncMock(side_effect=[apply_resp, comment_resp]) with ( patch("httpx.AsyncClient", return_value=client), patch("timmy.dispatcher.settings", _GITEA_SETTINGS), ): result = await _dispatch_via_gitea( agent=AgentType.CLAUDE_CODE, issue_number=10, title="Architecture task", description="", acceptance_criteria=[], ) assert result.success # Should only have 2 POST calls: apply label + comment (no label creation) assert client.post.call_count == 2 # --------------------------------------------------------------------------- # dispatch_task (integration-style) # --------------------------------------------------------------------------- class TestDispatchTask: async def test_empty_title_returns_failed(self): result = await dispatch_task(title=" ") assert result.status == DispatchStatus.FAILED assert "`title` is required" in (result.error or "") async def test_local_dispatch_for_timmy_task(self): result = await dispatch_task( title="Triage the open issues", description="We have 40 open issues.", acceptance_criteria=["Issues are labelled"], task_type=TaskType.TRIAGE, ) assert result.agent == AgentType.TIMMY assert result.success async def test_explicit_agent_override(self): """Caller can force a specific agent regardless of task type.""" result = await dispatch_task( title="Triage the open issues", agent=AgentType.TIMMY, ) assert result.agent == AgentType.TIMMY async def test_gitea_dispatch_when_issue_provided(self): client_mock = AsyncMock() client_mock.__aenter__ = AsyncMock(return_value=client_mock) client_mock.__aexit__ = AsyncMock(return_value=False) client_mock.get = AsyncMock( return_value=MagicMock(status_code=200, json=MagicMock(return_value=[])) ) create_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 1})) apply_resp = MagicMock(status_code=201) comment_resp = MagicMock(status_code=201, json=MagicMock(return_value={"id": 5})) client_mock.post = AsyncMock(side_effect=[create_resp, apply_resp, comment_resp]) with ( patch("httpx.AsyncClient", return_value=client_mock), patch("timmy.dispatcher.settings", _GITEA_SETTINGS), ): result = await dispatch_task( title="Design the cascade router", description="Architecture task.", task_type=TaskType.ARCHITECTURE, issue_number=1072, ) assert result.agent == AgentType.CLAUDE_CODE assert result.success async def test_escalation_after_max_retries(self): """If all attempts fail, the result is ESCALATED.""" with ( patch("timmy.dispatcher._dispatch_via_gitea", new_callable=AsyncMock) as mock_dispatch, patch("timmy.dispatcher._log_escalation", new_callable=AsyncMock), ): mock_dispatch.return_value = DispatchResult( task_type=TaskType.ARCHITECTURE, agent=AgentType.CLAUDE_CODE, issue_number=1, status=DispatchStatus.FAILED, error="Gitea offline", ) result = await dispatch_task( title="Design router", task_type=TaskType.ARCHITECTURE, issue_number=1, max_retries=1, ) assert result.status == DispatchStatus.ESCALATED assert mock_dispatch.call_count == 2 # initial + 1 retry async def test_no_retry_on_success(self): with patch("timmy.dispatcher._dispatch_via_gitea", new_callable=AsyncMock) as mock_dispatch: mock_dispatch.return_value = DispatchResult( task_type=TaskType.ARCHITECTURE, agent=AgentType.CLAUDE_CODE, issue_number=1, status=DispatchStatus.ASSIGNED, comment_id=42, label_applied="claude-ready", ) result = await dispatch_task( title="Design router", task_type=TaskType.ARCHITECTURE, issue_number=1, max_retries=2, ) assert result.success assert mock_dispatch.call_count == 1 # no retries needed # --------------------------------------------------------------------------- # wait_for_completion # --------------------------------------------------------------------------- class TestWaitForCompletion: async def test_returns_completed_when_issue_closed(self): closed_resp = MagicMock( status_code=200, json=MagicMock(return_value={"state": "closed"}), ) client_mock = AsyncMock() client_mock.__aenter__ = AsyncMock(return_value=client_mock) client_mock.__aexit__ = AsyncMock(return_value=False) client_mock.get = AsyncMock(return_value=closed_resp) with ( patch("httpx.AsyncClient", return_value=client_mock), patch("timmy.dispatcher.settings", _GITEA_SETTINGS), ): status = await wait_for_completion(issue_number=42, poll_interval=0, max_wait=5) assert status == DispatchStatus.COMPLETED async def test_returns_timed_out_when_still_open(self): open_resp = MagicMock( status_code=200, json=MagicMock(return_value={"state": "open"}), ) client_mock = AsyncMock() client_mock.__aenter__ = AsyncMock(return_value=client_mock) client_mock.__aexit__ = AsyncMock(return_value=False) client_mock.get = AsyncMock(return_value=open_resp) with ( patch("httpx.AsyncClient", return_value=client_mock), patch("timmy.dispatcher.settings", _GITEA_SETTINGS), patch("asyncio.sleep", new_callable=AsyncMock), ): status = await wait_for_completion(issue_number=42, poll_interval=1, max_wait=2) assert status == DispatchStatus.TIMED_OUT