Sweep coordinator (Step 16): - SweepCoordinator runs all 8 agents in parallel with 60s per-agent / 300s total timeout - Aggregates findings, actions, errors into SweepCoordinatorResult - Registered in FastAPI lifespan; triggered via POST /sweep Structured logging (Step 18): - logging_utils/structured.py: JSONFormatter emitting ts/level/logger/msg + custom fields - log_directive_event() for structured directive lifecycle logging - push_to_loki() async Loki push (graceful no-op if LOKI_URL unset) - configure_logging() replaces root handler at startup Tests (Steps 17+19): - conftest.py: mock_odoo, mock_pool, mock_llm fixtures - test_tool_validator.py: 9 tests covering validation, coercion, hallucination stripping - test_llm_router.py: 6 tests covering local/cloud/hybrid modes and HIPAA enforcement - test_peer_bus.py: 6 tests covering registration, timeout, depth, circular detection - test_finance_agent.py: 10 tests covering all 6 steps + sweep + peer request - test_memory_manager.py: 3 tests covering context build + hard cap enforcement - test_dispatch_router.py: 3 tests covering dispatch, rate limit, health endpoint - test_odoo_client.py: 4 tests covering search_read, write result, unlink warning - test_e2e_dispatch.py: 2 E2E tests - full dispatch cycle + peer bus communication README (Step 20): architecture diagram, privacy modes, quick start, env vars, structure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from agent_service.agents.finance_agent import FinanceAgent, FINANCE_TOOLS
|
|
from agent_service.agents.base_agent import AgentDirective, AgentReport, SweepReport
|
|
from agent_service.llm.llm_types import LLMResponse
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ft():
|
|
ft = MagicMock()
|
|
ft.get_overdue_invoices = AsyncMock(return_value=[
|
|
{'id': 1, 'partner_name': 'ACME Corp', 'amount_residual': 5000.0, 'days_overdue': 45},
|
|
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
|
|
])
|
|
ft.get_financial_summary = AsyncMock(return_value={
|
|
'total_invoiced': 200000.0, 'collection_rate': 75.0,
|
|
})
|
|
ft.get_invoices = AsyncMock(return_value=[])
|
|
ft.send_payment_reminder = AsyncMock(return_value=True)
|
|
ft.flag_for_review = AsyncMock(return_value=True)
|
|
ft.post_chatter_note = AsyncMock(return_value=True)
|
|
ft.get_payment_history = AsyncMock(return_value=[])
|
|
return ft
|
|
|
|
|
|
@pytest.fixture
|
|
def agent(mock_odoo, mock_llm):
|
|
a = FinanceAgent(odoo=mock_odoo, llm=mock_llm)
|
|
return a
|
|
|
|
|
|
def test_finance_agent_has_8_tools():
|
|
assert len(FINANCE_TOOLS) == 8
|
|
|
|
|
|
def test_finance_agent_name():
|
|
assert FinanceAgent.name == 'finance_agent'
|
|
|
|
|
|
def test_finance_agent_hipaa_domain():
|
|
from agent_service.llm.llm_router import HIPAA_LOCKED_AGENTS
|
|
assert 'finance_agent' in HIPAA_LOCKED_AGENTS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_overdue_intent(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
directive = AgentDirective(
|
|
directive_id='d1', user_id='1', intent='show overdue invoices',
|
|
context={}, agent_name='finance_agent',
|
|
)
|
|
plan = await agent._plan(directive)
|
|
assert plan['fetch_overdue'] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plan_send_reminders(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
directive = AgentDirective(
|
|
directive_id='d2', user_id='1', intent='send payment reminders to overdue customers',
|
|
context={}, agent_name='finance_agent',
|
|
)
|
|
plan = await agent._plan(directive)
|
|
assert plan['send_reminders'] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gather_fetches_overdue(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
agent._plan_result = {'fetch_overdue': True, 'fetch_summary': False,
|
|
'fetch_invoices': False, 'partner_id': None, 'period': 'this_month'}
|
|
data = await agent._gather({})
|
|
assert 'overdue' in data
|
|
mock_ft.get_overdue_invoices.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reason_flags_90_day_invoices(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
agent._gathered_data = {
|
|
'overdue': [
|
|
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
|
|
],
|
|
}
|
|
analysis = await agent._reason({})
|
|
assert len(analysis['flags']) == 1
|
|
assert analysis['flags'][0]['invoice_id'] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reason_escalates_high_overdue(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
agent._gathered_data = {
|
|
'overdue': [{'id': 1, 'amount_residual': 60000.0, 'days_overdue': 5, 'partner_name': 'X'}],
|
|
}
|
|
analysis = await agent._reason({})
|
|
assert any('60000' in e or '60' in e for e in analysis['escalations'])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_includes_summary(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
agent._gathered_data = {'summary': {'total_invoiced': 100000.0, 'collection_rate': 80.0}}
|
|
agent._actions_taken = []
|
|
agent._escalations_list = []
|
|
agent._recommendations = []
|
|
report = await agent._report({'analysis': {'overdue_count': 0}})
|
|
assert isinstance(report, AgentReport)
|
|
assert '100000' in report.summary or '80' in report.summary
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sweep_returns_findings(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
mock_ft.get_overdue_invoices = AsyncMock(return_value=[
|
|
{'id': 3, 'partner_name': 'OldDebt', 'amount_residual': 2000.0, 'days_overdue': 65},
|
|
])
|
|
result = await agent.sweep()
|
|
assert isinstance(result, SweepReport)
|
|
assert len(result.findings) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_peer_request_overdue_summary(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
result = await agent.handle_peer_request({'type': 'overdue_summary'})
|
|
assert 'overdue_count' in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_tool_unknown_raises(agent, mock_ft):
|
|
agent._ft = mock_ft
|
|
with pytest.raises(ValueError, match='Unknown tool'):
|
|
await agent._dispatch_tool('nonexistent', {})
|