"""Unit tests for the four domain-specific 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.checkpoint_agent import CheckpointAgent from app.agents.note_agent import NoteAgent from app.agents.project_agent import ProjectAgent 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", "checkpoint_agent", "project_agent", "note_agent" }.issubset(names) def test_registry_returns_correct_types(self) -> None: assert isinstance(registry.get("task_agent"), TaskAgent) assert isinstance(registry.get("checkpoint_agent"), CheckpointAgent) assert isinstance(registry.get("project_agent"), ProjectAgent) assert isinstance(registry.get("note_agent"), NoteAgent) 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 and comments: list, create, update, delete, due-today, comments" def test_get_tools_count(self) -> None: assert len(TaskAgent().get_tools()) == 8 def test_tool_names(self) -> None: names = {t.name for t in TaskAgent().get_tools()} assert names == { "list_tasks", "create_task", "update_task", "delete_task", "list_tasks_due_today", "list_task_comments", "add_task_comment", "delete_task_comment", } @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.", ) result = await TaskAgent().handle("add a grocery task", {}) assert result == "Task 'Buy groceries' created." @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_rich_context(self) -> None: context = { "user_profile": {"id": "u1", "tier": "pro"}, "recent_tasks": [{"id": "t1", "title": "Old task"}], } 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_list_tasks_defaults(self) -> None: from app.agents.task_agent import list_tasks result = await list_tasks.ainvoke({}) data = json.loads(result) assert data["action"] == "list" assert data["table"] == "tasks" @pytest.mark.asyncio async def test_list_tasks_with_status_filter(self) -> None: from app.agents.task_agent import list_tasks result = await list_tasks.ainvoke({"status": "done"}) data = json.loads(result) assert data["filters"]["status"] == "done" @pytest.mark.asyncio async def test_create_task_defaults(self) -> None: from app.agents.task_agent import create_task result = await create_task.ainvoke({"title": "Test task"}) data = json.loads(result) assert data["action"] == "create_record" assert data["table"] == "tasks" assert data["data"]["title"] == "Test task" assert data["data"]["status"] == "todo" assert data["data"]["priority"] == "medium" @pytest.mark.asyncio async def test_create_task_with_all_fields(self) -> None: from app.agents.task_agent import create_task result = await create_task.ainvoke({ "title": "Deploy", "priority": "high", "status": "in_progress", "project_id": "p1", "is_ai_suggested": 1, }) data = json.loads(result) assert data["data"]["priority"] == "high" assert data["data"]["status"] == "in_progress" assert data["data"]["projectId"] == "p1" assert data["data"]["isAiSuggested"] == 1 @pytest.mark.asyncio async def test_update_task_with_status(self) -> None: from app.agents.task_agent import update_task result = await update_task.ainvoke({"task_id": "t1", "status": "done"}) data = json.loads(result) assert data["action"] == "update_record" assert data["data"]["id"] == "t1" assert data["data"]["updates"]["status"] == "done" @pytest.mark.asyncio async def test_update_task_empty_updates(self) -> None: from app.agents.task_agent import update_task result = await update_task.ainvoke({"task_id": "t1"}) data = json.loads(result) assert data["data"]["updates"] == {} @pytest.mark.asyncio async def test_delete_task(self) -> None: from app.agents.task_agent import delete_task result = await delete_task.ainvoke({"task_id": "t1"}) data = json.loads(result) assert data["action"] == "delete_record" assert data["table"] == "tasks" assert data["data"]["id"] == "t1" @pytest.mark.asyncio async def test_list_tasks_due_today(self) -> None: from app.agents.task_agent import list_tasks_due_today result = await list_tasks_due_today.ainvoke({}) data = json.loads(result) assert data["action"] == "list_due_today" assert data["table"] == "tasks" @pytest.mark.asyncio async def test_list_task_comments(self) -> None: from app.agents.task_agent import list_task_comments result = await list_task_comments.ainvoke({"task_id": "t1"}) data = json.loads(result) assert data["action"] == "list" assert data["table"] == "taskComments" assert data["filters"]["taskId"] == "t1" @pytest.mark.asyncio async def test_add_task_comment(self) -> None: from app.agents.task_agent import add_task_comment result = await add_task_comment.ainvoke({ "task_id": "t1", "author": "Alice", "content": "Looks good!", }) data = json.loads(result) assert data["action"] == "create_record" assert data["table"] == "taskComments" assert data["data"]["taskId"] == "t1" assert data["data"]["author"] == "Alice" assert data["data"]["content"] == "Looks good!" @pytest.mark.asyncio async def test_delete_task_comment(self) -> None: from app.agents.task_agent import delete_task_comment result = await delete_task_comment.ainvoke({"comment_id": "c1"}) data = json.loads(result) assert data["action"] == "delete_record" assert data["table"] == "taskComments" assert data["data"]["id"] == "c1" # ── CheckpointAgent ─────────────────────────────────────────────────── class TestCheckpointAgent: def test_name(self) -> None: assert CheckpointAgent().get_name() == "checkpoint_agent" def test_description(self) -> None: assert CheckpointAgent().get_description() == "Manages project checkpoints (milestones): list, create, update, delete" def test_get_tools_count(self) -> None: assert len(CheckpointAgent().get_tools()) == 4 def test_tool_names(self) -> None: names = {t.name for t in CheckpointAgent().get_tools()} assert names == {"list_checkpoints", "create_checkpoint", "update_checkpoint", "delete_checkpoint"} @pytest.mark.asyncio async def test_handle_no_tool_calls(self) -> None: with patch("app.agents.checkpoint_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("No checkpoints found.") result = await CheckpointAgent().handle("list checkpoints", {}) assert result == "No checkpoints found." @pytest.mark.asyncio async def test_handle_with_create_tool_call(self) -> None: with patch("app.agents.checkpoint_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm_with_tool_call( "create_checkpoint", {"project_id": "p1", "title": "MVP Launch", "date": 1700000000000}, "Checkpoint 'MVP Launch' created.", ) result = await CheckpointAgent().handle("add MVP checkpoint", {}) assert result == "Checkpoint 'MVP Launch' created." @pytest.mark.asyncio async def test_handle_accepts_empty_context(self) -> None: with patch("app.agents.checkpoint_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("Done.") result = await CheckpointAgent().handle("show milestones", {}) assert isinstance(result, str) class TestCheckpointAgentTools: @pytest.mark.asyncio async def test_list_checkpoints_no_project(self) -> None: from app.agents.checkpoint_agent import list_checkpoints result = await list_checkpoints.ainvoke({}) data = json.loads(result) assert data["action"] == "list" assert data["table"] == "checkpoints" assert data["filters"]["projectId"] is None @pytest.mark.asyncio async def test_list_checkpoints_with_project(self) -> None: from app.agents.checkpoint_agent import list_checkpoints result = await list_checkpoints.ainvoke({"project_id": "p1"}) data = json.loads(result) assert data["filters"]["projectId"] == "p1" @pytest.mark.asyncio async def test_create_checkpoint(self) -> None: from app.agents.checkpoint_agent import create_checkpoint result = await create_checkpoint.ainvoke({ "project_id": "p1", "title": "Beta release", "date": 1700000000000, }) data = json.loads(result) assert data["action"] == "create_record" assert data["table"] == "checkpoints" assert data["data"]["projectId"] == "p1" assert data["data"]["title"] == "Beta release" assert data["data"]["date"] == 1700000000000 @pytest.mark.asyncio async def test_create_checkpoint_ai_suggested(self) -> None: from app.agents.checkpoint_agent import create_checkpoint result = await create_checkpoint.ainvoke({ "project_id": "p1", "title": "Review", "date": 1700000000000, "is_ai_suggested": 1, }) data = json.loads(result) assert data["data"]["isAiSuggested"] == 1 assert data["data"]["isApproved"] == 0 @pytest.mark.asyncio async def test_update_checkpoint_approve(self) -> None: from app.agents.checkpoint_agent import update_checkpoint result = await update_checkpoint.ainvoke({ "checkpoint_id": "c1", "is_approved": 1, }) data = json.loads(result) assert data["action"] == "update_record" assert data["data"]["id"] == "c1" assert data["data"]["updates"]["isApproved"] == 1 @pytest.mark.asyncio async def test_update_checkpoint_empty_updates(self) -> None: from app.agents.checkpoint_agent import update_checkpoint result = await update_checkpoint.ainvoke({"checkpoint_id": "c1"}) data = json.loads(result) assert data["data"]["updates"] == {} @pytest.mark.asyncio async def test_delete_checkpoint(self) -> None: from app.agents.checkpoint_agent import delete_checkpoint result = await delete_checkpoint.ainvoke({"checkpoint_id": "c1"}) data = json.loads(result) assert data["action"] == "delete_record" assert data["table"] == "checkpoints" assert data["data"]["id"] == "c1" # ── ProjectAgent ────────────────────────────────────────────────────── class TestProjectAgent: def test_name(self) -> None: assert ProjectAgent().get_name() == "project_agent" def test_description(self) -> None: assert ProjectAgent().get_description() == "Manages projects: list, get, create, update, archive, delete" def test_get_tools_count(self) -> None: assert len(ProjectAgent().get_tools()) == 6 def test_tool_names(self) -> None: names = {t.name for t in ProjectAgent().get_tools()} assert names == { "list_projects", "list_all_projects", "get_project", "create_project", "update_project", "delete_project", } @pytest.mark.asyncio async def test_handle_no_tool_calls(self) -> None: with patch("app.agents.project_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("Project Alpha is active.") result = await ProjectAgent().handle("show my projects", {}) assert result == "Project Alpha is active." @pytest.mark.asyncio async def test_handle_with_create_project_tool_call(self) -> None: with patch("app.agents.project_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm_with_tool_call( "create_project", {"name": "Pippo"}, "Project 'Pippo' created.", ) result = await ProjectAgent().handle("create project Pippo", {}) assert result == "Project 'Pippo' created." @pytest.mark.asyncio async def test_handle_accepts_empty_context(self) -> None: with patch("app.agents.project_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("Done.") result = await ProjectAgent().handle("archive old project", {}) assert isinstance(result, str) class TestProjectAgentTools: @pytest.mark.asyncio async def test_list_projects_defaults(self) -> None: from app.agents.project_agent import list_projects result = await list_projects.ainvoke({}) data = json.loads(result) assert data["action"] == "list" assert data["table"] == "projects" assert data["filters"]["includeArchived"] is False @pytest.mark.asyncio async def test_list_projects_include_archived(self) -> None: from app.agents.project_agent import list_projects result = await list_projects.ainvoke({"include_archived": 1}) data = json.loads(result) assert data["filters"]["includeArchived"] is True @pytest.mark.asyncio async def test_list_all_projects(self) -> None: from app.agents.project_agent import list_all_projects result = await list_all_projects.ainvoke({}) data = json.loads(result) assert data["action"] == "list_all" assert data["table"] == "projects" @pytest.mark.asyncio async def test_get_project(self) -> None: from app.agents.project_agent import get_project result = await get_project.ainvoke({"project_id": "p1"}) data = json.loads(result) assert data["action"] == "get" assert data["table"] == "projects" assert data["data"]["id"] == "p1" @pytest.mark.asyncio async def test_create_project_name_only(self) -> None: from app.agents.project_agent import create_project result = await create_project.ainvoke({"name": "Alpha"}) data = json.loads(result) assert data["action"] == "create_record" assert data["data"]["name"] == "Alpha" assert data["data"]["clientId"] is None @pytest.mark.asyncio async def test_create_project_with_client(self) -> None: from app.agents.project_agent import create_project result = await create_project.ainvoke({"name": "Beta", "client_id": "cl1"}) data = json.loads(result) assert data["data"]["clientId"] == "cl1" @pytest.mark.asyncio async def test_update_project_archive(self) -> None: from app.agents.project_agent import update_project result = await update_project.ainvoke({"project_id": "p1", "status": "archived"}) data = json.loads(result) assert data["action"] == "update_record" assert data["data"]["id"] == "p1" assert data["data"]["updates"]["status"] == "archived" @pytest.mark.asyncio async def test_update_project_empty_updates(self) -> None: from app.agents.project_agent import update_project result = await update_project.ainvoke({"project_id": "p1"}) data = json.loads(result) assert data["data"]["updates"] == {} @pytest.mark.asyncio async def test_delete_project(self) -> None: from app.agents.project_agent import delete_project result = await delete_project.ainvoke({"project_id": "p1"}) data = json.loads(result) assert data["action"] == "delete_record" assert data["data"]["id"] == "p1" # ── NoteAgent ───────────────────────────────────────────────────────── class TestNoteAgent: def test_name(self) -> None: assert NoteAgent().get_name() == "note_agent" def test_description(self) -> None: assert NoteAgent().get_description() == "Manages notes: list, get, create, update, delete" def test_get_tools_count(self) -> None: assert len(NoteAgent().get_tools()) == 5 def test_tool_names(self) -> None: names = {t.name for t in NoteAgent().get_tools()} assert names == {"list_notes", "get_note", "create_note", "update_note", "delete_note"} @pytest.mark.asyncio async def test_handle_no_tool_calls(self) -> None: with patch("app.agents.note_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("Note created.") result = await NoteAgent().handle("create a note", {}) assert result == "Note created." @pytest.mark.asyncio async def test_handle_with_create_note_tool_call(self) -> None: with patch("app.agents.note_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm_with_tool_call( "create_note", {"title": "Daily log", "content": "# Today\nAll good."}, "Note 'Daily log' created.", ) result = await NoteAgent().handle("log today's progress", {}) assert result == "Note 'Daily log' created." @pytest.mark.asyncio async def test_handle_accepts_empty_context(self) -> None: with patch("app.agents.note_agent.ChatOpenAI") as mock_cls: mock_cls.return_value = _mock_llm("Done.") result = await NoteAgent().handle("show notes", {}) assert isinstance(result, str) class TestNoteAgentTools: @pytest.mark.asyncio async def test_list_notes_no_project(self) -> None: from app.agents.note_agent import list_notes result = await list_notes.ainvoke({}) data = json.loads(result) assert data["action"] == "list" assert data["table"] == "notes" assert data["filters"]["projectId"] is None @pytest.mark.asyncio async def test_list_notes_with_project(self) -> None: from app.agents.note_agent import list_notes result = await list_notes.ainvoke({"project_id": "p1"}) data = json.loads(result) assert data["filters"]["projectId"] == "p1" @pytest.mark.asyncio async def test_get_note(self) -> None: from app.agents.note_agent import get_note result = await get_note.ainvoke({"note_id": "n1"}) data = json.loads(result) assert data["action"] == "get" assert data["table"] == "notes" assert data["data"]["id"] == "n1" @pytest.mark.asyncio async def test_create_note_minimal(self) -> None: from app.agents.note_agent import create_note result = await create_note.ainvoke({ "title": "Daily log", "content": "# Today\nAll good.", }) data = json.loads(result) assert data["action"] == "create_record" assert data["table"] == "notes" assert data["data"]["title"] == "Daily log" assert data["data"]["content"] == "# Today\nAll good." assert data["data"]["projectId"] is None @pytest.mark.asyncio async def test_create_note_with_project(self) -> None: from app.agents.note_agent import create_note result = await create_note.ainvoke({ "title": "Sprint notes", "content": "## Sprint 1", "project_id": "p1", }) data = json.loads(result) assert data["data"]["projectId"] == "p1" @pytest.mark.asyncio async def test_update_note_content_only(self) -> None: from app.agents.note_agent import update_note result = await update_note.ainvoke({ "note_id": "n1", "content": "# Updated content", }) data = json.loads(result) assert data["action"] == "update_record" assert data["data"]["id"] == "n1" assert data["data"]["updates"]["content"] == "# Updated content" assert "title" not in data["data"]["updates"] @pytest.mark.asyncio async def test_update_note_empty_updates(self) -> None: from app.agents.note_agent import update_note result = await update_note.ainvoke({"note_id": "n1"}) data = json.loads(result) assert data["data"]["updates"] == {} @pytest.mark.asyncio async def test_delete_note(self) -> None: from app.agents.note_agent import delete_note result = await delete_note.ainvoke({"note_id": "n1"}) data = json.loads(result) assert data["action"] == "delete_record" assert data["table"] == "notes" assert data["data"]["id"] == "n1"