"""Unit tests for ProjectAgent — plan, gather, reason, report, peer_bus, sweep.""" import datetime import pytest from unittest.mock import AsyncMock, MagicMock from agent_service.agents.project_agent import ProjectAgent, PROJECT_TOOLS from agent_service.agents.base_agent import AgentDirective, AgentReport, SweepReport def _directive(intent='', context=None): return AgentDirective( directive_id='proj-d1', user_id='1', intent=intent, context=context or {}, agent_name='project_agent', ) def _make_agent(): agent = ProjectAgent(odoo=MagicMock(), llm=MagicMock()) agent._pt = MagicMock() agent._pt.get_projects = AsyncMock(return_value=[ {'id': 1, 'name': 'Alpha'}, {'id': 2, 'name': 'Beta'} ]) agent._pt.get_tasks = AsyncMock(return_value=[]) agent._pt.get_project_summary = AsyncMock(return_value={'task_count': 5}) agent._pt.update_task_stage = AsyncMock(return_value=True) agent._pt.assign_task = AsyncMock(return_value=True) agent._pt.create_task = AsyncMock(return_value=99) agent._pt.log_timesheet = AsyncMock(return_value=True) agent._pt.post_chatter_note = AsyncMock(return_value=True) return agent # ── Meta ──────────────────────────────────────────────────────────────────── def test_tool_count(): assert len(PROJECT_TOOLS) <= 8 def test_agent_name(): assert ProjectAgent.name == 'project_agent' # ── _plan ─────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_plan_project_intent(): agent = _make_agent() plan = await agent._plan(_directive(intent='show all projects overview')) assert plan['fetch_projects'] is True @pytest.mark.asyncio async def test_plan_task_intent(): agent = _make_agent() plan = await agent._plan(_directive(intent='list my tasks')) assert plan['fetch_tasks'] is True @pytest.mark.asyncio async def test_plan_propagates_project_id(): agent = _make_agent() plan = await agent._plan(_directive(context={'project_id': 3})) assert plan['project_id'] == 3 # ── _gather ───────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_gather_projects_by_default(): agent = _make_agent() ctx = {'plan': {'fetch_projects': True, 'fetch_tasks': False, 'project_id': None, 'user_id': None}} data = await agent._gather(ctx) assert 'projects' in data assert len(data['projects']) == 2 @pytest.mark.asyncio async def test_gather_tasks_when_requested(): agent = _make_agent() agent._pt.get_tasks = AsyncMock(return_value=[{'id': 10, 'kanban_state': 'normal'}]) ctx = {'plan': {'fetch_projects': False, 'fetch_tasks': True, 'project_id': None, 'user_id': None}} data = await agent._gather(ctx) assert 'tasks' in data @pytest.mark.asyncio async def test_gather_tasks_when_project_id_set(): agent = _make_agent() ctx = {'plan': {'fetch_projects': False, 'fetch_tasks': False, 'project_id': 1, 'user_id': None}} data = await agent._gather(ctx) assert 'tasks' in data # ── _reason ───────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_reason_flags_blocked_tasks(): agent = _make_agent() agent._gathered_data = { 'tasks': [ {'id': i, 'kanban_state': 'blocked', 'name': f'Task {i}'} for i in range(6) ] } analysis = await agent._reason({}) assert len(analysis['blocked_tasks']) == 6 assert len(analysis['escalations']) == 1 assert '6' in analysis['escalations'][0] @pytest.mark.asyncio async def test_reason_few_blocked_no_escalation(): agent = _make_agent() agent._gathered_data = { 'tasks': [{'id': 1, 'kanban_state': 'blocked', 'name': 'T1'}] } analysis = await agent._reason({}) assert len(analysis['blocked_tasks']) == 1 assert analysis['escalations'] == [] @pytest.mark.asyncio async def test_reason_no_tasks_no_escalation(): agent = _make_agent() agent._gathered_data = {'projects': [{'id': 1}]} analysis = await agent._reason({}) assert analysis['escalations'] == [] # ── _report ───────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_report_project_count(): agent = _make_agent() agent._gathered_data = {'projects': [{'id': 1}, {'id': 2}, {'id': 3}]} agent._escalations_list = [] report = await agent._report({}) assert isinstance(report, AgentReport) assert '3' in report.summary @pytest.mark.asyncio async def test_report_task_and_blocked_count(): agent = _make_agent() agent._gathered_data = { 'tasks': [{'id': i, 'kanban_state': 'blocked' if i < 2 else 'normal'} for i in range(5)] } agent._escalations_list = [] report = await agent._report({'analysis': {'blocked_tasks': [0, 1]}}) assert '5' in report.summary assert '2' in report.summary @pytest.mark.asyncio async def test_report_fallback_message(): agent = _make_agent() agent._gathered_data = {} agent._escalations_list = [] report = await agent._report({}) assert 'complete' in report.summary.lower() or 'project' in report.summary.lower() # ── _dispatch_tool ─────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_dispatch_get_projects(): agent = _make_agent() await agent._dispatch_tool('get_projects', {}) agent._pt.get_projects.assert_awaited_once() @pytest.mark.asyncio async def test_dispatch_create_task(): agent = _make_agent() await agent._dispatch_tool('create_task', {'project_id': 1, 'name': 'New Task'}) agent._pt.create_task.assert_awaited_once_with(project_id=1, name='New Task') @pytest.mark.asyncio async def test_dispatch_unknown_raises(): agent = _make_agent() with pytest.raises(ValueError, match='Unknown tool'): await agent._dispatch_tool('nonexistent', {}) # ── handle_peer_request ────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_peer_project_list(): agent = _make_agent() result = await agent.handle_peer_request('project_list', {}, 'dir-1') assert 'projects' in result assert len(result['projects']) == 2 @pytest.mark.asyncio async def test_peer_task_count(): agent = _make_agent() agent._pt.get_tasks = AsyncMock(return_value=[{'id': 1}, {'id': 2}]) result = await agent.handle_peer_request('task_count', {'project_id': 1}, 'dir-1') assert result['count'] == 2 @pytest.mark.asyncio async def test_peer_unknown_returns_error(): agent = _make_agent() result = await agent.handle_peer_request('bad_type', {}, 'dir-1') assert 'error' in result # ── sweep ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_sweep_finds_blocked_tasks(): agent = _make_agent() agent._pt.get_tasks = AsyncMock(return_value=[ {'id': 1, 'name': 'Stuck', 'kanban_state': 'blocked', 'date_deadline': None} ]) result = await agent.sweep() assert isinstance(result, SweepReport) assert any(f['type'] == 'blocked_task' for f in result.findings) @pytest.mark.asyncio async def test_sweep_finds_overdue_tasks(): agent = _make_agent() yesterday = str((datetime.date.today() - datetime.timedelta(days=1))) agent._pt.get_tasks = AsyncMock(return_value=[ {'id': 2, 'name': 'Late', 'kanban_state': 'normal', 'date_deadline': yesterday} ]) result = await agent.sweep() assert any(f['type'] == 'overdue_task' for f in result.findings) @pytest.mark.asyncio async def test_sweep_healthy_no_findings(): agent = _make_agent() future = str((datetime.date.today() + datetime.timedelta(days=7))) agent._pt.get_tasks = AsyncMock(return_value=[ {'id': 1, 'name': 'On track', 'kanban_state': 'normal', 'date_deadline': future} ]) result = await agent.sweep() assert result.findings == [] @pytest.mark.asyncio async def test_sweep_handles_exception(): agent = _make_agent() agent._pt.get_tasks = AsyncMock(side_effect=Exception('timeout')) result = await agent.sweep() assert result.error is not None