Merge master (Prompter #80) into lifecycle-residual-hardening

This commit is contained in:
Renn F
2026-06-08 05:45:07 +02:00
24 changed files with 3840 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
"""Unit tests for Prompter API schemas.
Covers schema validation for both the session-based and legacy schemas.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError as PydanticValidationError
from roboco.api.schemas.prompter import (
ChatMessage,
PrompterChatRequest,
PrompterDraftTask,
PrompterMessageRequest,
PrompterSessionCreateRequest,
TaskConfirmRequest,
)
# =============================================================================
# ChatMessage
# =============================================================================
def test_chat_message_valid_roles() -> None:
for role in ("user", "assistant", "system"):
msg = ChatMessage(role=role, content="Hello")
assert msg.role == role
def test_chat_message_invalid_role() -> None:
with pytest.raises(PydanticValidationError) as exc_info:
ChatMessage(role="admin", content="Hello")
assert "role must be one of" in str(exc_info.value)
def test_chat_message_empty_content() -> None:
with pytest.raises(PydanticValidationError):
ChatMessage(role="user", content="")
# =============================================================================
# PrompterSessionCreateRequest
# =============================================================================
def test_session_create_request_defaults() -> None:
req = PrompterSessionCreateRequest()
assert req.context == {}
def test_session_create_request_with_context() -> None:
req = PrompterSessionCreateRequest(context={"team": "backend"})
assert req.context == {"team": "backend"}
# =============================================================================
# PrompterMessageRequest
# =============================================================================
def test_message_request_valid() -> None:
req = PrompterMessageRequest(content="I need a feature")
assert req.content == "I need a feature"
assert req.context == {}
def test_message_request_empty_content() -> None:
with pytest.raises(PydanticValidationError):
PrompterMessageRequest(content="")
def test_message_request_with_context() -> None:
req = PrompterMessageRequest(content="Hello", context={"key": "value"})
assert req.context["key"] == "value"
# =============================================================================
# TaskConfirmRequest
# =============================================================================
def test_task_confirm_request_all_optional() -> None:
req = TaskConfirmRequest()
assert req.project_id is None
assert req.product_id is None
assert req.assigned_to is None
assert req.overrides == {}
def test_task_confirm_request_with_project() -> None:
pid = uuid4()
req = TaskConfirmRequest(project_id=pid)
assert req.project_id == pid
# =============================================================================
# PrompterDraftTask
# =============================================================================
def test_draft_task_valid() -> None:
draft = PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
assert draft.title == "Add login page"
assert draft.source == "prompter"
assert draft.confirmed_by_human is False
def test_draft_task_title_too_long() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="x" * 201,
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_description_too_short() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="short", # <20 chars
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_empty_acceptance_criteria() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=[],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_invalid_team() -> None:
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="infra", # invalid
task_type="code",
nature="technical",
estimated_complexity="medium",
)
def test_draft_task_priority_bounds() -> None:
# Valid bounds
for p in (0, 1, 2, 3):
d = PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
priority=p,
)
assert d.priority == p
# Out of bounds
with pytest.raises(PydanticValidationError):
PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
priority=4,
)
# =============================================================================
# PrompterChatRequest (legacy)
# =============================================================================
def test_chat_request_requires_messages() -> None:
with pytest.raises(PydanticValidationError):
PrompterChatRequest(messages=[])
def test_chat_request_valid() -> None:
req = PrompterChatRequest(messages=[ChatMessage(role="user", content="Hello")])
assert len(req.messages) == 1
assert req.context == {}
+354
View File
@@ -0,0 +1,354 @@
"""Unit tests for PrompterService.
Tests the service layer logic with mocked LLM calls. Uses an in-memory
async session (via conftest fixtures) for DB-backed tests.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable
from roboco.models.base import AgentRole, AgentStatus
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import (
PrompterService,
_build_chat_prompt,
_build_draft_prompt,
_build_reasoning,
_detect_draft_ready,
_extract_text,
get_prompter_service,
)
# =============================================================================
# Pure function tests (no DB)
# =============================================================================
def test_detect_draft_ready_signals() -> None:
signals = [
"I have enough information to proceed",
"Ready to generate a draft now.",
"ready to draft the task",
"i can now draft this.",
"draft_ready=true",
"The task is draft ready",
]
for text in signals:
assert _detect_draft_ready(text), f"Expected True for: {text!r}"
def test_detect_draft_ready_negative() -> None:
not_signals = [
"Tell me more about the feature.",
"Could you clarify the acceptance criteria?",
"Let's continue the conversation.",
]
for text in not_signals:
assert not _detect_draft_ready(text), f"Expected False for: {text!r}"
def test_extract_text_with_blocks() -> None:
block1 = MagicMock()
block1.text = "Hello, "
block2 = MagicMock()
block2.text = "world!"
response = MagicMock()
response.content = [block1, block2]
result = _extract_text(response)
assert result == "Hello, \nworld!"
def test_extract_text_empty_response() -> None:
response = MagicMock()
response.content = []
assert _extract_text(response) == ""
def test_extract_text_no_text_attr() -> None:
block = MagicMock(spec=[]) # no 'text' attribute
response = MagicMock()
response.content = [block]
assert _extract_text(response) == ""
def test_build_chat_prompt_basic() -> None:
messages = [
{"role": "user", "content": "I need a feature"},
{"role": "assistant", "content": "Tell me more"},
]
prompt = _build_chat_prompt(messages, None)
assert "user: I need a feature" in prompt
assert "assistant: Tell me more" in prompt
assert "Continue the conversation" in prompt
def test_build_chat_prompt_with_context() -> None:
messages = [{"role": "user", "content": "hello"}]
prompt = _build_chat_prompt(messages, {"team": "backend"})
assert "Context:" in prompt
assert "team: backend" in prompt
def test_build_draft_prompt() -> None:
messages = [{"role": "user", "content": "I need a login page"}]
prompt = _build_draft_prompt(messages, None)
assert "valid JSON" in prompt
assert "user: I need a login page" in prompt
def test_build_reasoning() -> None:
messages = [{"role": "user", "content": "Hello"}] * 3
draft = {"title": "My Task", "team": "backend", "estimated_complexity": "medium"}
reasoning = _build_reasoning(messages, draft)
assert "My Task" in reasoning
assert "backend" in reasoning
assert "medium" in reasoning
assert "3 messages" in reasoning
# =============================================================================
# Factory
# =============================================================================
def test_get_prompter_service_no_db() -> None:
service = get_prompter_service()
assert isinstance(service, PrompterService)
assert service._db is None
def test_get_prompter_service_raises_without_db_for_session_methods() -> None:
service = get_prompter_service()
with pytest.raises(ServiceError, match="DB session"):
_ = service._session
# =============================================================================
# Stateless chat / draft (with mocked LLM)
# =============================================================================
@pytest.mark.asyncio
async def test_chat_success_with_mock_llm() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Great, let's continue!")]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}]
)
assert result["message"] == "Great, let's continue!"
assert result["draft_ready"] is False
@pytest.mark.asyncio
async def test_chat_draft_ready_signal() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [
MagicMock(text="I have enough information. Ready to draft.")
]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}]
)
assert result["draft_ready"] is True
@pytest.mark.asyncio
async def test_chat_raises_on_empty_response() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [] # Empty content blocks
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM returned empty content"):
await service.chat(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_chat_raises_on_llm_error() -> None:
service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(
side_effect=Exception("API unavailable")
)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM chat failed"):
await service.chat(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_draft_success_with_mock_llm() -> None:
service = get_prompter_service()
draft_data = {
"title": "Add login",
"description": "Implement login functionality with JWT tokens",
"acceptance_criteria": ["User can log in"],
"team": "backend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
}
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(draft_data))]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
result = await service.draft(
messages=[{"role": "user", "content": "I need a login feature"}]
)
assert result["draft"]["title"] == "Add login"
assert result["draft"]["source"] == "prompter"
assert result["draft"]["confirmed_by_human"] is False
assert "reasoning" in result
@pytest.mark.asyncio
async def test_draft_raises_on_invalid_json() -> None:
service = get_prompter_service()
mock_response = MagicMock()
mock_response.content = [MagicMock(text="Not JSON at all")]
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_client
with pytest.raises(ValidationError, match="not valid JSON"):
await service.draft(messages=[{"role": "user", "content": "Hello"}])
@pytest.mark.asyncio
async def test_draft_raises_on_llm_error() -> None:
service = get_prompter_service()
with patch.object(service, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.messages.create = AsyncMock(
side_effect=Exception("API unavailable")
)
mock_get_client.return_value = mock_client
with pytest.raises(ServiceError, match="LLM draft generation failed"):
await service.draft(messages=[{"role": "user", "content": "Hello"}])
# =============================================================================
# API key validation
# =============================================================================
def test_get_client_raises_without_api_key() -> None:
service = get_prompter_service()
service._client = None # Force fresh init
with patch("roboco.services.prompter.settings") as mock_settings:
mock_settings.anthropic_api_key = None
with pytest.raises(ServiceError, match="Anthropic API key not configured"):
service._get_client()
# =============================================================================
# Session-based: create_session (DB-backed via conftest)
# =============================================================================
@pytest.mark.asyncio
async def test_create_session_db(db_session: Any) -> None:
"""create_session persists a PrompterSessionTable row."""
service = get_prompter_service(db=db_session)
agent = AgentTable(
id=uuid4(),
name="TestAgent",
slug=f"test-{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()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
assert session.id is not None
assert session.status == "active"
assert session.agent_id == agent.id
@pytest.mark.asyncio
async def test_get_session_not_found(db_session: Any) -> None:
"""_get_session raises NotFoundError for unknown session ID."""
service = get_prompter_service(db=db_session)
with pytest.raises(NotFoundError):
await service._get_session(uuid4(), uuid4())
@pytest.mark.asyncio
async def test_get_draft_empty_session_raises(db_session: Any) -> None:
"""get_or_generate_draft raises ValidationError if no messages exist."""
service = get_prompter_service(db=db_session)
agent = AgentTable(
id=uuid4(),
name="TestAgent",
slug=f"test-{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()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
with pytest.raises(ValidationError, match="empty conversation"):
await service.get_or_generate_draft(
session_id=session.id, # type: ignore[arg-type]
agent_id=agent.id, # type: ignore[arg-type]
)