"""Prompter API route integration tests. Covers both the new session-based endpoints: POST /sessions, POST /sessions/{id}/messages, GET /sessions/{id}/draft, POST /sessions/{id}/confirm And the legacy stateless endpoints: POST /chat, POST /draft """ from __future__ import annotations import json from http import HTTPStatus from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from roboco.api.deps import get_agent_context, get_db from roboco.api.routes.prompter import router as prompter_router from roboco.db.tables import AgentTable, ProjectTable from roboco.models.base import AgentRole, AgentStatus, Team from roboco.models.permissions import AgentContext from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine if TYPE_CHECKING: from collections.abc import AsyncIterator from sqlalchemy.ext.asyncio import AsyncSession # Expected message counts in multi-turn tests _SINGLE_TURN_MSGS = 2 # 1 user + 1 assistant _DOUBLE_TURN_MSGS = 4 # 2 user + 2 assistant # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def prompter_client( db_session: AsyncSession, ) -> AsyncIterator[dict[str, Any]]: agent_id = uuid4() agent = AgentTable( id=agent_id, name="DevAgent", slug=f"dev-agent-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) db_session.add(agent) await db_session.flush() app = FastAPI() app.include_router(prompter_router, prefix="/api/prompter") async def _override_db() -> AsyncIterator[AsyncSession]: yield db_session async def _override_agent() -> AgentContext: return AgentContext( agent_id=agent_id, role=AgentRole.DEVELOPER, team=None, ) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield {"client": client, "agent": agent, "db": db_session} app.dependency_overrides.clear() @pytest_asyncio.fixture async def project_fixture(db_session: AsyncSession) -> ProjectTable: """Create a minimal project for task creation in confirm tests.""" creator = AgentTable( id=uuid4(), name="ProjectCreator", slug=f"proj-creator-{uuid4().hex[:8]}", role=AgentRole.MAIN_PM, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="pm", capabilities=[], permissions={}, metrics={}, ) db_session.add(creator) await db_session.flush() project = ProjectTable( id=uuid4(), name="Test Project", slug=f"test-project-{uuid4().hex[:8]}", git_url="https://github.com/test/repo.git", default_branch="main", assigned_cell=Team.BACKEND, created_by=creator.id, ) db_session.add(project) await db_session.flush() return project _HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"} @pytest_asyncio.fixture async def cross_request_client( _test_database_url: str, ) -> AsyncIterator[dict[str, Any]]: """Client whose DB dependency yields a fresh, NON-auto-committing session per request. This is the boundary the shared-session ``prompter_client`` fixture can't exercise: here a write is only visible to the next request if the route committed it explicitly. The seed agent is committed up front so both requests can resolve it. """ engine = create_async_engine(_test_database_url, future=True, pool_pre_ping=True) maker = async_sessionmaker(bind=engine, expire_on_commit=False) agent_id = uuid4() async with maker() as seed: seed.add( AgentTable( id=agent_id, name="XReqAgent", slug=f"xreq-agent-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=[], permissions={}, metrics={}, ) ) await seed.commit() app = FastAPI() app.include_router(prompter_router, prefix="/api/prompter") async def _override_db() -> AsyncIterator[AsyncSession]: # A fresh session per request that does NOT commit on teardown, so # persistence depends solely on the route's explicit commit. async with maker() as session: yield session async def _override_agent() -> AgentContext: return AgentContext(agent_id=agent_id, role=AgentRole.DEVELOPER, team=None) app.dependency_overrides[get_db] = _override_db app.dependency_overrides[get_agent_context] = _override_agent transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield {"client": client, "agent_id": agent_id} app.dependency_overrides.clear() await engine.dispose() @pytest.mark.asyncio async def test_session_persists_across_requests(cross_request_client: dict) -> None: """A created session must survive into the next request's own DB session. Regression for the production 404: the create returned 201 but the session write was never committed, so the immediately-following /messages call could not find it. Without the route's explicit commit, this is a 404. """ client = cross_request_client["client"] create = await client.post("/api/prompter/sessions", json={}, headers=_HDR) assert create.status_code == HTTPStatus.CREATED session_id = create.json()["id"] reply = ( 'ack\n```roboco-meta\n{"covered": [], "ready": false, "scale": "single"}\n```' ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=reply, ): msg = await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "hello"}, headers=_HDR, ) assert msg.status_code == HTTPStatus.OK, msg.json() assert len(msg.json()["messages"]) == _SINGLE_TURN_MSGS # ============================================================================= # Session-based endpoint tests # ============================================================================= @pytest.mark.asyncio async def test_create_session_success(prompter_client: dict) -> None: """POST /sessions creates a new session linked to the agent.""" client = prompter_client["client"] response = await client.post( "/api/prompter/sessions", json={}, headers=_HDR, ) assert response.status_code == HTTPStatus.CREATED body = response.json() assert "id" in body assert body["status"] == "active" assert "agent_id" in body assert "created_at" in body @pytest.mark.asyncio async def test_send_message_success(prompter_client: dict) -> None: """POST /sessions/{id}/messages appends user+assistant messages.""" client = prompter_client["client"] # Create session session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] mock_response = "Great! Let's gather requirements." with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a new feature"}, headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() messages = body["messages"] assert len(messages) == _SINGLE_TURN_MSGS roles = [m["role"] for m in messages] assert "user" in roles assert "assistant" in roles assert messages[-1]["content"] == "Great! Let's gather requirements." assert body["draft_ready"] is False @pytest.mark.asyncio async def test_send_message_marks_draft_ready(prompter_client: dict) -> None: """A ready roboco-meta control block flips draft_ready and session status.""" client = prompter_client["client"] session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] mock_response = ( "Understood — I have what I need.\n\n" '```roboco-meta\n{"covered": ["objective", "scope", "surface", ' '"acceptance"], "ready": true, "scale": "single"}\n```' ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "Add a login page with MFA support"}, headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() assert len(body["messages"]) == _SINGLE_TURN_MSGS assert body["draft_ready"] is True assert body["scale"] == "single" # The control block must not leak into the persisted assistant message. assert "roboco-meta" not in body["messages"][-1]["content"] @pytest.mark.asyncio async def test_send_message_not_found(prompter_client: dict) -> None: """POST /sessions/{id}/messages with unknown session → 404.""" client = prompter_client["client"] response = await client.post( f"/api/prompter/sessions/{uuid4()}/messages", json={"content": "Hello"}, headers=_HDR, ) assert response.status_code == HTTPStatus.NOT_FOUND @pytest.mark.asyncio async def test_get_draft_generates_from_conversation(prompter_client: dict) -> None: """GET /sessions/{id}/draft generates a draft via LLM.""" client = prompter_client["client"] session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] draft_json = { "title": "Add login page", "description": "Implement a secure login page with email and password", "acceptance_criteria": [ "User can enter email and password", "Invalid credentials show error message", ], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "medium", "priority": 2, } chat_response = "Tell me more about the requirements." draft_response = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a login page"}, headers=_HDR, ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=draft_response, ): response = await client.get( f"/api/prompter/sessions/{session_id}/draft", headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() assert body["draft"]["title"] == "Add login page" assert body["draft"]["source"] == "prompter" assert body["confirmed_at"] is None assert body["draft"]["confirmed_by_human"] is False assert body["session_id"] == session_id @pytest.mark.asyncio async def test_get_draft_cached(prompter_client: dict) -> None: """GET /sessions/{id}/draft returns the cached draft on subsequent calls.""" client = prompter_client["client"] session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] draft_json = { "title": "Add login page", "description": "Implement a secure login page with email and password", "acceptance_criteria": ["User can enter credentials"], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "medium", "priority": 2, } chat_response = "Got it." draft_response = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a login page"}, headers=_HDR, ) call_count = 0 async def _mock_create(**_kwargs: Any) -> str: nonlocal call_count call_count += 1 return draft_response with patch( "roboco.services.prompter.PrompterService._create_message", side_effect=_mock_create, ): await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) second_response = await client.get( f"/api/prompter/sessions/{session_id}/draft", headers=_HDR ) assert second_response.status_code == HTTPStatus.OK # LLM should only be called once (draft is cached) assert call_count == 1 @pytest.mark.asyncio async def test_get_draft_empty_session_returns_400(prompter_client: dict) -> None: """GET /sessions/{id}/draft with no messages → 400.""" client = prompter_client["client"] session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] response = await client.get( f"/api/prompter/sessions/{session_id}/draft", headers=_HDR, ) assert response.status_code == HTTPStatus.BAD_REQUEST @pytest.mark.asyncio async def test_confirm_draft_creates_task( prompter_client: dict, project_fixture: ProjectTable ) -> None: """POST /sessions/{id}/confirm validates draft and creates a real task.""" client = prompter_client["client"] project_id = str(project_fixture.id) session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] draft_json = { "title": "Add login page", "description": "Implement a secure login page with email and password", "acceptance_criteria": ["User can enter credentials"], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "medium", "priority": 2, } chat_response = "Got it." draft_response = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a login page"}, headers=_HDR, ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=draft_response, ): await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) confirm_response = await client.post( f"/api/prompter/sessions/{session_id}/confirm", json={"project_id": project_id}, headers=_HDR, ) assert confirm_response.status_code == HTTPStatus.CREATED body = confirm_response.json() assert "task_id" in body assert body["task_id"] is not None @pytest.mark.asyncio async def test_confirm_draft_requires_project_or_product( prompter_client: dict, ) -> None: """POST /sessions/{id}/confirm without project_id/product_id → 400.""" client = prompter_client["client"] session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) session_id = session_resp.json()["id"] draft_json = { "title": "Add login page", "description": "Implement a secure login page with email and password", "acceptance_criteria": ["User can enter credentials"], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "medium", "priority": 2, } chat_response = "Got it." draft_response = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a login page"}, headers=_HDR, ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=draft_response, ): await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) confirm_response = await client.post( f"/api/prompter/sessions/{session_id}/confirm", json={}, headers=_HDR, ) assert confirm_response.status_code == HTTPStatus.BAD_REQUEST # ============================================================================= # Full happy path integration test # ============================================================================= @pytest.mark.asyncio async def test_full_happy_path( prompter_client: dict, project_fixture: ProjectTable ) -> None: """Full happy path: create session → send messages → get draft → confirm task.""" client = prompter_client["client"] project_id = str(project_fixture.id) # Step 1: Create session step1 = await client.post("/api/prompter/sessions", json={}, headers=_HDR) assert step1.status_code == HTTPStatus.CREATED session_id = step1.json()["id"] # Step 2: Send messages chat_mock = "Please describe the acceptance criteria for this feature." with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_mock, ): step2a = await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "I need a dark mode toggle for the UI"}, headers=_HDR, ) assert step2a.status_code == HTTPStatus.OK chat_mock2 = "I have enough information to draft a task now." with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_mock2, ): step2b = await client.post( f"/api/prompter/sessions/{session_id}/messages", json={"content": "Preference is persisted across sessions"}, headers=_HDR, ) assert step2b.status_code == HTTPStatus.OK messages = step2b.json()["messages"] assert len(messages) == _DOUBLE_TURN_MSGS # Step 3: Get draft draft_json = { "title": "Add dark mode toggle", "description": "Implement a dark mode toggle so users can switch themes", "acceptance_criteria": [ "User can toggle light/dark mode", "Preference is persisted across sessions", ], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "low", "priority": 2, } draft_mock = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=draft_mock, ): step3 = await client.get( f"/api/prompter/sessions/{session_id}/draft", headers=_HDR ) assert step3.status_code == HTTPStatus.OK draft_body = step3.json() assert draft_body["draft"]["title"] == "Add dark mode toggle" # Step 4: Confirm draft → creates task step4 = await client.post( f"/api/prompter/sessions/{session_id}/confirm", json={"project_id": project_id}, headers=_HDR, ) assert step4.status_code == HTTPStatus.CREATED task_body = step4.json() assert "task_id" in task_body assert task_body["task_id"] is not None # ============================================================================= # Legacy stateless endpoint tests (backward compatibility) # ============================================================================= @pytest.mark.asyncio async def test_prompter_chat_success(prompter_client: dict) -> None: client = prompter_client["client"] mock_response = "Great! Let's gather requirements." with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( "/api/prompter/chat", json={ "messages": [{"role": "user", "content": "I need a new feature"}], }, headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() assert body["message"] == "Great! Let's gather requirements." assert body["draft_ready"] is False @pytest.mark.asyncio async def test_prompter_chat_draft_ready(prompter_client: dict) -> None: client = prompter_client["client"] mock_response = ( "Understood.\n\n" '```roboco-meta\n{"covered": ["objective", "scope", "surface", ' '"acceptance"], "ready": true, "scale": "single"}\n```' ) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( "/api/prompter/chat", json={ "messages": [ {"role": "user", "content": "I need a new feature"}, {"role": "assistant", "content": "Tell me more"}, {"role": "user", "content": "Add a login page"}, ], }, headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() assert body["draft_ready"] is True @pytest.mark.asyncio async def test_prompter_chat_llm_failure(prompter_client: dict) -> None: client = prompter_client["client"] with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, side_effect=Exception("Anthropic API unavailable"), ): response = await client.post( "/api/prompter/chat", json={ "messages": [{"role": "user", "content": "Hello"}], }, headers=_HDR, ) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR body = response.json() assert "LLM chat failed" in body["detail"]["message"] @pytest.mark.asyncio async def test_prompter_draft_success(prompter_client: dict) -> None: client = prompter_client["client"] draft_json = { "title": "Add login page", "description": "Implement a secure login page with email and password", "acceptance_criteria": [ "User can enter email and password", "Invalid credentials show error message", ], "team": "frontend", "task_type": "code", "nature": "technical", "estimated_complexity": "medium", "priority": 2, } mock_response = json.dumps(draft_json) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( "/api/prompter/draft", json={ "messages": [ {"role": "user", "content": "I need a login page"}, ], }, headers=_HDR, ) assert response.status_code == HTTPStatus.OK body = response.json() assert body["draft"]["title"] == "Add login page" assert body["draft"]["source"] == "prompter" assert body["draft"]["confirmed_by_human"] is False assert "reasoning" in body @pytest.mark.asyncio async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> None: client = prompter_client["client"] mock_response = "not valid json" with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( "/api/prompter/draft", json={ "messages": [{"role": "user", "content": "Hello"}], }, headers=_HDR, ) assert response.status_code == HTTPStatus.BAD_REQUEST body = response.json() assert "Draft response was not valid JSON" in body["detail"]["message"] @pytest.mark.asyncio async def test_prompter_draft_schema_mismatch(prompter_client: dict) -> None: client = prompter_client["client"] bad_draft = { "title": "x", "description": "too short", } mock_response = json.dumps(bad_draft) with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): response = await client.post( "/api/prompter/draft", json={ "messages": [{"role": "user", "content": "Hello"}], }, headers=_HDR, ) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR body = response.json() assert "draft_schema_error" in body["detail"]["error"] @pytest.mark.asyncio async def test_prompter_draft_llm_failure(prompter_client: dict) -> None: client = prompter_client["client"] with patch( "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, side_effect=Exception("Anthropic API unavailable"), ): response = await client.post( "/api/prompter/draft", json={ "messages": [{"role": "user", "content": "Hello"}], }, headers=_HDR, ) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR body = response.json() assert "LLM draft generation failed" in body["detail"]["message"] @pytest.mark.asyncio async def test_prompter_chat_empty_messages(prompter_client: dict) -> None: client = prompter_client["client"] response = await client.post( "/api/prompter/chat", json={"messages": []}, headers=_HDR, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_prompter_chat_invalid_role(prompter_client: dict) -> None: client = prompter_client["client"] response = await client.post( "/api/prompter/chat", json={"messages": [{"role": "invalid", "content": "hi"}]}, headers=_HDR, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_prompter_draft_empty_messages(prompter_client: dict) -> None: client = prompter_client["client"] response = await client.post( "/api/prompter/draft", json={"messages": []}, headers=_HDR, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY