diff --git a/apps/backend/methodologies/native/manifest.yaml b/apps/backend/methodologies/native/manifest.yaml index bf955293..93b01f77 100644 --- a/apps/backend/methodologies/native/manifest.yaml +++ b/apps/backend/methodologies/native/manifest.yaml @@ -25,18 +25,26 @@ phases: description: "Generate specification document" artifacts: - spec-md - - id: plan - name: "Planning" - description: "Create implementation plan with subtasks" - artifacts: - - implementation-plan-json - id: validate name: "Validation" description: "Validate spec completeness" + - id: planning + name: "Planning" + description: "Create implementation plan from spec via planner agent" + artifacts: + - implementation-plan-json + - id: coding + name: "Coding" + description: "Implement subtasks from the plan via coder agent" + - id: qa_validation + name: "QA Validation" + description: "Validate acceptance criteria via QA reviewer/fixer loop" + artifacts: + - qa-report-md checkpoints: - id: after_planning - phase: plan + phase: planning description: "Review implementation plan before coding" - id: after_spec phase: spec @@ -62,6 +70,10 @@ artifacts: name: "Implementation Plan" type: json path: "implementation_plan.json" + - id: qa-report-md + name: "QA Report" + type: markdown + path: "qa_report.md" complexity_levels: - quick diff --git a/apps/backend/methodologies/native/methodology.py b/apps/backend/methodologies/native/methodology.py index 39ea018c..61441681 100644 --- a/apps/backend/methodologies/native/methodology.py +++ b/apps/backend/methodologies/native/methodology.py @@ -48,19 +48,22 @@ class NativeRunner: This class implements the MethodologyRunner Protocol, providing the interface for the plugin framework to execute the Native methodology. - The Native methodology follows a 6-phase pipeline: + The Native methodology follows an 8-phase pipeline: 1. Discovery - Gather project context and user requirements 2. Requirements - Structure and validate requirements 3. Context - Build codebase context for implementation 4. Spec - Generate specification document - 5. Plan - Create implementation plan with subtasks - 6. Validate - Validate spec completeness + 5. Validate - Validate spec completeness + 6. Planning - Create implementation plan via planner agent + 7. Coding - Implement subtasks via coder agent + 8. QA Validation - Validate via QA reviewer/fixer loop Delegation Pattern: - Discovery: delegates to spec.discovery.run_discovery_script - Requirements: delegates to spec.requirements module - Context: delegates to spec.context module - - Spec/Plan/Validate: require framework agent infrastructure + - Spec/Validate: require framework agent infrastructure + - Planning/Coding/QA: delegate to agents module Example: runner = NativeRunner() @@ -70,6 +73,9 @@ class NativeRunner: result = runner.execute_phase(phase.id) """ + # Default model for agent execution (Story 2.5) + _DEFAULT_AGENT_MODEL: str = "claude-sonnet-4-5-20250929" + def __init__(self) -> None: """Initialize NativeRunner instance.""" self._context: RunContext | None = None @@ -269,8 +275,11 @@ class NativeRunner: "requirements": self._execute_requirements, "context": self._execute_context, "spec": self._execute_spec, - "plan": self._execute_plan, "validate": self._execute_validate, + # Story 2.5: Implementation phases + "planning": self._execute_planning, + "coding": self._execute_coding, + "qa_validation": self._execute_qa_validation, } handler = dispatch.get(phase_id) @@ -512,41 +521,6 @@ class NativeRunner: "Use SpecOrchestrator for full pipeline execution.", ) - def _execute_plan(self) -> PhaseResult: - """Execute the planning phase. - - Creates implementation plan via agent execution. - Requires framework agent infrastructure for full implementation. - - Returns: - PhaseResult with success status and artifacts - """ - if self._spec_dir is None: - return PhaseResult( - success=False, - phase_id="plan", - error="No spec_dir configured. Set spec_dir in task_config.metadata.", - ) - - plan_file = self._spec_dir / "implementation_plan.json" - - # Check if plan already exists - if plan_file.exists(): - return PhaseResult( - success=True, - phase_id="plan", - message="Implementation plan already exists", - artifacts=[str(plan_file)], - ) - - # Plan generation requires agent execution via framework - return PhaseResult( - success=False, - phase_id="plan", - error="Plan generation requires framework agent infrastructure. " - "Use SpecOrchestrator for full pipeline execution.", - ) - def _execute_validate(self) -> PhaseResult: """Execute the validation phase. @@ -586,6 +560,317 @@ class NativeRunner: error=f"Validation failed: {'; '.join(errors)}", ) + # ========================================================================= + # Story 2.5: Implementation Phase Methods + # ========================================================================= + + def _run_async(self, coro): + """Run an async coroutine safely from sync context. + + Handles the case where we might already be in an async context + by using a ThreadPoolExecutor with asyncio.run(). + + Args: + coro: The async coroutine to execute + + Returns: + The result of the coroutine + + Note: + Uses asyncio.run() which creates a new event loop. This is + preferred over get_event_loop() which is deprecated in Python 3.10+. + """ + import asyncio + + try: + # Check if we're already in an async context + asyncio.get_running_loop() + # We're in an async context, run in thread + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, coro).result() + except RuntimeError: + # No running loop, safe to use asyncio.run + return asyncio.run(coro) + + def _execute_planning(self) -> PhaseResult: + """Execute the planning phase via planner agent. + + Invokes the existing planner agent to create implementation_plan.json + from the spec.md document. Uses the Claude SDK client for agent execution. + + Returns: + PhaseResult with success status and implementation_plan.json artifact + + Story Reference: Story 2.5 AC#1 - Planning phase produces implementation_plan.json + """ + if self._spec_dir is None: + return PhaseResult( + success=False, + phase_id="planning", + error="No spec_dir configured. Set spec_dir in task_config.metadata.", + ) + + # Check if spec.md exists + spec_file = self._spec_dir / "spec.md" + if not spec_file.exists(): + return PhaseResult( + success=False, + phase_id="planning", + error="spec.md not found. Run spec phase first.", + ) + + # Check if plan already exists + plan_file = self._spec_dir / "implementation_plan.json" + if plan_file.exists(): + return PhaseResult( + success=True, + phase_id="planning", + message="Implementation plan already exists", + artifacts=[str(plan_file)], + ) + + # Report progress + self._invoke_progress_callback("Starting planner agent...", 10.0) + + try: + from apps.backend.agents.session import run_agent_session + from apps.backend.core.client import create_client + from apps.backend.prompts_pkg.prompt_generator import ( + generate_planner_prompt, + ) + from apps.backend.task_logger import LogPhase + + project_dir = Path(self._worktree_path or self._project_dir) + + # Generate planner prompt + self._invoke_progress_callback("Generating planner prompt...", 20.0) + prompt = generate_planner_prompt(self._spec_dir, project_dir) + + # Create client for planner agent + self._invoke_progress_callback("Creating planner agent client...", 30.0) + model = ( + self._task_config.metadata.get("model", self._DEFAULT_AGENT_MODEL) + if self._task_config + else self._DEFAULT_AGENT_MODEL + ) + client = create_client( + project_dir, + self._spec_dir, + model=model, + agent_type="planner", + max_thinking_tokens=None, + ) + + # Run planner agent session + self._invoke_progress_callback("Running planner agent...", 50.0) + + async def _run_planner(): + async with client: + status, response = await run_agent_session( + client, + prompt, + self._spec_dir, + verbose=False, + phase=LogPhase.PLANNING, + ) + return status, response + + status, response = self._run_async(_run_planner()) + + self._invoke_progress_callback("Planner agent completed", 90.0) + + # Verify the plan was created + if plan_file.exists(): + return PhaseResult( + success=True, + phase_id="planning", + message="Implementation plan created by planner agent", + artifacts=[str(plan_file)], + ) + else: + return PhaseResult( + success=False, + phase_id="planning", + error=f"Planner agent completed with status '{status}' but implementation_plan.json was not created", + ) + + except Exception as e: + logger.error(f"Planning phase failed: {e}") + return PhaseResult( + success=False, + phase_id="planning", + error=f"Planning phase failed: {str(e)}", + ) + + def _execute_coding(self) -> PhaseResult: + """Execute the coding phase via coder agent. + + Loads the implementation_plan.json and executes each subtask via the + coder agent. Updates subtask status in the plan after each execution. + + Returns: + PhaseResult with success status + + Story Reference: Story 2.5 AC#2 - Coding phase implements subtasks + """ + if self._spec_dir is None: + return PhaseResult( + success=False, + phase_id="coding", + error="No spec_dir configured. Set spec_dir in task_config.metadata.", + ) + + # Check if plan exists + plan_file = self._spec_dir / "implementation_plan.json" + if not plan_file.exists(): + return PhaseResult( + success=False, + phase_id="coding", + error="implementation_plan.json not found. Run planning phase first.", + ) + + self._invoke_progress_callback("Loading implementation plan...", 5.0) + + try: + from apps.backend.agents.coder import run_autonomous_agent + + project_dir = Path(self._worktree_path or self._project_dir) + + self._invoke_progress_callback("Starting coder agent loop...", 10.0) + + model = ( + self._task_config.metadata.get("model", self._DEFAULT_AGENT_MODEL) + if self._task_config + else self._DEFAULT_AGENT_MODEL + ) + + async def _run_coder(): + await run_autonomous_agent( + project_dir=project_dir, + spec_dir=self._spec_dir, + model=model, + max_iterations=None, + verbose=False, + source_spec_dir=None, + ) + + self._run_async(_run_coder()) + + self._invoke_progress_callback("Coder agent completed", 100.0) + + # Check if all subtasks are completed + from apps.backend.progress import is_build_complete + + if is_build_complete(self._spec_dir): + return PhaseResult( + success=True, + phase_id="coding", + message="All subtasks implemented successfully", + artifacts=[], + ) + else: + # Get progress info + from apps.backend.progress import count_subtasks + + completed, total = count_subtasks(self._spec_dir) + return PhaseResult( + success=False, + phase_id="coding", + error=f"Coding incomplete: {completed}/{total} subtasks completed", + ) + + except Exception as e: + logger.error(f"Coding phase failed: {e}") + return PhaseResult( + success=False, + phase_id="coding", + error=f"Coding phase failed: {str(e)}", + ) + + def _execute_qa_validation(self) -> PhaseResult: + """Execute the QA validation phase via QA reviewer/fixer loop. + + Runs the QA validation loop that: + 1. QA reviewer validates acceptance criteria + 2. If issues found, QA fixer applies fixes + 3. Loop continues until approved or max iterations + + Returns: + PhaseResult with success status and qa_report.md artifact + + Story Reference: Story 2.5 AC#3 - QA validation with reviewer/fixer loop + """ + if self._spec_dir is None: + return PhaseResult( + success=False, + phase_id="qa_validation", + error="No spec_dir configured. Set spec_dir in task_config.metadata.", + ) + + # Check if build is complete + from apps.backend.progress import is_build_complete + + if not is_build_complete(self._spec_dir): + return PhaseResult( + success=False, + phase_id="qa_validation", + error="Build not complete. Run coding phase first.", + ) + + self._invoke_progress_callback("Starting QA validation loop...", 10.0) + + try: + from apps.backend.qa.loop import run_qa_validation_loop + + project_dir = Path(self._worktree_path or self._project_dir) + + model = ( + self._task_config.metadata.get("model", self._DEFAULT_AGENT_MODEL) + if self._task_config + else self._DEFAULT_AGENT_MODEL + ) + + async def _run_qa(): + return await run_qa_validation_loop( + project_dir=project_dir, + spec_dir=self._spec_dir, + model=model, + verbose=False, + ) + + approved = self._run_async(_run_qa()) + + self._invoke_progress_callback("QA validation completed", 100.0) + + # Get QA report artifact + qa_report = self._spec_dir / "qa_report.md" + artifacts = [str(qa_report)] if qa_report.exists() else [] + + if approved: + return PhaseResult( + success=True, + phase_id="qa_validation", + message="QA validation passed - all acceptance criteria verified", + artifacts=artifacts, + ) + else: + return PhaseResult( + success=False, + phase_id="qa_validation", + error="QA validation failed - see qa_report.md for details", + artifacts=artifacts, + ) + + except Exception as e: + logger.error(f"QA validation phase failed: {e}") + return PhaseResult( + success=False, + phase_id="qa_validation", + error=f"QA validation phase failed: {str(e)}", + ) + def get_checkpoints(self) -> list[Checkpoint]: """Return checkpoint definitions for Semi-Auto mode. @@ -639,14 +924,19 @@ class NativeRunner: # Story 2.4: Progress Reporting Methods # ========================================================================= - # Phase weights for percentage calculation (Story 2.4 Task 6) + # Phase weights for percentage calculation (Story 2.4 Task 6, Story 2.5) + # Phase weights for percentage calculation (Story 2.4 Task 6, Story 2.5) + # Total: 100% (5+5+5+10+5+10+40+20 = 100) _PHASE_WEIGHTS: dict[str, int] = { - "discovery": 10, - "requirements": 10, - "context": 15, - "spec": 25, - "plan": 20, - "validate": 20, + "discovery": 5, + "requirements": 5, + "context": 5, + "spec": 10, + "validate": 5, + # Story 2.5: Implementation phases + "planning": 10, + "coding": 40, + "qa_validation": 20, } def _emit_progress_event( @@ -831,21 +1121,38 @@ class NativeRunner: is_optional=False, ), Phase( - id="plan", - name="Planning", - description="Create implementation plan with subtasks", + id="validate", + name="Validation", + description="Validate spec completeness", order=5, status=PhaseStatus.PENDING, is_optional=False, ), + # Story 2.5: Implementation phases Phase( - id="validate", - name="Validation", - description="Validate spec completeness", + id="planning", + name="Planning", + description="Create implementation plan from spec via planner agent", order=6, status=PhaseStatus.PENDING, is_optional=False, ), + Phase( + id="coding", + name="Coding", + description="Implement subtasks from the plan via coder agent", + order=7, + status=PhaseStatus.PENDING, + is_optional=False, + ), + Phase( + id="qa_validation", + name="QA Validation", + description="Validate acceptance criteria via QA reviewer/fixer loop", + order=8, + status=PhaseStatus.PENDING, + is_optional=False, + ), ] def _init_checkpoints(self) -> None: @@ -855,7 +1162,7 @@ class NativeRunner: id="after_planning", name="Planning Review", description="Review implementation plan before coding", - phase_id="plan", + phase_id="planning", status=CheckpointStatus.PENDING, requires_approval=True, ), @@ -912,6 +1219,15 @@ class NativeRunner: phase_id="plan", content_type="application/json", ), + # Story 2.5: QA artifacts + Artifact( + id="qa-report-md", + artifact_type="markdown", + name="QA Report", + file_path="qa_report.md", + phase_id="qa_validation", + content_type="text/markdown", + ), ] # ========================================================================= diff --git a/tests/methodologies/test_native_methodology.py b/tests/methodologies/test_native_methodology.py index 66a120e7..75cb23a1 100644 --- a/tests/methodologies/test_native_methodology.py +++ b/tests/methodologies/test_native_methodology.py @@ -282,16 +282,16 @@ class TestNativeManifestValidation: manifest = load_manifest(manifest_path) assert manifest.entry_point == "methodology.NativeRunner" - def test_manifest_has_six_phases(self): - """Test that manifest defines exactly 6 phases.""" + def test_manifest_has_nine_phases(self): + """Test that manifest defines exactly 9 phases (6 spec + 3 implementation).""" from apps.backend.methodologies.manifest import load_manifest manifest_path = NATIVE_METHODOLOGY_DIR / "manifest.yaml" manifest = load_manifest(manifest_path) - assert len(manifest.phases) == 6 + assert len(manifest.phases) == 8 def test_manifest_phase_ids_are_correct(self): - """Test that phases have the correct IDs per AC #2.""" + """Test that phases have the correct IDs (6 spec + 3 implementation).""" from apps.backend.methodologies.manifest import load_manifest manifest_path = NATIVE_METHODOLOGY_DIR / "manifest.yaml" @@ -302,8 +302,11 @@ class TestNativeManifestValidation: "requirements", "context", "spec", - "plan", "validate", + # Story 2.5: Implementation phases + "planning", + "coding", + "qa_validation", ] actual_phase_ids = [phase.id for phase in manifest.phases] assert actual_phase_ids == expected_phase_ids @@ -446,7 +449,7 @@ class TestNativeRunnerInitialization: # Should not raise after initialization phases = runner.get_phases() - assert len(phases) == 6 + assert len(phases) == 8 # 6 spec + 3 implementation phases (Story 2.5) def test_runner_cannot_initialize_twice(self, mock_context, mock_workspace_manager): """Test runner raises error if initialized twice.""" @@ -484,16 +487,16 @@ class TestNativeRunnerPhases: phases = initialized_runner.get_phases() assert isinstance(phases, list) - def test_get_phases_returns_six_phases(self, initialized_runner): - """Test get_phases returns exactly 6 phases.""" + def test_get_phases_returns_nine_phases(self, initialized_runner): + """Test get_phases returns exactly 9 phases (6 spec + 3 implementation).""" phases = initialized_runner.get_phases() - assert len(phases) == 6 + assert len(phases) == 8 def test_phases_have_correct_order(self, initialized_runner): - """Test phases are in correct execution order.""" + """Test phases are in correct execution order (1-8).""" phases = initialized_runner.get_phases() orders = [phase.order for phase in phases] - assert orders == [1, 2, 3, 4, 5, 6] + assert orders == [1, 2, 3, 4, 5, 6, 7, 8] def test_phases_have_pending_status_initially(self, initialized_runner): """Test all phases start with PENDING status.""" @@ -567,13 +570,13 @@ class TestNativeRunnerArtifacts: artifacts = initialized_runner.get_artifacts() assert isinstance(artifacts, list) - def test_get_artifacts_returns_four_artifacts(self, initialized_runner): - """Test get_artifacts returns exactly 4 artifacts.""" + def test_get_artifacts_returns_five_artifacts(self, initialized_runner): + """Test get_artifacts returns exactly 5 artifacts (4 spec + 1 QA).""" artifacts = initialized_runner.get_artifacts() - assert len(artifacts) == 4 + assert len(artifacts) == 5 def test_artifacts_have_expected_ids(self, initialized_runner): - """Test artifacts have the expected IDs.""" + """Test artifacts have the expected IDs (4 spec + 1 QA).""" artifacts = initialized_runner.get_artifacts() expected_ids = { @@ -581,6 +584,8 @@ class TestNativeRunnerArtifacts: "context-json", "spec-md", "implementation-plan-json", + # Story 2.5: QA artifact + "qa-report-md", } actual_ids = {artifact.id for artifact in artifacts} assert actual_ids == expected_ids @@ -749,10 +754,14 @@ class TestNativeRunnerGetPhasesStory22: """Test NativeRunner get_phases returns correct phase info (Story 2.2 AC#2).""" def test_get_phases_returns_phases_with_ids(self, initialized_runner): - """Test phases have expected IDs matching manifest.""" + """Test phases have expected IDs matching manifest (5 spec + 3 implementation).""" phases = initialized_runner.get_phases() - expected_ids = ["discovery", "requirements", "context", "spec", "plan", "validate"] + expected_ids = [ + "discovery", "requirements", "context", "spec", "validate", + # Story 2.5: Implementation phases + "planning", "coding", "qa_validation" + ] actual_ids = [phase.id for phase in phases] assert actual_ids == expected_ids @@ -808,13 +817,6 @@ class TestNativeRunnerPhaseExecutionNoSpecDir: assert result.success is False assert "spec_dir" in result.error.lower() - def test_plan_fails_without_spec_dir(self, initialized_runner): - """Test plan phase fails without spec_dir.""" - result = initialized_runner.execute_phase("plan") - - assert result.success is False - assert "spec_dir" in result.error.lower() - def test_validate_fails_without_spec_dir(self, initialized_runner): """Test validate phase fails without spec_dir.""" result = initialized_runner.execute_phase("validate") @@ -926,33 +928,6 @@ class TestNativeRunnerSpecPhase: assert len(result.artifacts) == 1 -class TestNativeRunnerPlanPhase: - """Test plan phase execution (Story 2.2 Task 9).""" - - def test_plan_fails_without_existing_file(self, initialized_runner_with_spec_dir): - """Test plan phase fails when implementation_plan.json doesn't exist.""" - result = initialized_runner_with_spec_dir.execute_phase("plan") - - # Plan generation requires agent infrastructure - assert result.success is False - assert "framework" in result.error.lower() or "agent" in result.error.lower() - - def test_plan_returns_existing_if_present(self, initialized_runner_with_spec_dir): - """Test plan phase succeeds if implementation_plan.json already exists.""" - import json - - runner = initialized_runner_with_spec_dir - - # Create existing plan - plan_file = runner._spec_dir / "implementation_plan.json" - plan_file.write_text(json.dumps({"subtasks": []})) - - result = runner.execute_phase("plan") - - assert result.success is True - assert "already exists" in result.message.lower() - - class TestNativeRunnerValidatePhase: """Test validate phase execution (Story 2.2 Task 10).""" @@ -1722,8 +1697,8 @@ class TestIncrementalProgressReporting: assert event.phase_id == "spec" assert event.status == "in_progress" assert event.message == "Generating specification..." - # 50% within spec phase (35-60%) = 35 + (25 * 0.5) = 47.5% overall - assert event.percentage == 47.5 + # Story 2.5: 50% within spec phase (15-25%) = 15 + (10 * 0.5) = 20% overall + assert event.percentage == 20.0 def test_phase_percentage_within_bounds(self, initialized_runner_with_spec_dir): """Test incremental progress percentage stays within phase bounds.""" @@ -1741,7 +1716,7 @@ class TestIncrementalProgressReporting: mock_progress.update = MagicMock() runner._context.progress = mock_progress - # Emit progress at 50% within spec phase (which is 35-60% overall) + # Story 2.5: Emit progress at 50% within spec phase (which is 15-25% overall) runner.emit_incremental_progress( phase_id="spec", message="Halfway through spec", @@ -1749,8 +1724,8 @@ class TestIncrementalProgressReporting: ) event = emitted_events[0] - # Spec phase: start=35%, end=60%, so 50% within = 35 + (25 * 0.5) = 47.5% - assert 35.0 <= event.percentage <= 60.0 + # Spec phase: start=15%, end=25%, so 50% within = 15 + (10 * 0.5) = 20% + assert 15.0 <= event.percentage <= 25.0 # ============================================================================= @@ -1903,55 +1878,88 @@ class TestProgressCallbacks: class TestPhasePercentageCalculation: - """Test phase percentage calculation (Story 2.4 Task 6).""" + """Test phase percentage calculation (Story 2.4 Task 6, updated for Story 2.5). + + With Story 2.5 (after removing duplicate plan phase), the weights are: + - discovery: 5%, requirements: 5%, context: 5%, spec: 10%, validate: 5% + - planning: 10%, coding: 40%, qa_validation: 20% + Total: 100% + + Phase ranges: + - discovery: 0-5% + - requirements: 5-10% + - context: 10-15% + - spec: 15-25% + - validate: 25-30% + - planning: 30-40% + - coding: 40-80% + - qa_validation: 80-100% + """ def test_discovery_starts_at_zero(self, initialized_runner): """Test discovery phase starts at 0%.""" assert initialized_runner._get_phase_start_percentage("discovery") == 0.0 - def test_discovery_ends_at_ten(self, initialized_runner): - """Test discovery phase ends at 10%.""" - assert initialized_runner._get_phase_end_percentage("discovery") == 10.0 + def test_discovery_ends_at_five(self, initialized_runner): + """Test discovery phase ends at 5%.""" + assert initialized_runner._get_phase_end_percentage("discovery") == 5.0 - def test_requirements_starts_at_ten(self, initialized_runner): - """Test requirements phase starts at 10%.""" - assert initialized_runner._get_phase_start_percentage("requirements") == 10.0 + def test_requirements_starts_at_five(self, initialized_runner): + """Test requirements phase starts at 5%.""" + assert initialized_runner._get_phase_start_percentage("requirements") == 5.0 - def test_requirements_ends_at_twenty(self, initialized_runner): - """Test requirements phase ends at 20%.""" - assert initialized_runner._get_phase_end_percentage("requirements") == 20.0 + def test_requirements_ends_at_ten(self, initialized_runner): + """Test requirements phase ends at 10%.""" + assert initialized_runner._get_phase_end_percentage("requirements") == 10.0 - def test_context_starts_at_twenty(self, initialized_runner): - """Test context phase starts at 20%.""" - assert initialized_runner._get_phase_start_percentage("context") == 20.0 + def test_context_starts_at_ten(self, initialized_runner): + """Test context phase starts at 10%.""" + assert initialized_runner._get_phase_start_percentage("context") == 10.0 - def test_context_ends_at_thirty_five(self, initialized_runner): - """Test context phase ends at 35%.""" - assert initialized_runner._get_phase_end_percentage("context") == 35.0 + def test_context_ends_at_fifteen(self, initialized_runner): + """Test context phase ends at 15%.""" + assert initialized_runner._get_phase_end_percentage("context") == 15.0 - def test_spec_starts_at_thirty_five(self, initialized_runner): - """Test spec phase starts at 35%.""" - assert initialized_runner._get_phase_start_percentage("spec") == 35.0 + def test_spec_starts_at_fifteen(self, initialized_runner): + """Test spec phase starts at 15%.""" + assert initialized_runner._get_phase_start_percentage("spec") == 15.0 - def test_spec_ends_at_sixty(self, initialized_runner): - """Test spec phase ends at 60%.""" - assert initialized_runner._get_phase_end_percentage("spec") == 60.0 + def test_spec_ends_at_twenty_five(self, initialized_runner): + """Test spec phase ends at 25%.""" + assert initialized_runner._get_phase_end_percentage("spec") == 25.0 - def test_plan_starts_at_sixty(self, initialized_runner): - """Test plan phase starts at 60%.""" - assert initialized_runner._get_phase_start_percentage("plan") == 60.0 + def test_validate_starts_at_twenty_five(self, initialized_runner): + """Test validate phase starts at 25%.""" + assert initialized_runner._get_phase_start_percentage("validate") == 25.0 - def test_plan_ends_at_eighty(self, initialized_runner): - """Test plan phase ends at 80%.""" - assert initialized_runner._get_phase_end_percentage("plan") == 80.0 + def test_validate_ends_at_thirty(self, initialized_runner): + """Test validate phase ends at 30%.""" + assert initialized_runner._get_phase_end_percentage("validate") == 30.0 - def test_validate_starts_at_eighty(self, initialized_runner): - """Test validate phase starts at 80%.""" - assert initialized_runner._get_phase_start_percentage("validate") == 80.0 + # Story 2.5: Implementation phase percentage tests + def test_planning_starts_at_thirty(self, initialized_runner): + """Test planning phase starts at 30%.""" + assert initialized_runner._get_phase_start_percentage("planning") == 30.0 - def test_validate_ends_at_hundred(self, initialized_runner): - """Test validate phase ends at 100%.""" - assert initialized_runner._get_phase_end_percentage("validate") == 100.0 + def test_planning_ends_at_forty(self, initialized_runner): + """Test planning phase ends at 40%.""" + assert initialized_runner._get_phase_end_percentage("planning") == 40.0 + + def test_coding_starts_at_forty(self, initialized_runner): + """Test coding phase starts at 40%.""" + assert initialized_runner._get_phase_start_percentage("coding") == 40.0 + + def test_coding_ends_at_eighty(self, initialized_runner): + """Test coding phase ends at 80%.""" + assert initialized_runner._get_phase_end_percentage("coding") == 80.0 + + def test_qa_validation_starts_at_eighty(self, initialized_runner): + """Test qa_validation phase starts at 80%.""" + assert initialized_runner._get_phase_start_percentage("qa_validation") == 80.0 + + def test_qa_validation_ends_at_hundred(self, initialized_runner): + """Test qa_validation phase ends at 100%.""" + assert initialized_runner._get_phase_end_percentage("qa_validation") == 100.0 def test_unknown_phase_returns_zero(self, initialized_runner): """Test unknown phase returns 0%.""" @@ -2031,3 +2039,325 @@ class TestNativeRunnerCleanup: runner = NativeRunner() # Should not raise runner.cleanup() + + +# ============================================================================= +# Story 2.5: Implementation Phase Tests +# ============================================================================= + + +class TestNativeRunnerPlanningPhase: + """Test planning implementation phase execution (Story 2.5 AC#1).""" + + def test_planning_phase_exists_in_phases(self, initialized_runner): + """Test planning phase is in phases list.""" + phases = initialized_runner.get_phases() + phase_ids = [p.id for p in phases] + assert "planning" in phase_ids + + def test_planning_phase_fails_without_spec_dir(self, initialized_runner): + """Test planning phase fails without spec_dir.""" + result = initialized_runner.execute_phase("planning") + assert result.success is False + assert "spec_dir" in result.error.lower() + + def test_planning_phase_fails_without_spec_md(self, initialized_runner_with_spec_dir): + """Test planning phase fails if spec.md doesn't exist.""" + result = initialized_runner_with_spec_dir.execute_phase("planning") + assert result.success is False + assert "spec.md" in result.error.lower() + + def test_planning_returns_existing_plan(self, initialized_runner_with_spec_dir): + """Test planning phase returns existing plan if present.""" + import json + + runner = initialized_runner_with_spec_dir + + # Create spec.md and existing plan + spec_file = runner._spec_dir / "spec.md" + spec_file.write_text("# Test Spec\n\nContent here.") + + plan_file = runner._spec_dir / "implementation_plan.json" + plan_file.write_text(json.dumps({"subtasks": [{"id": "1.1"}]})) + + result = runner.execute_phase("planning") + + assert result.success is True + assert "already exists" in result.message.lower() + assert str(plan_file) in result.artifacts + + +class TestNativeRunnerCodingPhase: + """Test coding implementation phase execution (Story 2.5 AC#2).""" + + def test_coding_phase_exists_in_phases(self, initialized_runner): + """Test coding phase is in phases list.""" + phases = initialized_runner.get_phases() + phase_ids = [p.id for p in phases] + assert "coding" in phase_ids + + def test_coding_phase_fails_without_spec_dir(self, initialized_runner): + """Test coding phase fails without spec_dir.""" + result = initialized_runner.execute_phase("coding") + assert result.success is False + assert "spec_dir" in result.error.lower() + + def test_coding_phase_fails_without_plan(self, initialized_runner_with_spec_dir): + """Test coding phase fails if implementation_plan.json doesn't exist.""" + result = initialized_runner_with_spec_dir.execute_phase("coding") + assert result.success is False + assert "implementation_plan.json" in result.error.lower() + + +class TestNativeRunnerQAValidationPhase: + """Test QA validation phase execution (Story 2.5 AC#3).""" + + def test_qa_validation_phase_exists_in_phases(self, initialized_runner): + """Test qa_validation phase is in phases list.""" + phases = initialized_runner.get_phases() + phase_ids = [p.id for p in phases] + assert "qa_validation" in phase_ids + + def test_qa_validation_phase_fails_without_spec_dir(self, initialized_runner): + """Test qa_validation phase fails without spec_dir.""" + result = initialized_runner.execute_phase("qa_validation") + assert result.success is False + assert "spec_dir" in result.error.lower() + + def test_qa_validation_phase_fails_without_complete_build(self, initialized_runner_with_spec_dir): + """Test qa_validation phase fails if build not complete.""" + import json + + runner = initialized_runner_with_spec_dir + + # Create incomplete plan (some subtasks not completed) + plan_file = runner._spec_dir / "implementation_plan.json" + plan_file.write_text(json.dumps({ + "subtasks": [ + {"id": "1.1", "status": "completed"}, + {"id": "1.2", "status": "pending"} # Not complete + ] + })) + + result = runner.execute_phase("qa_validation") + assert result.success is False + assert "complete" in result.error.lower() + + +class TestNativeRunnerImplementationPhaseIntegration: + """Test integration between implementation phases (Story 2.5).""" + + def test_implementation_phases_have_correct_order(self, initialized_runner): + """Test implementation phases are ordered after spec phases.""" + phases = initialized_runner.get_phases() + + # Get phase orders + phase_order = {p.id: p.order for p in phases} + + # Implementation phases should come after spec phases + assert phase_order["planning"] > phase_order["validate"] + assert phase_order["coding"] > phase_order["planning"] + assert phase_order["qa_validation"] > phase_order["coding"] + + def test_qa_report_artifact_exists(self, initialized_runner): + """Test qa-report-md artifact is defined.""" + artifacts = initialized_runner.get_artifacts() + artifact_ids = [a.id for a in artifacts] + assert "qa-report-md" in artifact_ids + + def test_qa_report_artifact_has_correct_phase(self, initialized_runner): + """Test qa-report-md artifact is associated with qa_validation phase.""" + artifacts = initialized_runner.get_artifacts() + qa_artifact = next(a for a in artifacts if a.id == "qa-report-md") + assert qa_artifact.phase_id == "qa_validation" + + def test_qa_report_artifact_has_correct_file_path(self, initialized_runner): + """Test qa-report-md artifact has correct file path.""" + artifacts = initialized_runner.get_artifacts() + qa_artifact = next(a for a in artifacts if a.id == "qa-report-md") + assert qa_artifact.file_path == "qa_report.md" + + +class TestNativeRunnerAgentInvocation: + """Test that implementation phases invoke correct agent functions (Story 2.5).""" + + def test_planning_invokes_run_agent_session(self, initialized_runner_with_spec_dir): + """Test planning phase calls run_agent_session with correct args.""" + import json + from unittest.mock import patch, MagicMock, AsyncMock + + runner = initialized_runner_with_spec_dir + + # Create spec.md (required for planning) + spec_file = runner._spec_dir / "spec.md" + spec_file.write_text("# Test Spec\n\n## Overview\nTest content") + + # Mock the agent session and related functions + mock_session = AsyncMock(return_value=("complete", "response")) + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch( + "apps.backend.agents.session.run_agent_session", + mock_session, + ), patch( + "apps.backend.core.client.create_client", + return_value=mock_client, + ) as mock_create_client, patch( + "apps.backend.prompts_pkg.prompt_generator.generate_planner_prompt", + return_value="test prompt", + ): + # The phase will fail because implementation_plan.json isn't created + # but we can verify the agent was invoked + result = runner.execute_phase("planning") + + # Verify create_client was called with correct agent_type + mock_create_client.assert_called_once() + call_kwargs = mock_create_client.call_args + assert call_kwargs[1]["agent_type"] == "planner" + + # Verify run_agent_session was called + mock_session.assert_called_once() + + def test_coding_invokes_run_autonomous_agent(self, initialized_runner_with_spec_dir): + """Test coding phase calls run_autonomous_agent with correct args.""" + import json + from unittest.mock import patch, MagicMock, AsyncMock + + runner = initialized_runner_with_spec_dir + + # Create implementation_plan.json (required for coding) + plan_file = runner._spec_dir / "implementation_plan.json" + plan_file.write_text(json.dumps({ + "subtasks": [ + {"id": "1.1", "title": "Test", "status": "pending"} + ] + })) + + mock_coder = AsyncMock() + + with patch( + "apps.backend.agents.coder.run_autonomous_agent", + mock_coder, + ), patch( + "apps.backend.progress.is_build_complete", + return_value=True, + ): + result = runner.execute_phase("coding") + + # Verify run_autonomous_agent was called + mock_coder.assert_called_once() + + # Verify it was called with spec_dir + call_kwargs = mock_coder.call_args + assert call_kwargs[1]["spec_dir"] == runner._spec_dir + + def test_qa_validation_invokes_run_qa_validation_loop(self, initialized_runner_with_spec_dir): + """Test qa_validation phase calls run_qa_validation_loop with correct args.""" + import json + from unittest.mock import patch, AsyncMock + + runner = initialized_runner_with_spec_dir + + # Create complete build (all subtasks completed) + plan_file = runner._spec_dir / "implementation_plan.json" + plan_file.write_text(json.dumps({ + "subtasks": [ + {"id": "1.1", "status": "completed"}, + {"id": "1.2", "status": "completed"} + ] + })) + + mock_qa_loop = AsyncMock(return_value=True) # Return approved + + with patch( + "apps.backend.qa.loop.run_qa_validation_loop", + mock_qa_loop, + ), patch( + "apps.backend.progress.is_build_complete", + return_value=True, + ): + result = runner.execute_phase("qa_validation") + + # Verify run_qa_validation_loop was called + mock_qa_loop.assert_called_once() + + # Verify result + assert result.success is True + assert "passed" in result.message.lower() + + def test_model_from_task_config_metadata(self, initialized_runner_with_spec_dir): + """Test that model is read from task_config.metadata if available.""" + import json + from unittest.mock import patch, MagicMock, AsyncMock + + runner = initialized_runner_with_spec_dir + + # Set a custom model in task_config metadata + runner._task_config.metadata["model"] = "claude-opus-4-5-20251101" + + # Create spec.md (required for planning) + spec_file = runner._spec_dir / "spec.md" + spec_file.write_text("# Test Spec\n\n## Overview\nTest content") + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_session = AsyncMock(return_value=("complete", "response")) + + with patch( + "apps.backend.agents.session.run_agent_session", + mock_session, + ), patch( + "apps.backend.core.client.create_client", + return_value=mock_client, + ) as mock_create_client, patch( + "apps.backend.prompts_pkg.prompt_generator.generate_planner_prompt", + return_value="test prompt", + ): + result = runner.execute_phase("planning") + + # Verify create_client was called with the custom model + mock_create_client.assert_called_once() + call_kwargs = mock_create_client.call_args + assert call_kwargs[1]["model"] == "claude-opus-4-5-20251101" + + def test_default_model_used_when_not_in_metadata(self, initialized_runner_with_spec_dir): + """Test that DEFAULT_AGENT_MODEL is used when model not in metadata.""" + import json + from unittest.mock import patch, MagicMock, AsyncMock + + from apps.backend.methodologies.native.methodology import NativeRunner + + runner = initialized_runner_with_spec_dir + + # Ensure no model in metadata + if "model" in runner._task_config.metadata: + del runner._task_config.metadata["model"] + + # Create spec.md (required for planning) + spec_file = runner._spec_dir / "spec.md" + spec_file.write_text("# Test Spec\n\n## Overview\nTest content") + + mock_client = MagicMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_session = AsyncMock(return_value=("complete", "response")) + + with patch( + "apps.backend.agents.session.run_agent_session", + mock_session, + ), patch( + "apps.backend.core.client.create_client", + return_value=mock_client, + ) as mock_create_client, patch( + "apps.backend.prompts_pkg.prompt_generator.generate_planner_prompt", + return_value="test prompt", + ): + result = runner.execute_phase("planning") + + # Verify create_client was called with the default model + mock_create_client.assert_called_once() + call_kwargs = mock_create_client.call_args + assert call_kwargs[1]["model"] == NativeRunner._DEFAULT_AGENT_MODEL