From e86388088359a9642b2a93a77afd4cab6b1fd6a8 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 8 Jun 2026 06:44:14 +0200 Subject: [PATCH] Fix the Prompter tests against the current schema and SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- roboco/services/prompter.py | 106 ++++++++++++---------- tests/integration/test_prompter_routes.py | 62 ++++++++----- 2 files changed, 100 insertions(+), 68 deletions(-) diff --git a/roboco/services/prompter.py b/roboco/services/prompter.py index 90d3c678..24c8ffff 100644 --- a/roboco/services/prompter.py +++ b/roboco/services/prompter.py @@ -136,6 +136,15 @@ class PrompterService: self._client = AsyncAnthropic(api_key=api_key) 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 def _session(self) -> AsyncSession: """Return DB session, raising if not configured.""" @@ -287,40 +296,13 @@ class PrompterService: session_rec = await self._get_session(session_id, agent_id) 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_data: dict[str, Any] = dict(draft_record.draft_data) + self._apply_overrides(draft_data, ov) - # Apply overrides - 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) - - # 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 - + resolved_project_id = self._resolve_uuid_field(draft_data, "project_id") + resolved_product_id = self._resolve_uuid_field(draft_data, "product_id") if resolved_project_id is None and resolved_product_id is None: raise ValidationError( message=( @@ -330,17 +312,7 @@ class PrompterService: field="project_id", ) - # Validate and coerce required fields - 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 + team, task_type, nature, complexity = self._coerce_draft_enums(draft_data) # Resolve assigned_to as UUID if possible resolved_assigned_to: UUID | None = None @@ -385,6 +357,50 @@ class PrompterService: ) 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) # ----------------------------------------------------------------------- @@ -426,10 +442,9 @@ class PrompterService: max_tokens: int = 2048, ) -> dict[str, Any]: """Call the LLM for a chat response. Returns {message, draft_ready}.""" - client = self._get_client() user_prompt = _build_chat_prompt(messages, context) try: - response = await client.messages.create( + response = await self._create_message( model=model, max_tokens=max_tokens, system=_PROMPTER_SYSTEM_PROMPT, @@ -456,10 +471,9 @@ class PrompterService: max_tokens: int = 4096, ) -> dict[str, Any]: """Call the LLM to generate a structured draft. Returns {draft, reasoning}.""" - client = self._get_client() user_prompt = _build_draft_prompt(messages, context) try: - response = await client.messages.create( + response = await self._create_message( model=model, max_tokens=max_tokens, system=_DRAFT_SYSTEM_PROMPT, diff --git a/tests/integration/test_prompter_routes.py b/tests/integration/test_prompter_routes.py index 42d181f0..12897838 100644 --- a/tests/integration/test_prompter_routes.py +++ b/tests/integration/test_prompter_routes.py @@ -23,7 +23,7 @@ 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 +from roboco.models.base import AgentRole, AgentStatus, Team from roboco.models.permissions import AgentContext if TYPE_CHECKING: @@ -85,12 +85,30 @@ async def prompter_client( @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", - git_branch="main", + default_branch="main", + assigned_cell=Team.BACKEND, + created_by=creator.id, ) db_session.add(project) 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.")] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): @@ -187,7 +205,7 @@ async def test_send_message_marks_draft_ready(prompter_client: dict) -> None: ] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): @@ -254,7 +272,7 @@ async def test_get_draft_generates_from_conversation(prompter_client: dict) -> N ) with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): @@ -314,7 +332,7 @@ async def test_get_draft_cached(prompter_client: dict) -> None: return draft_response with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", side_effect=_mock_create, ): 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): @@ -381,7 +399,7 @@ async def test_confirm_draft_creates_task( ) with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_response, ): @@ -437,7 +455,7 @@ async def test_confirm_draft_requires_project_or_product( ) with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=draft_response, ): @@ -477,7 +495,7 @@ async def test_full_happy_path( ] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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.") ] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=chat_mock2, ): @@ -524,7 +542,7 @@ async def test_full_happy_path( draft_mock.content = [MagicMock(text=json.dumps(draft_json))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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.")] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): @@ -593,7 +611,7 @@ async def test_prompter_chat_draft_ready(prompter_client: dict) -> None: ] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): @@ -619,7 +637,7 @@ async def test_prompter_chat_llm_failure(prompter_client: dict) -> None: client = prompter_client["client"] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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")] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, 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))] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, return_value=mock_response, ): @@ -740,7 +758,7 @@ async def test_prompter_draft_llm_failure(prompter_client: dict) -> None: client = prompter_client["client"] with patch( - "roboco.services.prompter.AsyncAnthropic.messages.create", + "roboco.services.prompter.PrompterService._create_message", new_callable=AsyncMock, side_effect=Exception("Anthropic API unavailable"), ):