"""Helper utilities for v3 plan lifecycle Robot integration tests. Covers the integration between: - Plan and Action domain models (plan.py, action.py) - PlanLifecycleService (plan_lifecycle_service.py) - Automation levels - Subplan support """ from __future__ import annotations import sys from datetime import UTC, datetime from cleveragents.application.services.plan_lifecycle_service import ( InvalidPhaseTransitionError, PlanLifecycleService, PlanNotReadyError, ) from cleveragents.config.settings import Settings from cleveragents.domain.models.core.action import ( Action, ActionArgument, ActionState, ArgumentRequirement, ArgumentType, ) from cleveragents.domain.models.core.plan import ( AutomationProfileProvenance, AutomationProfileRef, ExecutionMode, NamespacedName, PlanPhase, ProcessingState, ProjectLink, SubplanConfig, SubplanFailureHandler, SubplanMergeStrategy, SubplanStatus, can_transition, ) def _create_service() -> PlanLifecycleService: settings = Settings() return PlanLifecycleService(settings=settings) def _lifecycle_full_cycle() -> None: """Integration test: create action -> use -> strategize -> execute -> apply.""" service = _create_service() # Create action (state is AVAILABLE immediately per new spec) action = service.create_action( name="local/code-review", definition_of_done="All files reviewed and approved", strategy_actor="local/planner", execution_actor="local/executor", description="Code review action", ) assert action.state == ActionState.AVAILABLE, ( f"Expected AVAILABLE, got {action.state}" ) # Use action to create plan (enters STRATEGIZE, QUEUED) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], created_by="integration-test", ) assert plan.phase == PlanPhase.STRATEGIZE assert plan.state == ProcessingState.QUEUED plan_id = plan.identity.plan_id # Strategize lifecycle plan = service.start_strategize(plan_id) assert plan.state == ProcessingState.PROCESSING plan = service.complete_strategize(plan_id) assert plan.state == ProcessingState.COMPLETE # Execute transition plan = service.execute_plan(plan_id) assert plan.phase == PlanPhase.EXECUTE assert plan.state == ProcessingState.QUEUED plan = service.start_execute(plan_id) assert plan.state == ProcessingState.PROCESSING plan = service.complete_execute(plan_id) assert plan.state == ProcessingState.COMPLETE # Apply transition plan = service.apply_plan(plan_id) assert plan.phase == PlanPhase.APPLY assert plan.state == ProcessingState.QUEUED plan = service.start_apply(plan_id) assert plan.state == ProcessingState.PROCESSING plan = service.complete_apply(plan_id) assert plan.phase == PlanPhase.APPLY assert plan.state == ProcessingState.APPLIED assert plan.is_terminal print("lifecycle-full-cycle-ok") def _action_crud() -> None: """Integration test: action CRUD operations via service.""" service = _create_service() # Create multiple actions (both AVAILABLE immediately) a1 = service.create_action( name="local/action-one", description="Action one", definition_of_done="Done 1", strategy_actor="local/s1", execution_actor="local/e1", ) service.create_action( name="local/action-two", description="Action two", definition_of_done="Done 2", strategy_actor="local/s2", execution_actor="local/e2", ) # List all actions = service.list_actions() assert len(actions) == 2, f"Expected 2 actions, got {len(actions)}" # List by namespace local_actions = service.list_actions(namespace="local") assert len(local_actions) == 2 # Get by name found = service.get_action_by_name("local/action-one") assert str(found.namespaced_name) == str(a1.namespaced_name) # Archive archived = service.archive_action(str(a1.namespaced_name)) assert archived.state == ActionState.ARCHIVED # List by state available = service.list_actions(state=ActionState.AVAILABLE) assert len(available) == 1 # Only action-two remains available archived_list = service.list_actions(state=ActionState.ARCHIVED) assert len(archived_list) == 1 print("action-crud-ok") def _plan_cancellation() -> None: """Integration test: plan cancellation at various phases.""" service = _create_service() action = service.create_action( name="local/cancel-test", description="Cancel test action", definition_of_done="Test cancellation", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Cancel while in STRATEGIZE cancelled = service.cancel_plan(plan_id, reason="User requested") assert cancelled.state == ProcessingState.CANCELLED assert cancelled.error_message == "User requested" print("plan-cancellation-ok") def _plan_failure_recovery() -> None: """Integration test: plan failure and error states.""" service = _create_service() action = service.create_action( name="local/fail-test", description="Fail test action", definition_of_done="Test failure", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Start strategize then fail it service.start_strategize(plan_id) plan = service.fail_strategize(plan_id, "Resource unavailable") assert plan.state == ProcessingState.ERRORED assert plan.is_errored assert plan.error_message == "Resource unavailable" print("plan-failure-recovery-ok") def _invalid_transitions() -> None: """Integration test: invalid phase transitions raise proper errors.""" service = _create_service() action = service.create_action( name="local/invalid-trans", description="Invalid transitions test action", definition_of_done="Test invalid transitions", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Try to execute before completing strategize (should fail) try: service.execute_plan(plan_id) print("FAIL: Expected PlanNotReadyError") return except PlanNotReadyError: pass # Try to apply before execute (should fail) try: service.apply_plan(plan_id) print("FAIL: Expected InvalidPhaseTransitionError or PlanNotReadyError") return except (InvalidPhaseTransitionError, PlanNotReadyError): pass print("invalid-transitions-ok") def _automation_full_auto() -> None: """Integration test: full automation auto-progresses through all phases.""" service = _create_service() action = service.create_action( name="local/full-auto", description="Full automation test action", definition_of_done="Test full automation", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Set full-auto profile so auto_progress will advance the plan plan.automation_profile = AutomationProfileRef( profile_name="full-auto", provenance=AutomationProfileProvenance.PLAN, ) # Start and complete strategize - should auto-progress to EXECUTE service.start_strategize(plan_id) plan = service.complete_strategize(plan_id) # After complete_strategize with full-auto profile, auto_progress kicks in plan = service.get_plan(plan_id) assert plan.phase == PlanPhase.EXECUTE, f"Expected EXECUTE, got {plan.phase}" # Start and complete execute - should auto-progress to APPLY service.start_execute(plan_id) plan = service.complete_execute(plan_id) plan = service.get_plan(plan_id) assert plan.phase == PlanPhase.APPLY, f"Expected APPLY, got {plan.phase}" print("automation-full-auto-ok") def _automation_review_before_apply() -> None: """Integration test: review-before-apply pauses before apply.""" service = _create_service() action = service.create_action( name="local/review-mode", description="Review mode test action", definition_of_done="Test review mode", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Set "auto" profile (review-before-apply equivalent): # create_tool=0.0, select_tool=1.0 → advances after strategize but pauses at apply plan.automation_profile = AutomationProfileRef( profile_name="auto", provenance=AutomationProfileProvenance.PLAN, ) # Complete strategize - should auto-progress to EXECUTE service.start_strategize(plan_id) plan = service.complete_strategize(plan_id) plan = service.get_plan(plan_id) assert plan.phase == PlanPhase.EXECUTE, f"Expected EXECUTE, got {plan.phase}" # Complete execute - should NOT auto-progress to APPLY (requires review) service.start_execute(plan_id) plan = service.complete_execute(plan_id) plan = service.get_plan(plan_id) # In review/auto mode, execute completes but does not auto-transition to APPLY assert plan.phase == PlanPhase.EXECUTE, ( f"Expected EXECUTE (paused), got {plan.phase}" ) assert plan.state == ProcessingState.COMPLETE print("automation-review-before-apply-ok") def _automation_pause_resume() -> None: """Integration test: pause and resume automation.""" service = _create_service() action = service.create_action( name="local/pause-resume", description="Pause/resume test action", definition_of_done="Test pause/resume", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) plan_id = plan.identity.plan_id # Pause - should set profile to manual plan = service.pause_plan(plan_id) assert plan.automation_profile is not None assert plan.automation_profile.profile_name == "manual" # Resume with auto profile plan = service.resume_plan(plan_id, automation_profile="auto") assert plan.automation_profile is not None assert plan.automation_profile.profile_name == "auto" print("automation-pause-resume-ok") def _subplan_model() -> None: """Integration test: subplan configuration and failure handling.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY, max_parallel=3, fail_fast=True, timeout_per_subplan_seconds=600, retry_failed=True, max_retries=2, ) assert config.execution_mode == ExecutionMode.PARALLEL assert config.max_parallel == 3 # Create a subplan status (using valid ULID-format IDs) # Error strings must match RETRIABLE_FAILURES (PascalCase: TimeoutError, etc.) status = SubplanStatus( subplan_id="01KH72MDGQMPRW3R5WPZVB8KC1", action_name="local/sub-task", target_resources=["src/main.py"], status=ProcessingState.ERRORED, error="TimeoutError: Operation timed out", ) handler = SubplanFailureHandler() should_stop = handler.should_stop_others(config, status) assert should_stop is True, "fail_fast=True should stop others" should_retry = handler.should_retry(config, status) assert should_retry is True, "TimeoutError is retriable and retries enabled" # Non-retriable error (must match NON_RETRIABLE_ERRORS: ConfigurationError, etc.) status_perm = SubplanStatus( subplan_id="01KH72MDGQMPRW3R5WPZVB8KC2", action_name="local/sub-task", target_resources=["src/other.py"], status=ProcessingState.ERRORED, error="ConfigurationError: Invalid config", ) should_retry_perm = handler.should_retry(config, status_perm) assert should_retry_perm is False, "ConfigurationError is not retriable" print("subplan-model-ok") def _plan_with_subplans() -> None: """Integration test: plan with subplan config via service.""" service = _create_service() action = service.create_action( name="local/parent-action", description="Parent action with subplans", definition_of_done="Parent task with subplans", strategy_actor="local/s", execution_actor="local/e", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ProjectLink(project_name="proj-001")], ) # Add subplan config to verify Plan model accepts it plan_copy = plan.model_copy( update={ "subplan_config": SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, merge_strategy=SubplanMergeStrategy.SEQUENTIAL_APPLY, ), "subplan_statuses": [ SubplanStatus( subplan_id="01KH72MDGQMPRW3R5WPZVB8KC3", action_name="local/sub-task", target_resources=["src/main.py"], status=ProcessingState.QUEUED, ), ], } ) assert plan_copy.has_subplans assert plan_copy.subplan_config is not None assert plan_copy.subplan_config.execution_mode == ExecutionMode.SEQUENTIAL assert len(plan_copy.subplan_statuses) == 1 # The original plan has no parent => is_root_plan assert not plan_copy.is_subplan # no parent_plan_id set print("plan-with-subplans-ok") def _phase_transitions_model() -> None: """Integration test: verify can_transition function for all valid/invalid paths.""" # Valid transitions (four-phase: ACTION -> STRATEGIZE -> EXECUTE -> APPLY) assert can_transition(PlanPhase.ACTION, PlanPhase.STRATEGIZE) assert can_transition(PlanPhase.STRATEGIZE, PlanPhase.EXECUTE) assert can_transition(PlanPhase.EXECUTE, PlanPhase.APPLY) assert can_transition(PlanPhase.EXECUTE, PlanPhase.STRATEGIZE) assert can_transition(PlanPhase.APPLY, PlanPhase.STRATEGIZE) # Invalid transitions assert not can_transition(PlanPhase.STRATEGIZE, PlanPhase.APPLY) assert not can_transition(PlanPhase.ACTION, PlanPhase.EXECUTE) assert not can_transition(PlanPhase.ACTION, PlanPhase.APPLY) assert not can_transition(PlanPhase.APPLY, PlanPhase.EXECUTE) print("phase-transitions-ok") def _namespaced_name_parsing() -> None: """Integration test: NamespacedName parse and string round-trip.""" # Simple local name n1 = NamespacedName.parse("local/my-action") assert n1.namespace == "local" assert n1.name == "my-action" assert str(n1) == "local/my-action" # Server-prefixed name n2 = NamespacedName.parse("server:org/my-action") assert n2.server == "server" assert n2.namespace == "org" assert n2.name == "my-action" assert str(n2) == "server:org/my-action" # Default namespace n3 = NamespacedName.parse("standalone-action") assert n3.namespace == "local" assert n3.name == "standalone-action" print("namespaced-name-ok") def _action_argument_parsing() -> None: """Integration test: ActionArgument parse and validation.""" arg = ActionArgument.parse("target:str:required:The target directory") assert arg.name == "target" assert arg.arg_type.value == "string" assert arg.requirement.value == "required" assert arg.description == "The target directory" # Test Action argument validation action = Action( namespaced_name=NamespacedName.parse("local/test-action"), description="Test", definition_of_done="Done", strategy_actor="local/s", execution_actor="local/e", arguments=[arg], created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) # Valid arguments errors = action.validate_arguments({"target": "/tmp/dir"}) assert len(errors) == 0, f"Expected no errors, got {errors}" # Missing required argument errors = action.validate_arguments({}) assert len(errors) > 0, "Expected validation error for missing required arg" print("action-argument-ok") def _action_yaml_config_loading() -> None: """Integration test: Action.from_config() loads YAML fields and as_cli_dict() renders them.""" # Simulate a full YAML config dict with all supported fields config = { "name": "org/code-coverage", "description": "Increase code coverage to target level", "long_description": "Comprehensive code coverage improvement action", "definition_of_done": "Coverage meets ${target_coverage}% threshold", "strategy_actor": "openai/gpt-4", "execution_actor": "anthropic/claude-3", "review_actor": "local/reviewer", "apply_actor": "local/applier", "estimation_actor": "local/estimator", "invariant_actor": "local/invariant-checker", "automation_profile": "org/high-trust", "invariants": [ "No test deletions allowed", "Coverage must not decrease", "All new code must have tests", ], "arguments": [ { "name": "target_coverage", "type": "integer", "required": True, "description": "Target coverage percentage", "default": 80, "min_value": 0, "max_value": 100, }, { "name": "test_framework", "type": "string", "required": False, "description": "Test framework to use", }, { "name": "include_branches", "type": "boolean", "required": False, "description": "Include branch coverage", }, ], "reusable": True, "read_only": False, "tags": ["coverage", "testing"], "created_by": "yaml-test", } # --- Test from_config() loads all fields correctly --- action = Action.from_config(config) assert str(action.namespaced_name) == "org/code-coverage", ( f"Expected 'org/code-coverage', got '{action.namespaced_name}'" ) assert action.description == "Increase code coverage to target level" assert action.long_description == "Comprehensive code coverage improvement action" assert action.definition_of_done == "Coverage meets ${target_coverage}% threshold" assert action.strategy_actor == "openai/gpt-4" assert action.execution_actor == "anthropic/claude-3" assert action.review_actor == "local/reviewer" assert action.apply_actor == "local/applier" assert action.estimation_actor == "local/estimator" assert action.invariant_actor == "local/invariant-checker" assert action.automation_profile == "org/high-trust" assert action.reusable is True assert action.read_only is False assert action.state == ActionState.AVAILABLE assert action.created_by == "yaml-test" assert action.tags == ["coverage", "testing"] # Invariants assert len(action.invariants) == 3 assert action.invariants[0] == "No test deletions allowed" assert action.invariants[1] == "Coverage must not decrease" assert action.invariants[2] == "All new code must have tests" # Arguments assert len(action.arguments) == 3 arg_coverage = action.arguments[0] assert arg_coverage.name == "target_coverage" assert arg_coverage.arg_type == ArgumentType.INTEGER assert arg_coverage.requirement == ArgumentRequirement.REQUIRED assert arg_coverage.description == "Target coverage percentage" assert arg_coverage.default_value == 80 assert arg_coverage.min_value == 0 assert arg_coverage.max_value == 100 arg_framework = action.arguments[1] assert arg_framework.name == "test_framework" assert arg_framework.arg_type == ArgumentType.STRING assert arg_framework.requirement == ArgumentRequirement.OPTIONAL arg_branches = action.arguments[2] assert arg_branches.name == "include_branches" assert arg_branches.arg_type == ArgumentType.BOOLEAN assert arg_branches.requirement == ArgumentRequirement.OPTIONAL # --- Test as_cli_dict() renders expected keys --- cli = action.as_cli_dict() # Core identity assert cli["name"] == "org/code-coverage" assert cli["description"] == "Increase code coverage to target level" assert cli["state"] == "available" # Actors assert cli["strategy_actor"] == "openai/gpt-4" assert cli["execution_actor"] == "anthropic/claude-3" assert cli["review_actor"] == "local/reviewer" assert cli["apply_actor"] == "local/applier" assert cli["estimation_actor"] == "local/estimator" assert cli["invariant_actor"] == "local/invariant-checker" # Automation profile assert "automation_profile" in cli, "automation_profile missing from cli dict" assert cli["automation_profile"] == "org/high-trust" # Invariants assert "invariants" in cli, "invariants missing from cli dict" assert len(cli["invariants"]) == 3 assert cli["invariants"][0] == "No test deletions allowed" assert cli["invariants"][2] == "All new code must have tests" # Arguments rendered assert "arguments" in cli, "arguments missing from cli dict" assert len(cli["arguments"]) == 3 assert cli["arguments"][0]["name"] == "target_coverage" assert cli["arguments"][0]["type"] == "integer" assert cli["arguments"][0]["required"] is True assert cli["arguments"][1]["name"] == "test_framework" assert cli["arguments"][1]["required"] is False assert cli["arguments"][2]["name"] == "include_branches" assert cli["arguments"][2]["type"] == "boolean" # Behavior flags assert cli["reusable"] is True assert cli["read_only"] is False # Tags assert cli["tags"] == ["coverage", "testing"] # Definition of done assert cli["definition_of_done"] == "Coverage meets ${target_coverage}% threshold" print("action-yaml-config-ok") def _plan_status_rendering() -> None: """Plan status renders action_name + automation profile. Verifies that Plan.as_cli_dict() includes the correct action_name and automation_profile fields with provenance tags. """ service = _create_service() action = service.create_action( name="local/status-test", description="Status rendering test action", definition_of_done="Test plan status rendering", strategy_actor="local/s", execution_actor="local/e", automation_profile="manual", ) plan = service.use_action( action_name=str(action.namespaced_name), project_links=[ ProjectLink(project_name="local/api-service", alias="api"), ], ) # Verify action_name is set on the plan assert plan.action_name, "Plan must have action_name set" assert "status-test" in plan.action_name, ( f"Expected action_name to contain 'status-test', got '{plan.action_name}'" ) # Verify as_cli_dict renders action_name cli_dict = plan.as_cli_dict() assert "action" in cli_dict, "CLI dict must include 'action' key" assert cli_dict["action"] == plan.action_name # Verify phase and state are rendered assert cli_dict["phase"] == "strategize" assert cli_dict["state"] == "queued" # Verify projects are rendered assert "projects" in cli_dict, "CLI dict must include 'projects' key" assert len(cli_dict["projects"]) == 1 assert cli_dict["projects"][0]["name"] == "local/api-service" assert cli_dict["projects"][0]["alias"] == "api" # If automation profile was resolved, verify provenance if plan.automation_profile: assert "automation_profile" in cli_dict assert "automation_profile_source" in cli_dict assert cli_dict["automation_profile_source"] in ( "plan", "action", "project", "global", ) # Verify timestamps are present assert "created_at" in cli_dict assert "updated_at" in cli_dict print("plan-status-rendering-ok") def main() -> None: if len(sys.argv) < 2: raise SystemExit("Expected command argument") command = sys.argv[1] commands: dict[str, object] = { "lifecycle-full-cycle": _lifecycle_full_cycle, "action-crud": _action_crud, "plan-cancellation": _plan_cancellation, "plan-failure-recovery": _plan_failure_recovery, "invalid-transitions": _invalid_transitions, "automation-full-auto": _automation_full_auto, "automation-review-before-apply": _automation_review_before_apply, "automation-pause-resume": _automation_pause_resume, "subplan-model": _subplan_model, "plan-with-subplans": _plan_with_subplans, "phase-transitions": _phase_transitions_model, "namespaced-name": _namespaced_name_parsing, "action-argument": _action_argument_parsing, "action-yaml-config": _action_yaml_config_loading, "plan-status-rendering": _plan_status_rendering, } if command not in commands: raise SystemExit(f"Unknown command: {command}") func = commands[command] if callable(func): func() if __name__ == "__main__": main()