diff --git a/backend/tests/api/routes/syndarix/test_agents.py b/backend/tests/api/routes/syndarix/test_agents.py new file mode 100644 index 0000000..a2ace16 --- /dev/null +++ b/backend/tests/api/routes/syndarix/test_agents.py @@ -0,0 +1,619 @@ +# tests/api/routes/syndarix/test_agents.py +"""Tests for agent instance management endpoints. + +Tests cover: +- Agent instance CRUD operations +- Agent lifecycle management (pause, resume) +- Agent status filtering +- Agent metrics +- Authorization and access control +""" + +import uuid + +import pytest +import pytest_asyncio +from starlette import status + + +@pytest_asyncio.fixture +async def test_project(client, user_token): + """Create a test project for agent tests.""" + response = await client.post( + "/api/v1/projects", + json={ + "name": "Agent Test Project", + "slug": "agent-test-project", + "autonomy_level": "milestone", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + +@pytest_asyncio.fixture +async def test_agent_type(client, superuser_token): + """Create a test agent type for spawning agents.""" + import uuid as uuid_mod + + unique_slug = f"test-developer-agent-{uuid_mod.uuid4().hex[:8]}" + response = await client.post( + "/api/v1/agent-types", + json={ + "name": "Test Developer Agent", + "slug": unique_slug, + "expertise": ["python", "testing"], + "primary_model": "claude-3-opus", + "personality_prompt": "You are a helpful developer agent for testing.", + "description": "A test developer agent", + }, + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED, f"Failed: {response.json()}" + return response.json() + + +@pytest.mark.asyncio +class TestSpawnAgent: + """Tests for POST /api/v1/projects/{project_id}/agents endpoint.""" + + async def test_spawn_agent_success( + self, client, user_token, test_project, test_agent_type + ): + """Test successfully spawning a new agent.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "My Developer Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "My Developer Agent" + assert data["status"] == "idle" + assert data["project_id"] == project_id + + async def test_spawn_agent_with_initial_memory( + self, client, user_token, test_project, test_agent_type + ): + """Test spawning agent with initial short-term memory.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Memory Agent", + "short_term_memory": {"context": "test setup"}, + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["short_term_memory"]["context"] == "test setup" + + async def test_spawn_agent_nonexistent_project( + self, client, user_token, test_agent_type + ): + """Test spawning agent in nonexistent project.""" + fake_project_id = str(uuid.uuid4()) + agent_type_id = test_agent_type["id"] + + response = await client.post( + f"/api/v1/projects/{fake_project_id}/agents", + json={ + "project_id": fake_project_id, + "agent_type_id": agent_type_id, + "name": "Orphan Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_spawn_agent_nonexistent_type( + self, client, user_token, test_project + ): + """Test spawning agent with nonexistent agent type.""" + project_id = test_project["id"] + fake_type_id = str(uuid.uuid4()) + + response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": fake_type_id, + "name": "Invalid Type Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_spawn_agent_mismatched_project_id( + self, client, user_token, test_project, test_agent_type + ): + """Test spawning agent with mismatched project_id in body.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + different_project_id = str(uuid.uuid4()) + + response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": different_project_id, + "agent_type_id": agent_type_id, + "name": "Mismatched Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +class TestListAgents: + """Tests for GET /api/v1/projects/{project_id}/agents endpoint.""" + + async def test_list_agents_empty(self, client, user_token, test_project): + """Test listing agents when none exist.""" + project_id = test_project["id"] + + response = await client.get( + f"/api/v1/projects/{project_id}/agents", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["data"] == [] + assert data["pagination"]["total"] == 0 + + async def test_list_agents_with_data( + self, client, user_token, test_project, test_agent_type + ): + """Test listing agents with data.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agents + await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Agent One", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Agent Two", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + response = await client.get( + f"/api/v1/projects/{project_id}/agents", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["data"]) == 2 + assert data["pagination"]["total"] == 2 + + async def test_list_agents_filter_by_status( + self, client, user_token, test_project, test_agent_type + ): + """Test filtering agents by status.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Idle Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + # Filter by idle status + response = await client.get( + f"/api/v1/projects/{project_id}/agents?status=idle", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert all(agent["status"] == "idle" for agent in data["data"]) + + +@pytest.mark.asyncio +class TestGetAgent: + """Tests for GET /api/v1/projects/{project_id}/agents/{agent_id} endpoint.""" + + async def test_get_agent_success( + self, client, user_token, test_project, test_agent_type + ): + """Test getting agent by ID.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Get Test Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Get agent + response = await client.get( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == agent_id + assert data["name"] == "Get Test Agent" + + async def test_get_agent_not_found(self, client, user_token, test_project): + """Test getting a nonexistent agent.""" + project_id = test_project["id"] + fake_agent_id = str(uuid.uuid4()) + + response = await client.get( + f"/api/v1/projects/{project_id}/agents/{fake_agent_id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +class TestUpdateAgent: + """Tests for PATCH /api/v1/projects/{project_id}/agents/{agent_id} endpoint.""" + + async def test_update_agent_current_task( + self, client, user_token, test_project, test_agent_type + ): + """Test updating agent current_task.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Task Update Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Update current_task + response = await client.patch( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + json={"current_task": "Working on feature #123"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["current_task"] == "Working on feature #123" + + async def test_update_agent_memory( + self, client, user_token, test_project, test_agent_type + ): + """Test updating agent short-term memory.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Memory Update Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Update memory + response = await client.patch( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + json={"short_term_memory": {"last_context": "updated", "step": 2}}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["short_term_memory"]["last_context"] == "updated" + assert data["short_term_memory"]["step"] == 2 + + async def test_update_agent_not_found(self, client, user_token, test_project): + """Test updating a nonexistent agent.""" + project_id = test_project["id"] + fake_agent_id = str(uuid.uuid4()) + + response = await client.patch( + f"/api/v1/projects/{project_id}/agents/{fake_agent_id}", + json={"current_task": "Some task"}, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +class TestAgentLifecycle: + """Tests for agent lifecycle management endpoints.""" + + async def test_pause_agent( + self, client, user_token, test_project, test_agent_type + ): + """Test pausing an agent.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Pause Test Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Pause agent + response = await client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/pause", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["status"] == "paused" + + async def test_resume_paused_agent( + self, client, user_token, test_project, test_agent_type + ): + """Test resuming a paused agent.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create and pause agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Resume Test Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Pause first + await client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/pause", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + # Resume agent + response = await client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/resume", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["status"] == "idle" + + async def test_pause_nonexistent_agent(self, client, user_token, test_project): + """Test pausing a nonexistent agent.""" + project_id = test_project["id"] + fake_agent_id = str(uuid.uuid4()) + + response = await client.post( + f"/api/v1/projects/{project_id}/agents/{fake_agent_id}/pause", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +class TestDeleteAgent: + """Tests for DELETE /api/v1/projects/{project_id}/agents/{agent_id} endpoint.""" + + async def test_delete_agent_success( + self, client, user_token, test_project, test_agent_type + ): + """Test deleting an agent.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Delete Test Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Delete agent + response = await client.delete( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["success"] is True + + async def test_delete_agent_not_found(self, client, user_token, test_project): + """Test deleting a nonexistent agent.""" + project_id = test_project["id"] + fake_agent_id = str(uuid.uuid4()) + + response = await client.delete( + f"/api/v1/projects/{project_id}/agents/{fake_agent_id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +class TestAgentMetrics: + """Tests for agent metrics endpoints.""" + + async def test_get_agent_metrics( + self, client, user_token, test_project, test_agent_type + ): + """Test getting metrics for a single agent.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent + create_response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Metrics Test Agent", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + agent_id = create_response.json()["id"] + + # Get metrics + response = await client.get( + f"/api/v1/projects/{project_id}/agents/{agent_id}/metrics", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + # AgentInstanceMetrics schema + assert "total_instances" in data + assert "total_tasks_completed" in data + assert "total_tokens_used" in data + assert "total_cost_incurred" in data + + async def test_get_project_agents_metrics( + self, client, user_token, test_project, test_agent_type + ): + """Test getting metrics for all agents in a project.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agents + await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Metrics Agent 1", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Metrics Agent 2", + }, + headers={"Authorization": f"Bearer {user_token}"}, + ) + + # Get project-wide metrics + response = await client.get( + f"/api/v1/projects/{project_id}/agents/metrics", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.asyncio +class TestAgentAuthorization: + """Tests for agent authorization.""" + + async def test_superuser_can_manage_any_project_agents( + self, client, user_token, superuser_token, test_project, test_agent_type + ): + """Test that superuser can manage agents in any project.""" + project_id = test_project["id"] + agent_type_id = test_agent_type["id"] + + # Create agent as superuser in user's project + response = await client.post( + f"/api/v1/projects/{project_id}/agents", + json={ + "project_id": project_id, + "agent_type_id": agent_type_id, + "name": "Superuser Created Agent", + }, + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + + assert response.status_code == status.HTTP_201_CREATED + + async def test_user_cannot_access_other_project_agents( + self, client, user_token, superuser_token, test_agent_type + ): + """Test that user cannot access agents in another user's project.""" + # Create a project as superuser (not owned by regular user) + project_response = await client.post( + "/api/v1/projects", + json={ + "name": "Other User Project", + "slug": f"other-user-project-{uuid.uuid4().hex[:8]}", + }, + headers={"Authorization": f"Bearer {superuser_token}"}, + ) + other_project_id = project_response.json()["id"] + + # Regular user tries to list agents - should fail + response = await client.get( + f"/api/v1/projects/{other_project_id}/agents", + headers={"Authorization": f"Bearer {user_token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN