mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix: open findings cleanup (#122)
* refactor(usage): remove the unconsumed per-agent USAGE_UPDATE event USAGE_UPDATE was published per active agent each sweep, bridged, and broadcast to /ws/system, but no panel client ever consumed it — the dashboard reads only the aggregate USAGE_SNAPSHOT. Every emission was wasted event-bus and WebSocket traffic. Drop the UsageUpdate payload, publish_usage_update and its throttle, the EventType member, and the bridge subscription. Keep USAGE_SNAPSHOT, which already carries the per-agent breakdown, so no live data is lost. * refactor(prompter): remove the legacy local-LLM HTTP endpoints The panel uses only the live SDK-intake path (/prompter/live/*); the legacy /prompter/chat, /draft and /sessions/* endpoints — backed by the local Ollama LLM with hardcoded prompts — had no remaining caller. Remove the router, its mount in app.py, and its integration test. The live router and the shared draft-confirmation service are untouched. * refactor(prompter): drop the dead legacy local-LLM service + schemas With the legacy HTTP endpoints gone, the local-LLM chat/draft/session methods, their prompt constants, the ConfirmOverrides/TurnResult dataclasses, and the entire prompter schema module had no production caller (only their own tests). Remove them, keeping the live-intake path: create_task_from_draft / confirm_live_draft, the enum/priority/team coercion, and the pure description/readiness helpers. * refactor(agents): stop granting the Task sub-agent tool to roles Every agent role was granted the built-in Task tool, but no role prompt or workflow uses it and there are no custom sub-agent definitions — so a Task call only spawns a context-blind generic sub-agent that burns budget (ToolSearch, the comment's stated use, is MCP-only and not callable in agent containers). Drop Task from all three grant points in lockstep: the --tools spawn flag and both _ROLE_BUILTIN_TOOLS maps (system-prompt + briefing layers), with a regression guard added to each layer's test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,862 +0,0 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user