Files
roboco/roboco/agents/qa.py
T
2025-12-12 18:08:06 +01:00

579 lines
18 KiB
Python

"""
QA Agent
Implementation of the QA workflow from the blueprint.
Handles review lifecycle:
MONITOR → RECEIVE → UNDERSTAND → TEST → VERDICT → DOCUMENT → RETURN
"""
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from uuid import UUID
import structlog
from roboco.agents.base import Agent, AgentConfig
from roboco.models import AgentRole, TaskStatus, Team
logger = structlog.get_logger()
class QATaskPhase(str, Enum):
"""Phases of the QA lifecycle."""
MONITOR = "monitor"
RECEIVE = "receive"
UNDERSTAND = "understand"
TEST = "test"
VERDICT = "verdict"
DOCUMENT = "document"
RETURN = "return"
class TestResult(str, Enum):
"""Test result outcomes."""
PASS = "pass"
FAIL = "fail"
BLOCKED = "blocked"
@dataclass
class TestCase:
"""A single test case."""
name: str
description: str
steps: list[str]
expected: str
result: TestResult | None = None
actual: str | None = None
notes: str | None = None
@dataclass
class ReviewContext:
"""Context for the current review being conducted."""
task_id: UUID
title: str
phase: QATaskPhase = QATaskPhase.RECEIVE
test_cases: list[TestCase] = field(default_factory=list)
current_test: int = 0
findings: list[str] = field(default_factory=list)
verdict: TestResult | None = None
started_at: datetime = field(default_factory=datetime.now(UTC))
notes: list[str] = field(default_factory=list)
class QAAgent(Agent):
"""
QA agent that follows the QA Lifecycle.
Workflow:
1. MONITOR - Watch cell channel, track tasks approaching completion
2. RECEIVE - Dev flags ready, claim review task
3. UNDERSTAND - Read requirements, dev notes, commits
4. TEST - Execute test scenarios, edge cases
5. VERDICT - PASS or FAIL with clear feedback
6. DOCUMENT - Add QA notes, test coverage
7. RETURN - Back to monitoring
"""
def __init__(self, config: AgentConfig) -> None:
"""Initialize QA agent."""
super().__init__(config)
self._review_context: ReviewContext | None = None
self._cell_channel_id: UUID | None = None
self._pending_reviews: list[UUID] = []
@property
def cell_name(self) -> str:
"""Get the cell name based on team."""
if self.team == Team.BACKEND:
return "backend-cell"
elif self.team == Team.FRONTEND:
return "frontend-cell"
elif self.team == Team.UX_UI:
return "uxui-cell"
return "unknown-cell"
# =========================================================================
# LIFECYCLE IMPLEMENTATION
# =========================================================================
async def find_work(self) -> UUID | None:
"""
MONITOR phase: Watch for tasks ready for review.
- Check for tasks flagged as awaiting_qa
- Check for PM notifications
"""
self.log.info("Monitoring for reviews")
# Check pending reviews queue
if self._pending_reviews:
return self._pending_reviews.pop(0)
# Query for tasks awaiting QA
task_id = await self._find_awaiting_qa()
if task_id:
return task_id
return None
async def execute_task(self, task_id: UUID) -> bool:
"""
Execute review through QA lifecycle phases.
Returns True when review is complete.
"""
# Initialize or restore review context
if self._review_context is None or self._review_context.task_id != task_id:
self._review_context = ReviewContext(
task_id=task_id,
title=await self._get_task_title(task_id),
)
ctx = self._review_context
try:
match ctx.phase:
case QATaskPhase.RECEIVE:
await self._phase_receive(ctx)
ctx.phase = QATaskPhase.UNDERSTAND
case QATaskPhase.UNDERSTAND:
await self._phase_understand(ctx)
ctx.phase = QATaskPhase.TEST
case QATaskPhase.TEST:
completed = await self._phase_test(ctx)
if completed:
ctx.phase = QATaskPhase.VERDICT
case QATaskPhase.VERDICT:
await self._phase_verdict(ctx)
ctx.phase = QATaskPhase.DOCUMENT
case QATaskPhase.DOCUMENT:
await self._phase_document(ctx)
ctx.phase = QATaskPhase.RETURN
case QATaskPhase.RETURN:
self._review_context = None
return True
return False
except Exception as e:
self.log.error("Error in review phase", phase=ctx.phase.value, error=str(e))
ctx.findings.append(f"Error during review: {e}")
return False
# =========================================================================
# PHASE IMPLEMENTATIONS
# =========================================================================
async def _phase_receive(self, ctx: ReviewContext) -> None:
"""
RECEIVE phase: Claim the review task.
- Acknowledge receipt
- Announce review started
"""
self.log.info("RECEIVE phase", task_id=str(ctx.task_id))
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"Starting review of TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
message_type="action",
)
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] Review started")
async def _phase_understand(self, ctx: ReviewContext) -> None:
"""
UNDERSTAND phase: Read requirements and dev notes.
- Read task requirements and acceptance criteria
- Read dev's journey notes
- Review commits
- Check conversation history
"""
self.log.info("UNDERSTAND phase", task_id=str(ctx.task_id))
# Read task context
requirements = await self._read_task_requirements(ctx.task_id)
dev_notes = await self._read_dev_notes(ctx.task_id)
commits = await self._get_task_commits(ctx.task_id)
# Use TOON for token-efficient context encoding
task_context = self.format_context_labeled(
"QA Review Context",
{
"title": ctx.title,
"requirements": requirements,
"dev_notes": dev_notes,
"commits": commits,
},
)
prompt = f"""You are a QA engineer reviewing a completed task.
{task_context}
Based on this, create test cases to verify the implementation.
Focus on:
- Acceptance criteria verification
- Edge cases
- Integration points
- Error handling
Format response as TOON tabular:
[N,]{{name,description,steps,expected}}:
Acceptance Criteria,Verify all criteria met,Review implementation|Check each criterion,All criteria satisfied
""" # noqa: E501
_response = await self.think(prompt) # Response informs test case structure
# Create test cases (simplified parsing)
ctx.test_cases = [
TestCase(
name="Acceptance Criteria",
description="Verify all acceptance criteria are met",
steps=["Review implementation", "Check each criterion"],
expected="All criteria satisfied",
),
TestCase(
name="Edge Cases",
description="Test edge cases and error handling",
steps=["Test with invalid input", "Test boundary conditions"],
expected="Graceful handling of edge cases",
),
TestCase(
name="Integration",
description="Verify integration with existing code",
steps=["Run integration tests", "Check API compatibility"],
expected="No breaking changes",
),
]
ts = datetime.now(UTC).isoformat()
ctx.notes.append(f"[{ts}] Created {len(ctx.test_cases)} test cases")
async def _phase_test(self, ctx: ReviewContext) -> bool:
"""
TEST phase: Execute test scenarios.
- Run through each test case
- Document findings
Returns True when all tests complete.
"""
self.log.info(
"TEST phase",
task_id=str(ctx.task_id),
test=ctx.current_test,
total=len(ctx.test_cases),
)
if ctx.current_test >= len(ctx.test_cases):
return True
test_case = ctx.test_cases[ctx.current_test]
# Use TOON for token-efficient context encoding
test_context = self.format_context_labeled(
"Test Case",
{
"name": test_case.name,
"description": test_case.description,
"steps": test_case.steps,
"expected": test_case.expected,
},
)
prompt = f"""Execute this test case:
{test_context}
Simulate executing this test and provide results.
Format response as TOON:
{{result,actual,notes}}:
PASS,All criteria verified successfully,No issues found
"""
response = await self.think(prompt)
# Parse result (simplified)
if "PASS" in response.upper():
test_case.result = TestResult.PASS
else:
test_case.result = TestResult.FAIL
ctx.findings.append(f"FAIL: {test_case.name}")
test_case.actual = response
ctx.current_test += 1
# Progress update
progress = f"{ctx.current_test}/{len(ctx.test_cases)}"
result_str = test_case.result.value.upper()
task_ref = str(ctx.task_id)[:8]
msg = f"TASK-{task_ref} test {progress}: {test_case.name} - {result_str}"
await self.send_message(
self._cell_channel_id or ctx.task_id,
msg,
message_type="action",
)
return ctx.current_test >= len(ctx.test_cases)
async def _phase_verdict(self, ctx: ReviewContext) -> None:
"""
VERDICT phase: Determine overall pass/fail.
- Analyze all test results
- Communicate clear verdict
- If fail, provide specific feedback
"""
self.log.info("VERDICT phase", task_id=str(ctx.task_id))
# Determine verdict
failed_tests = [t for t in ctx.test_cases if t.result == TestResult.FAIL]
if failed_tests:
ctx.verdict = TestResult.FAIL
# Communicate failure with specifics
failure_summary = "\n".join(
[f"- {t.name}: {t.actual or 'No details'}" for t in failed_tests]
)
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"TASK-{str(ctx.task_id)[:8]} QA FAILED\n\n"
f"Issues found:\n{failure_summary}\n\n"
f"Task returned to developer for fixes.",
message_type="decision",
)
# Update task status
await self._update_task_status(ctx.task_id, TaskStatus.NEEDS_REVISION)
else:
ctx.verdict = TestResult.PASS
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"TASK-{str(ctx.task_id)[:8]} QA APPROVED\n\n"
f"All {len(ctx.test_cases)} tests passed.\n"
f"Ready for documentation.",
message_type="decision",
)
# Update task status
await self._update_task_status(
ctx.task_id, TaskStatus.AWAITING_DOCUMENTATION
)
ctx.notes.append(
f"[{datetime.now(UTC).isoformat()}] Verdict: {ctx.verdict.value.upper()}"
)
async def _phase_document(self, ctx: ReviewContext) -> None:
"""
DOCUMENT phase: Add QA notes to task.
- Document test coverage
- Add handoff notes for documenter
"""
self.log.info("DOCUMENT phase", task_id=str(ctx.task_id))
# Generate QA report
test_summary = "\n".join(
[
f"- {t.name}: {t.result.value.upper() if t.result else 'NOT RUN'}"
for t in ctx.test_cases
]
)
qa_report = f"""
## QA Review Summary
**Task**: {ctx.title}
**Verdict**: {ctx.verdict.value.upper() if ctx.verdict else "UNKNOWN"}
**Reviewed**: {datetime.now(UTC).isoformat()}
### Tests Executed
{test_summary}
### Findings
{chr(10).join(ctx.findings) if ctx.findings else "No issues found"}
### Notes
{chr(10).join(ctx.notes)}
"""
# Would save to task record
self.log.info("QA report generated", report_length=len(qa_report))
ctx.notes.append(f"[{datetime.now(UTC).isoformat()}] QA documentation complete")
# =========================================================================
# HELPER METHODS
# =========================================================================
async def _find_awaiting_qa(self) -> UUID | None:
"""Find tasks awaiting QA review."""
try:
team_param = self.team.value if self.team else None
result = await self._api_call(
"GET",
"/tasks",
params={"status": "awaiting_qa", "team": team_param},
)
tasks = result.get("items", [])
return UUID(tasks[0]["id"]) if tasks else None
except Exception as e:
self.log.warning("Failed to find awaiting QA task", error=str(e))
return None
async def _get_task_title(self, task_id: UUID) -> str:
"""Get task title."""
try:
result = await self._api_call("GET", f"/tasks/{task_id}")
return result.get("title", f"Task {str(task_id)[:8]}")
except Exception as e:
self.log.warning("Failed to get task title", error=str(e))
return f"Task {str(task_id)[:8]}"
async def _read_task_requirements(self, task_id: UUID) -> str:
"""Read task requirements."""
try:
result = await self._api_call("GET", f"/tasks/{task_id}")
description = result.get("description", "")
acceptance_criteria = result.get("acceptance_criteria", [])
criteria_text = "\n".join(f"- {c}" for c in acceptance_criteria)
return f"{description}\n\nAcceptance Criteria:\n{criteria_text}"
except Exception as e:
self.log.warning("Failed to read task requirements", error=str(e))
return "Requirements unavailable"
async def _read_dev_notes(self, task_id: UUID) -> str:
"""Read developer's journey notes."""
try:
result = await self._api_call("GET", f"/tasks/{task_id}")
return result.get("dev_notes", "No developer notes available")
except Exception as e:
self.log.warning("Failed to read dev notes", error=str(e))
return "Dev notes unavailable"
async def _get_task_commits(self, task_id: UUID) -> str:
"""Get commits for the task."""
try:
result = await self._api_call("GET", f"/tasks/{task_id}")
commits = result.get("commits", [])
return "\n".join(commits) if commits else "No commits recorded"
except Exception as e:
self.log.warning("Failed to get task commits", error=str(e))
return "Commits unavailable"
async def _update_task_status(self, task_id: UUID, status: TaskStatus) -> None:
"""Update task status."""
try:
await self._api_call(
"PUT",
f"/tasks/{task_id}",
json={"status": status.value},
)
self.log.info(
"Task status updated", task_id=str(task_id), status=status.value
)
except Exception as e:
self.log.error("Failed to update task status", error=str(e))
def create_backend_qa(
name: str = "BE-QA",
system_prompt: str | None = None,
) -> QAAgent:
"""Factory function to create a backend QA agent."""
if system_prompt is None:
blueprint_path = Path("agents/blueprints/backend/be-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
system_prompt = "You are a backend QA engineer."
config = AgentConfig(
name=name,
slug=name.lower().replace(" ", "-"),
role=AgentRole.QA,
team=Team.BACKEND,
system_prompt=system_prompt,
capabilities=["code_review", "test_execution", "security_analysis"],
)
return QAAgent(config)
def create_frontend_qa(
name: str = "FE-QA",
system_prompt: str | None = None,
) -> QAAgent:
"""Factory function to create a frontend QA agent."""
if system_prompt is None:
blueprint_path = Path("agents/blueprints/frontend/fe-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
system_prompt = "You are a frontend QA engineer."
config = AgentConfig(
name=name,
slug=name.lower().replace(" ", "-"),
role=AgentRole.QA,
team=Team.FRONTEND,
system_prompt=system_prompt,
capabilities=["visual_testing", "a11y_testing", "browser_testing"],
)
return QAAgent(config)
def create_ux_qa(
name: str = "UX-QA",
system_prompt: str | None = None,
) -> QAAgent:
"""Factory function to create a UX/UI QA agent."""
if system_prompt is None:
blueprint_path = Path("agents/blueprints/ux_ui/ux-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
system_prompt = "You are a UX/UI QA engineer."
config = AgentConfig(
name=name,
slug=name.lower().replace(" ", "-"),
role=AgentRole.QA,
team=Team.UX_UI,
system_prompt=system_prompt,
capabilities=["design_review", "consistency_check", "a11y_testing"],
)
return QAAgent(config)