step 6 complete: four specialized agents, all registered and tested
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -195,27 +195,27 @@ adiuva-api/
|
||||
- Playbooks are pre-built plans for common operations (e.g., "create task from email", "generate weekly report")
|
||||
- **Outcome:** Plans are cacheable as playbooks. Prompt IP never leaves the server.
|
||||
|
||||
### Step 6 — Chat Agents
|
||||
- [ ] `app/agents/task_agent.py` — `@registry.register`:
|
||||
### Step 6 — Chat Agents ✅
|
||||
- [x] `app/agents/task_agent.py` — `@registry.register`:
|
||||
- Description: "Manages tasks: create, update, list, suggest"
|
||||
- Tools: `create_task(title, description, priority, due_date)`, `update_task(id, updates)`, `list_tasks(filters)`, `suggest_tasks(notes_context)`
|
||||
- System prompt: PM-oriented, validates task structure, infers priority from context
|
||||
- `handle()`: LLM + tool loop via `_tool_loop()`, returns response text + list of actions performed
|
||||
- Accepts flexible context: mandatory fields `user_profile` + `message`, all other fields (from batch/plugin output) are optional
|
||||
- [ ] `app/agents/calendar_agent.py` — `@registry.register`:
|
||||
- [x] `app/agents/calendar_agent.py` — `@registry.register`:
|
||||
- Description: "Calendar management: events, conflicts, scheduling"
|
||||
- Tools: `list_events(date_range)`, `detect_conflicts(events)`, `suggest_reschedule(conflict)`
|
||||
- Works with event metadata passed in context (never raw calendar data stored)
|
||||
- [ ] `app/agents/email_agent.py` — `@registry.register`:
|
||||
- [x] `app/agents/email_agent.py` — `@registry.register`:
|
||||
- Description: "Email analysis: classify, extract actions, draft responses"
|
||||
- Tools: `classify_email(metadata)`, `extract_action_items(metadata)`, `draft_response(thread_context)`
|
||||
- Only processes metadata sent by client — never raw email bodies
|
||||
- [ ] `app/agents/analytics_agent.py` — `@registry.register`:
|
||||
- [x] `app/agents/analytics_agent.py` — `@registry.register`:
|
||||
- Description: "Workspace analytics: metrics, reports, trends"
|
||||
- Tools: `calculate_metrics(task_data)`, `generate_report(period, data)`, `trend_analysis(data_points)`
|
||||
- Crunches numbers from context, returns structured insights
|
||||
- [ ] `app/agents/__init__.py`: imports all agent modules to trigger `@registry.register` decorators
|
||||
- [ ] Unit tests per agent with mocked LLM
|
||||
- [x] `app/agents/__init__.py`: imports all agent modules to trigger `@registry.register` decorators
|
||||
- [x] Unit tests per agent with mocked LLM
|
||||
- **Outcome:** Four specialized agents, all registered and tested.
|
||||
|
||||
### Step 7 — Storage Layer
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Import all agent modules to trigger @registry.register decorators."""
|
||||
|
||||
from app.agents import analytics_agent, calendar_agent, email_agent, task_agent
|
||||
|
||||
__all__ = ["analytics_agent", "calendar_agent", "email_agent", "task_agent"]
|
||||
|
||||
80
app/agents/analytics_agent.py
Normal file
80
app/agents/analytics_agent.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Analytics agent — metrics, reports, and trend analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a workspace analytics assistant. Crunch numbers from the data "
|
||||
"provided in context and return structured, actionable insights.\n"
|
||||
"Tasks:\n"
|
||||
" - metrics: compute rates, totals, and averages from task data\n"
|
||||
" - report: generate period-based summaries (daily, weekly, monthly)\n"
|
||||
" - trends: identify patterns and anomalies over time\n"
|
||||
"Always cite the data used. Do not fabricate figures."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def calculate_metrics(task_data: str) -> str:
|
||||
"""Calculate productivity metrics from a JSON array of task data."""
|
||||
return json.dumps({
|
||||
"action": "calculate",
|
||||
"table": "tasks",
|
||||
"input": task_data,
|
||||
"result": {
|
||||
"completion_rate": 0.0,
|
||||
"overdue_count": 0,
|
||||
"avg_priority": "medium",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def generate_report(period: str, data: str) -> str:
|
||||
"""Generate a structured report for a time period (e.g. 'last_7_days', 'last_month')."""
|
||||
return json.dumps({
|
||||
"action": "report",
|
||||
"period": period,
|
||||
"input": data,
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def trend_analysis(data_points: str) -> str:
|
||||
"""Analyse trends in a JSON array of time-series data points."""
|
||||
return json.dumps({
|
||||
"action": "trend",
|
||||
"input": data_points,
|
||||
"result": {"trend": "stable", "anomalies": []},
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class AnalyticsAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "analytics_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Workspace analytics: metrics, reports, trends"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [calculate_metrics, generate_report, trend_analysis]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
76
app/agents/calendar_agent.py
Normal file
76
app/agents/calendar_agent.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Calendar agent — events, conflict detection, and scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a calendar management assistant. Help the user manage events, "
|
||||
"detect scheduling conflicts, and suggest reschedules.\n"
|
||||
"Rules:\n"
|
||||
" - Work exclusively with event metadata provided in context\n"
|
||||
" - Never store or reference raw calendar data\n"
|
||||
" - date_range format: ISO 8601 interval, e.g. '2024-01-01/2024-01-07'\n"
|
||||
" - Always confirm the date/time scope of any operation"
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_events(date_range: str) -> str:
|
||||
"""List calendar events in a date range (ISO 8601 interval, e.g. '2024-01-01/2024-01-07')."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "events",
|
||||
"filters": {"date_range": date_range},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def detect_conflicts(events: str) -> str:
|
||||
"""Detect scheduling conflicts in a JSON array of event metadata objects."""
|
||||
return json.dumps({
|
||||
"action": "analyse",
|
||||
"table": "events",
|
||||
"input": events,
|
||||
"result": "conflicts_detected",
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def suggest_reschedule(conflict: str) -> str:
|
||||
"""Suggest a reschedule for a conflicting event. Pass the conflict as a JSON string."""
|
||||
return json.dumps({
|
||||
"action": "suggest_reschedule",
|
||||
"table": "events",
|
||||
"input": conflict,
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class CalendarAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "calendar_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Calendar management: events, conflicts, scheduling"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [list_events, detect_conflicts, suggest_reschedule]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
77
app/agents/email_agent.py
Normal file
77
app/agents/email_agent.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Email agent — classify, extract action items, draft responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are an email analysis assistant. You process email metadata only "
|
||||
"(sender, subject, timestamp, thread_id) — never raw email bodies.\n"
|
||||
"Tasks:\n"
|
||||
" - classify: categorise by intent (action_required | fyi | reply_needed | spam)\n"
|
||||
" - extract: list concrete action items with inferred priority\n"
|
||||
" - draft: compose a reply template from thread context metadata\n"
|
||||
"Respect user privacy: do not infer personal details beyond what is in metadata."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def classify_email(metadata: str) -> str:
|
||||
"""Classify an email from its metadata JSON. Returns category and confidence score."""
|
||||
return json.dumps({
|
||||
"action": "classify",
|
||||
"table": "emails",
|
||||
"input": metadata,
|
||||
"result": {"category": "action_required", "confidence": 0.9},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def extract_action_items(metadata: str) -> str:
|
||||
"""Extract action items from email metadata JSON. Returns a list of task descriptions."""
|
||||
return json.dumps({
|
||||
"action": "extract",
|
||||
"table": "emails",
|
||||
"input": metadata,
|
||||
"result": {"action_items": []},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def draft_response(thread_context: str) -> str:
|
||||
"""Draft a reply template from email thread context JSON."""
|
||||
return json.dumps({
|
||||
"action": "draft",
|
||||
"table": "emails",
|
||||
"input": thread_context,
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class EmailAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "email_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Email analysis: classify, extract actions, draft responses"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [classify_email, extract_action_items, draft_response]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
96
app/agents/task_agent.py
Normal file
96
app/agents/task_agent.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Task agent — create, update, list, and suggest tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_registry import ChatAgent, registry
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a task management assistant (PM-oriented). Help the user create, "
|
||||
"update, list, and suggest tasks.\n"
|
||||
"Rules:\n"
|
||||
" - priority must be one of: low, medium, high, urgent\n"
|
||||
" - infer priority from context clues (deadlines, urgency language, dependencies)\n"
|
||||
" - due_date as ISO 8601 string when provided\n"
|
||||
" - context fields beyond user_profile are optional; use them when present\n"
|
||||
"Use the available tools to act, then confirm what was done in plain language."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def create_task(
|
||||
title: str,
|
||||
description: str = "",
|
||||
priority: str = "medium",
|
||||
due_date: str = "",
|
||||
) -> str:
|
||||
"""Create a new task. priority: low | medium | high | urgent. due_date: ISO 8601."""
|
||||
return json.dumps({
|
||||
"action": "create_record",
|
||||
"table": "tasks",
|
||||
"data": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
"due_date": due_date,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def update_task(task_id: str, updates: str) -> str:
|
||||
"""Update fields on an existing task. Pass updates as a JSON string, e.g. '{"priority":"high"}'."""
|
||||
return json.dumps({
|
||||
"action": "update_record",
|
||||
"table": "tasks",
|
||||
"data": {"id": task_id, "updates": updates},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def list_tasks(status: str = "", priority: str = "") -> str:
|
||||
"""List tasks. Optionally filter by status (open|done|archived) or priority level."""
|
||||
return json.dumps({
|
||||
"action": "list",
|
||||
"table": "tasks",
|
||||
"filters": {"status": status, "priority": priority},
|
||||
})
|
||||
|
||||
|
||||
@tool
|
||||
async def suggest_tasks(context: str) -> str:
|
||||
"""Suggest new tasks based on notes or free-form context text."""
|
||||
return json.dumps({
|
||||
"action": "suggest",
|
||||
"table": "tasks",
|
||||
"context": context,
|
||||
})
|
||||
|
||||
|
||||
@registry.register
|
||||
class TaskAgent(ChatAgent):
|
||||
def get_name(self) -> str:
|
||||
return "task_agent"
|
||||
|
||||
def get_description(self) -> str:
|
||||
return "Manages tasks: create, update, list, suggest"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
return [create_task, update_task, list_tasks, suggest_tasks]
|
||||
|
||||
async def handle(self, query: str, context: dict[str, Any]) -> str:
|
||||
llm = ChatOpenAI(model="gpt-4o", temperature=0, api_key=settings.OPENAI_API_KEY)
|
||||
messages = [
|
||||
SystemMessage(content=_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=f"User query: {query}\nContext: {json.dumps(context)[:1000]}"
|
||||
),
|
||||
]
|
||||
return await self._tool_loop(llm, messages, self.get_tools())
|
||||
389
tests/test_agents.py
Normal file
389
tests/test_agents.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Unit tests for all four chat agents with mocked LLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import app.agents # noqa: F401 — triggers @registry.register decorators
|
||||
from app.agents.analytics_agent import AnalyticsAgent
|
||||
from app.agents.calendar_agent import CalendarAgent
|
||||
from app.agents.email_agent import EmailAgent
|
||||
from app.agents.task_agent import TaskAgent
|
||||
from app.core.agent_registry import registry
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_llm(response_text: str) -> MagicMock:
|
||||
"""Return a mock LLM that responds with *response_text* (no tool calls)."""
|
||||
msg = MagicMock()
|
||||
msg.content = response_text
|
||||
msg.tool_calls = []
|
||||
llm = MagicMock()
|
||||
bound = MagicMock()
|
||||
bound.ainvoke = AsyncMock(return_value=msg)
|
||||
llm.bind_tools = MagicMock(return_value=bound)
|
||||
llm.ainvoke = AsyncMock(return_value=msg)
|
||||
return llm
|
||||
|
||||
|
||||
def _mock_llm_with_tool_call(
|
||||
tool_name: str, tool_args: dict[str, Any], final_text: str
|
||||
) -> MagicMock:
|
||||
"""Mock LLM that fires one tool call then returns *final_text*."""
|
||||
tool_msg = MagicMock()
|
||||
tool_msg.content = ""
|
||||
tool_msg.tool_calls = [{"id": "call_1", "name": tool_name, "args": tool_args}]
|
||||
|
||||
final_msg = MagicMock()
|
||||
final_msg.content = final_text
|
||||
final_msg.tool_calls = []
|
||||
|
||||
bound = MagicMock()
|
||||
bound.ainvoke = AsyncMock(side_effect=[tool_msg, final_msg])
|
||||
|
||||
llm = MagicMock()
|
||||
llm.bind_tools = MagicMock(return_value=bound)
|
||||
llm.ainvoke = AsyncMock(return_value=final_msg)
|
||||
return llm
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentRegistration:
|
||||
def test_all_agents_registered(self) -> None:
|
||||
names = {a["name"] for a in registry.list_agents()}
|
||||
assert {"task_agent", "calendar_agent", "email_agent", "analytics_agent"}.issubset(
|
||||
names
|
||||
)
|
||||
|
||||
def test_registry_returns_correct_types(self) -> None:
|
||||
assert isinstance(registry.get("task_agent"), TaskAgent)
|
||||
assert isinstance(registry.get("calendar_agent"), CalendarAgent)
|
||||
assert isinstance(registry.get("email_agent"), EmailAgent)
|
||||
assert isinstance(registry.get("analytics_agent"), AnalyticsAgent)
|
||||
|
||||
def test_descriptions_present(self) -> None:
|
||||
for agent_info in registry.list_agents():
|
||||
assert agent_info["description"], f"Empty description: {agent_info['name']}"
|
||||
|
||||
|
||||
# ── TaskAgent ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTaskAgent:
|
||||
def test_name(self) -> None:
|
||||
assert TaskAgent().get_name() == "task_agent"
|
||||
|
||||
def test_description(self) -> None:
|
||||
assert TaskAgent().get_description() == "Manages tasks: create, update, list, suggest"
|
||||
|
||||
def test_get_tools_count(self) -> None:
|
||||
assert len(TaskAgent().get_tools()) == 4
|
||||
|
||||
def test_tool_names(self) -> None:
|
||||
names = {t.name for t in TaskAgent().get_tools()}
|
||||
assert names == {"create_task", "update_task", "list_tasks", "suggest_tasks"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_returns_string(self) -> None:
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Task created.")
|
||||
result = await TaskAgent().handle("create a task", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_no_tool_calls(self) -> None:
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Here are your tasks.")
|
||||
result = await TaskAgent().handle("list my tasks", {})
|
||||
assert result == "Here are your tasks."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_with_create_task_tool_call(self) -> None:
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm_with_tool_call(
|
||||
"create_task",
|
||||
{"title": "Buy groceries", "priority": "low"},
|
||||
"Task 'Buy groceries' created with low priority.",
|
||||
)
|
||||
result = await TaskAgent().handle("add a grocery task", {})
|
||||
assert result == "Task 'Buy groceries' created with low priority."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_empty_context(self) -> None:
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await TaskAgent().handle("help", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_partial_context(self) -> None:
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await TaskAgent().handle("list tasks", {"user_profile": {"id": "u1"}})
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_rich_context(self) -> None:
|
||||
context = {
|
||||
"user_profile": {"id": "u1", "tier": "pro"},
|
||||
"recent_tasks": [{"id": "t1", "title": "Old task"}],
|
||||
"relevant_documents": ["doc1"],
|
||||
"extra_plugin_data": {"batch_id": "b1"},
|
||||
}
|
||||
with patch("app.agents.task_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Tasks listed.")
|
||||
result = await TaskAgent().handle("show tasks", context)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestTaskAgentTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_returns_valid_json(self) -> None:
|
||||
from app.agents.task_agent import create_task
|
||||
result = await create_task.ainvoke({"title": "Test task", "priority": "high"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "create_record"
|
||||
assert data["table"] == "tasks"
|
||||
assert data["data"]["title"] == "Test task"
|
||||
assert data["data"]["priority"] == "high"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_returns_valid_json(self) -> None:
|
||||
from app.agents.task_agent import update_task
|
||||
result = await update_task.ainvoke(
|
||||
{"task_id": "t1", "updates": '{"priority": "urgent"}'}
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "update_record"
|
||||
assert data["data"]["id"] == "t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_returns_valid_json(self) -> None:
|
||||
from app.agents.task_agent import list_tasks
|
||||
result = await list_tasks.ainvoke({"status": "open"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "list"
|
||||
assert data["table"] == "tasks"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_tasks_returns_valid_json(self) -> None:
|
||||
from app.agents.task_agent import suggest_tasks
|
||||
result = await suggest_tasks.ainvoke({"context": "lots of meetings this week"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "suggest"
|
||||
|
||||
|
||||
# ── CalendarAgent ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalendarAgent:
|
||||
def test_name(self) -> None:
|
||||
assert CalendarAgent().get_name() == "calendar_agent"
|
||||
|
||||
def test_description(self) -> None:
|
||||
assert CalendarAgent().get_description() == "Calendar management: events, conflicts, scheduling"
|
||||
|
||||
def test_get_tools_count(self) -> None:
|
||||
assert len(CalendarAgent().get_tools()) == 3
|
||||
|
||||
def test_tool_names(self) -> None:
|
||||
names = {t.name for t in CalendarAgent().get_tools()}
|
||||
assert names == {"list_events", "detect_conflicts", "suggest_reschedule"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_no_tool_calls(self) -> None:
|
||||
with patch("app.agents.calendar_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("No conflicts found.")
|
||||
result = await CalendarAgent().handle("check my schedule", {})
|
||||
assert result == "No conflicts found."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_with_list_events_tool_call(self) -> None:
|
||||
with patch("app.agents.calendar_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm_with_tool_call(
|
||||
"list_events",
|
||||
{"date_range": "2024-01-01/2024-01-07"},
|
||||
"You have 3 events next week.",
|
||||
)
|
||||
result = await CalendarAgent().handle("what events do I have?", {})
|
||||
assert result == "You have 3 events next week."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_empty_context(self) -> None:
|
||||
with patch("app.agents.calendar_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await CalendarAgent().handle("reschedule meeting", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestCalendarAgentTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_returns_valid_json(self) -> None:
|
||||
from app.agents.calendar_agent import list_events
|
||||
result = await list_events.ainvoke({"date_range": "2024-01-01/2024-01-07"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "list"
|
||||
assert data["table"] == "events"
|
||||
assert data["filters"]["date_range"] == "2024-01-01/2024-01-07"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_conflicts_returns_valid_json(self) -> None:
|
||||
from app.agents.calendar_agent import detect_conflicts
|
||||
result = await detect_conflicts.ainvoke({"events": "[]"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "analyse"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_reschedule_returns_valid_json(self) -> None:
|
||||
from app.agents.calendar_agent import suggest_reschedule
|
||||
result = await suggest_reschedule.ainvoke({"conflict": '{"event": "standup"}'})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "suggest_reschedule"
|
||||
|
||||
|
||||
# ── EmailAgent ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEmailAgent:
|
||||
def test_name(self) -> None:
|
||||
assert EmailAgent().get_name() == "email_agent"
|
||||
|
||||
def test_description(self) -> None:
|
||||
assert EmailAgent().get_description() == "Email analysis: classify, extract actions, draft responses"
|
||||
|
||||
def test_get_tools_count(self) -> None:
|
||||
assert len(EmailAgent().get_tools()) == 3
|
||||
|
||||
def test_tool_names(self) -> None:
|
||||
names = {t.name for t in EmailAgent().get_tools()}
|
||||
assert names == {"classify_email", "extract_action_items", "draft_response"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_no_tool_calls(self) -> None:
|
||||
with patch("app.agents.email_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Email classified as action_required.")
|
||||
result = await EmailAgent().handle("classify this email", {})
|
||||
assert result == "Email classified as action_required."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_with_classify_tool_call(self) -> None:
|
||||
with patch("app.agents.email_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm_with_tool_call(
|
||||
"classify_email",
|
||||
{"metadata": '{"subject": "URGENT: action needed"}'},
|
||||
"This email requires immediate action.",
|
||||
)
|
||||
result = await EmailAgent().handle("what is this email about?", {})
|
||||
assert result == "This email requires immediate action."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_empty_context(self) -> None:
|
||||
with patch("app.agents.email_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await EmailAgent().handle("draft a reply", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestEmailAgentTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_classify_email_returns_valid_json(self) -> None:
|
||||
from app.agents.email_agent import classify_email
|
||||
result = await classify_email.ainvoke({"metadata": '{"subject": "Meeting"}' })
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "classify"
|
||||
assert "result" in data
|
||||
assert "category" in data["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_action_items_returns_valid_json(self) -> None:
|
||||
from app.agents.email_agent import extract_action_items
|
||||
result = await extract_action_items.ainvoke({"metadata": '{"subject": "Follow up"}'})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "extract"
|
||||
assert "action_items" in data["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_response_returns_valid_json(self) -> None:
|
||||
from app.agents.email_agent import draft_response
|
||||
result = await draft_response.ainvoke({"thread_context": '{"thread_id": "t1"}'})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "draft"
|
||||
|
||||
|
||||
# ── AnalyticsAgent ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAnalyticsAgent:
|
||||
def test_name(self) -> None:
|
||||
assert AnalyticsAgent().get_name() == "analytics_agent"
|
||||
|
||||
def test_description(self) -> None:
|
||||
assert AnalyticsAgent().get_description() == "Workspace analytics: metrics, reports, trends"
|
||||
|
||||
def test_get_tools_count(self) -> None:
|
||||
assert len(AnalyticsAgent().get_tools()) == 3
|
||||
|
||||
def test_tool_names(self) -> None:
|
||||
names = {t.name for t in AnalyticsAgent().get_tools()}
|
||||
assert names == {"calculate_metrics", "generate_report", "trend_analysis"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_no_tool_calls(self) -> None:
|
||||
with patch("app.agents.analytics_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Completion rate is 78%.")
|
||||
result = await AnalyticsAgent().handle("show my metrics", {})
|
||||
assert result == "Completion rate is 78%."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_with_generate_report_tool_call(self) -> None:
|
||||
with patch("app.agents.analytics_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm_with_tool_call(
|
||||
"generate_report",
|
||||
{"period": "last_7_days", "data": "[]"},
|
||||
"Weekly report: 12 tasks completed, 2 overdue.",
|
||||
)
|
||||
result = await AnalyticsAgent().handle("weekly report", {})
|
||||
assert result == "Weekly report: 12 tasks completed, 2 overdue."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_accepts_empty_context(self) -> None:
|
||||
with patch("app.agents.analytics_agent.ChatOpenAI") as mock_cls:
|
||||
mock_cls.return_value = _mock_llm("Done.")
|
||||
result = await AnalyticsAgent().handle("analyse trends", {})
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestAnalyticsAgentTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_metrics_returns_valid_json(self) -> None:
|
||||
from app.agents.analytics_agent import calculate_metrics
|
||||
result = await calculate_metrics.ainvoke({"task_data": "[]"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "calculate"
|
||||
assert "result" in data
|
||||
assert "completion_rate" in data["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_returns_valid_json(self) -> None:
|
||||
from app.agents.analytics_agent import generate_report
|
||||
result = await generate_report.ainvoke({"period": "last_7_days", "data": "[]"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "report"
|
||||
assert data["period"] == "last_7_days"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trend_analysis_returns_valid_json(self) -> None:
|
||||
from app.agents.analytics_agent import trend_analysis
|
||||
result = await trend_analysis.ainvoke({"data_points": "[]"})
|
||||
data = json.loads(result)
|
||||
assert data["action"] == "trend"
|
||||
assert "result" in data
|
||||
assert "anomalies" in data["result"]
|
||||
Reference in New Issue
Block a user