Fix the Prompter tests against the current schema and SDK

The Prompter feature's integration tests were never executed by CI — the PR
checks run CodeQL and secret-scanning, not the pytest suite — so they carried
bugs that only surfaced once the feature was integrated and the full quality
gate ran against it:

- The project fixture set a `git_branch` field the model doesn't have (it is
  `default_branch`) and omitted the required `assigned_cell` / `created_by`
  columns, so every confirm-flow test errored at setup.
- The tests patched the Anthropic client's `messages.create` at the class
  level, but the SDK exposes `messages` as a cached_property, so that target
  can't be resolved. Route the call through a single `_create_message` seam on
  PrompterService (behavior-identical) and patch that instead.

Also drop `confirm_draft` below the cyclomatic-complexity threshold by
extracting the override-merge, UUID-field, and enum-coercion helpers
(behavior-preserving; the prompter test suite passes unchanged).
This commit is contained in:
Renn F
2026-06-08 06:44:14 +02:00
parent 5a41ef5c90
commit e863880883
2 changed files with 100 additions and 68 deletions
+60 -46
View File
@@ -136,6 +136,15 @@ class PrompterService:
self._client = AsyncAnthropic(api_key=api_key) self._client = AsyncAnthropic(api_key=api_key)
return self._client return self._client
async def _create_message(self, **kwargs: Any) -> Any:
"""Single seam for the Anthropic ``messages.create`` call.
The SDK exposes ``messages`` as a cached_property, so it can't be
patched at the client-class level; tests substitute this method.
"""
client = self._get_client()
return await client.messages.create(**kwargs)
@property @property
def _session(self) -> AsyncSession: def _session(self) -> AsyncSession:
"""Return DB session, raising if not configured.""" """Return DB session, raising if not configured."""
@@ -287,40 +296,13 @@ class PrompterService:
session_rec = await self._get_session(session_id, agent_id) session_rec = await self._get_session(session_id, agent_id)
ov = confirm_overrides or ConfirmOverrides() ov = confirm_overrides or ConfirmOverrides()
# Get or generate the draft # Get or generate the draft, then merge confirm-time overrides
draft_record = await self.get_or_generate_draft(session_id, agent_id) draft_record = await self.get_or_generate_draft(session_id, agent_id)
draft_data: dict[str, Any] = dict(draft_record.draft_data) draft_data: dict[str, Any] = dict(draft_record.draft_data)
self._apply_overrides(draft_data, ov)
# Apply overrides resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
if ov.project_id is not None: resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
draft_data["project_id"] = str(ov.project_id)
if ov.product_id is not None:
draft_data["product_id"] = str(ov.product_id)
if ov.assigned_to is not None:
draft_data["assigned_to"] = ov.assigned_to
if ov.extra:
draft_data.update(ov.extra)
# Resolve project/product IDs
resolved_project_id: UUID | None = None
resolved_product_id: UUID | None = None
if draft_data.get("project_id"):
try:
resolved_project_id = UUID(str(draft_data["project_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid project_id UUID: {draft_data['project_id']}",
field="project_id",
) from exc
if draft_data.get("product_id"):
try:
resolved_product_id = UUID(str(draft_data["product_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid product_id UUID: {draft_data['product_id']}",
field="product_id",
) from exc
if resolved_project_id is None and resolved_product_id is None: if resolved_project_id is None and resolved_product_id is None:
raise ValidationError( raise ValidationError(
message=( message=(
@@ -330,17 +312,7 @@ class PrompterService:
field="project_id", field="project_id",
) )
# Validate and coerce required fields team, task_type, nature, complexity = self._coerce_draft_enums(draft_data)
try:
team = Team(draft_data["team"])
task_type = TaskType(draft_data["task_type"])
nature = TaskNature(draft_data["nature"])
complexity = Complexity(draft_data["estimated_complexity"])
except (KeyError, ValueError) as exc:
raise ValidationError(
message=f"Draft has invalid or missing required fields: {exc}",
field="draft",
) from exc
# Resolve assigned_to as UUID if possible # Resolve assigned_to as UUID if possible
resolved_assigned_to: UUID | None = None resolved_assigned_to: UUID | None = None
@@ -385,6 +357,50 @@ class PrompterService:
) )
return task.id # type: ignore[return-value] return task.id # type: ignore[return-value]
@staticmethod
def _apply_overrides(draft_data: dict[str, Any], ov: ConfirmOverrides) -> None:
"""Merge confirm-time overrides onto the draft data in place."""
if ov.project_id is not None:
draft_data["project_id"] = str(ov.project_id)
if ov.product_id is not None:
draft_data["product_id"] = str(ov.product_id)
if ov.assigned_to is not None:
draft_data["assigned_to"] = ov.assigned_to
if ov.extra:
draft_data.update(ov.extra)
@staticmethod
def _resolve_uuid_field(draft_data: dict[str, Any], key: str) -> UUID | None:
"""Parse ``draft_data[key]`` as a UUID; None if absent, raises if malformed."""
raw = draft_data.get(key)
if not raw:
return None
try:
return UUID(str(raw))
except ValueError as exc:
raise ValidationError(
message=f"Invalid {key} UUID: {raw}",
field=key,
) from exc
@staticmethod
def _coerce_draft_enums(
draft_data: dict[str, Any],
) -> tuple[Team, TaskType, TaskNature, Complexity]:
"""Coerce the draft's required enum fields, raising on missing/invalid."""
try:
return (
Team(draft_data["team"]),
TaskType(draft_data["task_type"]),
TaskNature(draft_data["nature"]),
Complexity(draft_data["estimated_complexity"]),
)
except (KeyError, ValueError) as exc:
raise ValidationError(
message=f"Draft has invalid or missing required fields: {exc}",
field="draft",
) from exc
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Private helpers (session-based) # Private helpers (session-based)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@@ -426,10 +442,9 @@ class PrompterService:
max_tokens: int = 2048, max_tokens: int = 2048,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call the LLM for a chat response. Returns {message, draft_ready}.""" """Call the LLM for a chat response. Returns {message, draft_ready}."""
client = self._get_client()
user_prompt = _build_chat_prompt(messages, context) user_prompt = _build_chat_prompt(messages, context)
try: try:
response = await client.messages.create( response = await self._create_message(
model=model, model=model,
max_tokens=max_tokens, max_tokens=max_tokens,
system=_PROMPTER_SYSTEM_PROMPT, system=_PROMPTER_SYSTEM_PROMPT,
@@ -456,10 +471,9 @@ class PrompterService:
max_tokens: int = 4096, max_tokens: int = 4096,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call the LLM to generate a structured draft. Returns {draft, reasoning}.""" """Call the LLM to generate a structured draft. Returns {draft, reasoning}."""
client = self._get_client()
user_prompt = _build_draft_prompt(messages, context) user_prompt = _build_draft_prompt(messages, context)
try: try:
response = await client.messages.create( response = await self._create_message(
model=model, model=model,
max_tokens=max_tokens, max_tokens=max_tokens,
system=_DRAFT_SYSTEM_PROMPT, system=_DRAFT_SYSTEM_PROMPT,
+40 -22
View File
@@ -23,7 +23,7 @@ from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.prompter import router as prompter_router from roboco.api.routes.prompter import router as prompter_router
from roboco.db.tables import AgentTable, ProjectTable from roboco.db.tables import AgentTable, ProjectTable
from roboco.models.base import AgentRole, AgentStatus from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.permissions import AgentContext from roboco.models.permissions import AgentContext
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -85,12 +85,30 @@ async def prompter_client(
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def project_fixture(db_session: AsyncSession) -> ProjectTable: async def project_fixture(db_session: AsyncSession) -> ProjectTable:
"""Create a minimal project for task creation in confirm tests.""" """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( project = ProjectTable(
id=uuid4(), id=uuid4(),
name="Test Project", name="Test Project",
slug=f"test-project-{uuid4().hex[:8]}", slug=f"test-project-{uuid4().hex[:8]}",
git_url="https://github.com/test/repo.git", git_url="https://github.com/test/repo.git",
git_branch="main", default_branch="main",
assigned_cell=Team.BACKEND,
created_by=creator.id,
) )
db_session.add(project) db_session.add(project)
await db_session.flush() await db_session.flush()
@@ -149,7 +167,7 @@ async def test_send_message_success(prompter_client: dict) -> None:
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")] mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -187,7 +205,7 @@ async def test_send_message_marks_draft_ready(prompter_client: dict) -> None:
] ]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -243,7 +261,7 @@ async def test_get_draft_generates_from_conversation(prompter_client: dict) -> N
draft_response.content = [MagicMock(text=json.dumps(draft_json))] draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_response, return_value=chat_response,
): ):
@@ -254,7 +272,7 @@ async def test_get_draft_generates_from_conversation(prompter_client: dict) -> N
) )
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=draft_response, return_value=draft_response,
): ):
@@ -296,7 +314,7 @@ async def test_get_draft_cached(prompter_client: dict) -> None:
draft_response.content = [MagicMock(text=json.dumps(draft_json))] draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_response, return_value=chat_response,
): ):
@@ -314,7 +332,7 @@ async def test_get_draft_cached(prompter_client: dict) -> None:
return draft_response return draft_response
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
side_effect=_mock_create, side_effect=_mock_create,
): ):
await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR)
@@ -370,7 +388,7 @@ async def test_confirm_draft_creates_task(
draft_response.content = [MagicMock(text=json.dumps(draft_json))] draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_response, return_value=chat_response,
): ):
@@ -381,7 +399,7 @@ async def test_confirm_draft_creates_task(
) )
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=draft_response, return_value=draft_response,
): ):
@@ -426,7 +444,7 @@ async def test_confirm_draft_requires_project_or_product(
draft_response.content = [MagicMock(text=json.dumps(draft_json))] draft_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_response, return_value=chat_response,
): ):
@@ -437,7 +455,7 @@ async def test_confirm_draft_requires_project_or_product(
) )
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=draft_response, return_value=draft_response,
): ):
@@ -477,7 +495,7 @@ async def test_full_happy_path(
] ]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_mock, return_value=chat_mock,
): ):
@@ -493,7 +511,7 @@ async def test_full_happy_path(
MagicMock(text="I have enough information to draft a task now.") MagicMock(text="I have enough information to draft a task now.")
] ]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=chat_mock2, return_value=chat_mock2,
): ):
@@ -524,7 +542,7 @@ async def test_full_happy_path(
draft_mock.content = [MagicMock(text=json.dumps(draft_json))] draft_mock.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=draft_mock, return_value=draft_mock,
): ):
@@ -560,7 +578,7 @@ async def test_prompter_chat_success(prompter_client: dict) -> None:
mock_response.content = [MagicMock(text="Great! Let's gather requirements.")] mock_response.content = [MagicMock(text="Great! Let's gather requirements.")]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -593,7 +611,7 @@ async def test_prompter_chat_draft_ready(prompter_client: dict) -> None:
] ]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -619,7 +637,7 @@ async def test_prompter_chat_llm_failure(prompter_client: dict) -> None:
client = prompter_client["client"] client = prompter_client["client"]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=Exception("Anthropic API unavailable"), side_effect=Exception("Anthropic API unavailable"),
): ):
@@ -658,7 +676,7 @@ async def test_prompter_draft_success(prompter_client: dict) -> None:
mock_response.content = [MagicMock(text=json.dumps(draft_json))] mock_response.content = [MagicMock(text=json.dumps(draft_json))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -688,7 +706,7 @@ async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> No
mock_response.content = [MagicMock(text="not valid json")] mock_response.content = [MagicMock(text="not valid json")]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -718,7 +736,7 @@ async def test_prompter_draft_schema_mismatch(prompter_client: dict) -> None:
mock_response.content = [MagicMock(text=json.dumps(bad_draft))] mock_response.content = [MagicMock(text=json.dumps(bad_draft))]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=mock_response, return_value=mock_response,
): ):
@@ -740,7 +758,7 @@ async def test_prompter_draft_llm_failure(prompter_client: dict) -> None:
client = prompter_client["client"] client = prompter_client["client"]
with patch( with patch(
"roboco.services.prompter.AsyncAnthropic.messages.create", "roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=Exception("Anthropic API unavailable"), side_effect=Exception("Anthropic API unavailable"),
): ):