"""Step definitions for subplan spawn → execute → merge orchestration. These steps exercise ``SubplanService.spawn()`` and verify that the end-to-end orchestration creates real child ``Plan`` domain objects, wires parent-child identity, inherits project links, and correctly updates parent ``SubplanStatus`` entries on child completion/failure. """ from __future__ import annotations from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.application.services.subplan_service import ( SpawnEntry, SpawnResult, SubplanService, ) from cleveragents.domain.models.core.decision import ( ContextSnapshot, Decision, DecisionType, ) from cleveragents.domain.models.core.plan import ( ExecutionMode, NamespacedName, Plan, PlanIdentity, PlanPhase, ProcessingState, ProjectLink, SubplanConfig, SubplanStatus, ) _PARENT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6PN00" _ROOT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6RF00" _DECISION_ID_1: str = "01HGZ6FE0AQDYTR4BXVQZ6DA00" _DECISION_ID_2: str = "01HGZ6FE0AQDYTR4BXVQZ6DB00" def _mock_decision_service() -> MagicMock: """Create a mock DecisionService for SubplanService construction.""" svc: MagicMock = MagicMock() svc.list_by_type = MagicMock(return_value=[]) return svc def _make_parent_plan( execution_mode: ExecutionMode = ExecutionMode.SEQUENTIAL, ) -> Plan: """Create a parent plan suitable for subplan spawning.""" return Plan( identity=PlanIdentity( plan_id=_PARENT_PLAN_ID, root_plan_id=_ROOT_PLAN_ID, ), namespaced_name=NamespacedName(namespace="local", name="parent-plan"), description="Parent plan for subplan spawn orchestration test", action_name="local/parent-action", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.PROCESSING, project_links=[ ProjectLink(project_name="local/my-project"), ], subplan_config=SubplanConfig( execution_mode=execution_mode, ), ) def _make_spawn_entries() -> list[SpawnEntry]: """Create spawn entries for testing.""" dec1: Decision = Decision( decision_id=_DECISION_ID_1, plan_id=_PARENT_PLAN_ID, decision_type=DecisionType.SUBPLAN_SPAWN, sequence_number=0, question="Spawn child plan for module A?", chosen_option="local/sub-action-a", context_snapshot=ContextSnapshot(), ) dec2: Decision = Decision( decision_id=_DECISION_ID_2, plan_id=_PARENT_PLAN_ID, decision_type=DecisionType.SUBPLAN_SPAWN, sequence_number=1, question="Spawn child plan for module B?", chosen_option="local/sub-action-b", context_snapshot=ContextSnapshot(), ) return [ SpawnEntry(decision=dec1, action_name="local/sub-action-a"), SpawnEntry(decision=dec2, action_name="local/sub-action-b"), ] # -- Given steps (not duplicating tdd_subplan_spawn_orchestration_steps) ---- @given("a parent plan configured for parallel subplan spawning") def step_parallel_parent_plan(context: Context) -> None: """Set up a parent plan with parallel subplan configuration.""" context.parent_plan = _make_parent_plan( execution_mode=ExecutionMode.PARALLEL, ) context.subplan_service = SubplanService( decision_service=_mock_decision_service(), ) context.subplan_config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, ) # -- When steps --------------------------------------------------------------- @when('a child plan fails with error "{error_msg}"') def step_child_plan_fails(context: Context, error_msg: str) -> None: """Simulate a child plan failure by updating the parent's subplan status.""" result: SpawnResult = context.spawn_result parent: Plan = context.parent_plan if result.child_plans: child: Plan = result.child_plans[0] child_id: str = child.identity.plan_id # Update the matching subplan status on the parent for status in parent.subplan_statuses: if status.subplan_id == child_id: status.status = ProcessingState.ERRORED status.error = error_msg break context.affected_child_id = child_id @when( 'a child plan completes successfully with changeset summary "{summary}"', ) def step_child_plan_completes(context: Context, summary: str) -> None: """Simulate a child plan completion by updating the parent's subplan status.""" result: SpawnResult = context.spawn_result parent: Plan = context.parent_plan if result.child_plans: child: Plan = result.child_plans[0] child_id: str = child.identity.plan_id # Update the matching subplan status on the parent for status in parent.subplan_statuses: if status.subplan_id == child_id: status.status = ProcessingState.COMPLETE status.changeset_summary = summary status.files_changed = 1 break context.affected_child_id = child_id # -- Then steps --------------------------------------------------------------- @then("each child Plan root_plan_id should trace to the root plan") def step_child_plans_root_id(context: Context) -> None: """Assert child Plan objects reference the root plan.""" result: SpawnResult = context.spawn_result assert result.child_plans is not None, "No child_plans on SpawnResult" for child in result.child_plans: assert isinstance(child, Plan) assert child.identity.root_plan_id == _ROOT_PLAN_ID, ( f"Child plan root_plan_id={child.identity.root_plan_id!r}, " f"expected {_ROOT_PLAN_ID!r}" ) @then("each child plan should inherit the parent project links") def step_child_plans_inherit_project_links(context: Context) -> None: """Assert child plans carry the parent's project links.""" result: SpawnResult = context.spawn_result parent: Plan = context.parent_plan assert result.child_plans is not None, "No child_plans on SpawnResult" parent_project_names: list[str] = [ link.project_name for link in parent.project_links ] for child in result.child_plans: child_project_names: list[str] = [ link.project_name for link in child.project_links ] assert child_project_names == parent_project_names, ( f"Child project links {child_project_names!r} do not match " f"parent {parent_project_names!r}" ) @then("the parent plan should have pending subplan statuses") def step_parent_has_pending_statuses(context: Context) -> None: """Assert parent plan has queued subplan statuses after spawn.""" parent: Plan = context.parent_plan assert len(parent.subplan_statuses) > 0, "No subplan statuses on parent" for status in parent.subplan_statuses: assert status.status == ProcessingState.QUEUED, ( f"Expected QUEUED, got {status.status!r}" ) @then("the parent plan subplan_statuses count should equal spawn count") def step_parent_status_count_matches(context: Context) -> None: """Assert the number of subplan statuses equals the spawn entries.""" parent: Plan = context.parent_plan entries: list[SpawnEntry] = context.spawn_entries assert len(parent.subplan_statuses) == len(entries), ( f"Parent has {len(parent.subplan_statuses)} statuses, expected {len(entries)}" ) @then("the corresponding parent subplan status should be errored") def step_parent_status_errored(context: Context) -> None: """Assert the affected child's parent subplan status is errored.""" parent: Plan = context.parent_plan child_id: str = context.affected_child_id matching: list[SubplanStatus] = [ s for s in parent.subplan_statuses if s.subplan_id == child_id ] assert len(matching) == 1, f"Expected 1 matching status, got {len(matching)}" assert matching[0].status == ProcessingState.ERRORED @then('the parent subplan status error should contain "{fragment}"') def step_parent_status_error_contains(context: Context, fragment: str) -> None: """Assert the error message contains the expected fragment.""" parent: Plan = context.parent_plan child_id: str = context.affected_child_id matching: list[SubplanStatus] = [ s for s in parent.subplan_statuses if s.subplan_id == child_id ] assert len(matching) == 1 assert matching[0].error is not None, "Error is None" assert fragment in matching[0].error, ( f"Expected '{fragment}' in error '{matching[0].error}'" ) @then("the corresponding parent subplan status should be complete") def step_parent_status_complete(context: Context) -> None: """Assert the affected child's parent subplan status is complete.""" parent: Plan = context.parent_plan child_id: str = context.affected_child_id matching: list[SubplanStatus] = [ s for s in parent.subplan_statuses if s.subplan_id == child_id ] assert len(matching) == 1 assert matching[0].status == ProcessingState.COMPLETE @then('the parent subplan status changeset summary should be "{summary}"') def step_parent_status_changeset_summary(context: Context, summary: str) -> None: """Assert the changeset summary matches.""" parent: Plan = context.parent_plan child_id: str = context.affected_child_id matching: list[SubplanStatus] = [ s for s in parent.subplan_statuses if s.subplan_id == child_id ] assert len(matching) == 1 assert matching[0].changeset_summary == summary, ( f"Expected summary '{summary}', got '{matching[0].changeset_summary}'" ) @then("the spawn result execution mode should be parallel") def step_result_mode_parallel(context: Context) -> None: """Assert the spawn result execution mode is parallel.""" result: SpawnResult = context.spawn_result assert result.execution_mode == ExecutionMode.PARALLEL, ( f"Expected PARALLEL, got {result.execution_mode!r}" ) @then("the spawn result child plan count should match entries") def step_result_child_count_matches(context: Context) -> None: """Assert child plan count matches spawn entries.""" result: SpawnResult = context.spawn_result entries: list[SpawnEntry] = context.spawn_entries assert len(result.child_plans) == len(entries), ( f"Expected {len(entries)} child plans, got {len(result.child_plans)}" )