"""Step definitions for plan_executor_new_coverage.feature. Covers uncovered branches and lines in plan_executor.py: - PlanExecutor.__init__ with None lifecycle_service - has_runtime / changeset_store properties - run_strategize guard branches (wrong phase, empty plan_id, exception path) - _guard_execute branches (wrong phase, wrong state, None decision_root_id) - run_execute routing (stub vs runtime) - StrategizeStubActor / ExecuteStubActor with and without stream_callback """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock from behave import given, then, when from ulid import ULID from cleveragents.application.services.plan_execution_context import ( PlanExecutionContext, RuntimeExecuteResult, ) from cleveragents.application.services.plan_executor import ( ExecuteResult, ExecuteStubActor, PlanExecutor, StrategizeStubActor, StrategyDecision, ) from cleveragents.core.exceptions import PlanError, ValidationError from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState from cleveragents.tool.registry import ToolRegistry from cleveragents.tool.runner import ToolRunner # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_plan_mock( phase: PlanPhase = PlanPhase.STRATEGIZE, state: ProcessingState = ProcessingState.QUEUED, decision_root_id: str | None = None, definition_of_done: str = "Step one\nStep two", invariants: list | None = None, ) -> MagicMock: """Create a mock Plan object with the given attributes.""" plan = MagicMock() plan.phase = phase plan.state = state plan.decision_root_id = decision_root_id plan.definition_of_done = definition_of_done plan.invariants = invariants or [] plan.timestamps = MagicMock() plan.error_details = {} plan.changeset_id = None plan.sandbox_refs = [] plan.namespaced_name = "test/plan" plan.identity = MagicMock() return plan def _make_lifecycle(**overrides: Any) -> MagicMock: """Create a mock lifecycle service with default methods.""" lc = MagicMock() lc.start_strategize = MagicMock() lc.complete_strategize = MagicMock() lc.fail_strategize = MagicMock() lc.start_execute = MagicMock() lc.complete_execute = MagicMock() lc.fail_execute = MagicMock() lc._commit_plan = MagicMock() for key, value in overrides.items(): setattr(lc, key, value) return lc def _make_decisions(count: int = 2) -> list[StrategyDecision]: root_id = str(ULID()) decisions = [] for i in range(count): decisions.append( StrategyDecision( decision_id=root_id if i == 0 else str(ULID()), step_text=f"Step {i + 1}", sequence=i, parent_id=root_id if i > 0 else None, ) ) return decisions # --------------------------------------------------------------------------- # PlanExecutor __init__ validation # --------------------------------------------------------------------------- @when("I construct a PlanExecutor with None lifecycle_service") def step_construct_pe_none_lc(ctx: Any) -> None: ctx.pe_error = None try: PlanExecutor(lifecycle_service=None) except ValidationError as exc: ctx.pe_error = exc @then('a PE ValidationError should be raised containing "{text}"') def step_assert_pe_validation_error_contains(ctx: Any, text: str) -> None: assert ctx.pe_error is not None, "Expected a ValidationError but none was raised" assert isinstance(ctx.pe_error, ValidationError), ( f"Expected ValidationError, got {type(ctx.pe_error).__name__}" ) assert text in str(ctx.pe_error), ( f"Expected '{text}' in error message, got: {ctx.pe_error}" ) @given("a mock plan lifecycle service for plan executor") def step_given_mock_lifecycle(ctx: Any) -> None: ctx.lifecycle = _make_lifecycle() ctx.plan_id = str(ULID()) @when("I construct a PlanExecutor with the mock lifecycle service") def step_construct_pe_with_lc(ctx: Any) -> None: ctx.pe_error = None try: ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle) except Exception as exc: ctx.pe_error = exc @then("the PlanExecutor should be created without error") def step_assert_no_error(ctx: Any) -> None: assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}" assert ctx.executor is not None # --------------------------------------------------------------------------- # has_runtime property # --------------------------------------------------------------------------- @given("a mock execution context") def step_given_mock_exec_ctx(ctx: Any) -> None: ctx.exec_ctx = PlanExecutionContext(plan_id=ctx.plan_id) @when( "I construct a PlanExecutor with the mock lifecycle service and execution context" ) def step_construct_pe_with_ctx(ctx: Any) -> None: ctx.executor = PlanExecutor( lifecycle_service=ctx.lifecycle, execution_context=ctx.exec_ctx, ) @then("the executor has_runtime property should be True") def step_assert_has_runtime_true(ctx: Any) -> None: assert ctx.executor.has_runtime is True @then("the executor has_runtime property should be False") def step_assert_has_runtime_false(ctx: Any) -> None: assert ctx.executor.has_runtime is False # --------------------------------------------------------------------------- # changeset_store property # --------------------------------------------------------------------------- @given("a mock execution context with a changeset store") def step_given_exec_ctx_with_store(ctx: Any) -> None: ctx.mock_store = MagicMock() ctx.exec_ctx = MagicMock() ctx.exec_ctx.changeset_store = ctx.mock_store ctx.exec_ctx.decision_root_id = None @then("the executor changeset_store should be the mock store") def step_assert_changeset_store_is_mock(ctx: Any) -> None: assert ctx.executor.changeset_store is ctx.mock_store @then("the executor changeset_store should be None") def step_assert_changeset_store_none(ctx: Any) -> None: assert ctx.executor.changeset_store is None # --------------------------------------------------------------------------- # run_strategize guard: wrong phase # --------------------------------------------------------------------------- @given("a PlanExecutor with the mock lifecycle service") def step_given_pe_with_lc(ctx: Any) -> None: ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle) @given("the lifecycle returns a plan in EXECUTE phase") def step_lifecycle_returns_execute_phase(ctx: Any) -> None: plan = _make_plan_mock(phase=PlanPhase.EXECUTE) ctx.lifecycle.get_plan = MagicMock(return_value=plan) @when("I attempt to run strategize on the executor") def step_attempt_run_strategize(ctx: Any) -> None: ctx.pe_error = None try: ctx.executor.run_strategize(ctx.plan_id) except (PlanError, ValidationError) as exc: ctx.pe_error = exc @then('a PE PlanError should be raised containing "{text}"') def step_assert_pe_plan_error_contains(ctx: Any, text: str) -> None: assert ctx.pe_error is not None, "Expected a PlanError but none was raised" assert isinstance(ctx.pe_error, PlanError), ( f"Expected PlanError, got {type(ctx.pe_error).__name__}" ) assert text in str(ctx.pe_error), ( f"Expected '{text}' in error message, got: {ctx.pe_error}" ) # --------------------------------------------------------------------------- # run_strategize guard: empty plan_id # --------------------------------------------------------------------------- @when("I attempt to run strategize with an empty plan_id") def step_attempt_strategize_empty_plan_id(ctx: Any) -> None: ctx.pe_error = None try: ctx.executor.run_strategize("") except (ValidationError, PlanError) as exc: ctx.pe_error = exc # --------------------------------------------------------------------------- # run_strategize exception handling # --------------------------------------------------------------------------- @given("the lifecycle returns a plan in STRATEGIZE phase") def step_lifecycle_returns_strategize_phase(ctx: Any) -> None: plan = _make_plan_mock( phase=PlanPhase.STRATEGIZE, definition_of_done="Step one\nStep two", ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) ctx.mock_plan = plan @given("the lifecycle start_strategize will raise a RuntimeError") def step_lifecycle_start_strategize_raises(ctx: Any) -> None: """Make the strategize actor itself raise inside the try block. start_strategize is called *before* the try, so we need to make the actor.execute raise instead to hit the except branch (line 314). """ ctx._patch_strategize_actor_to_fail = True @when("I attempt to run strategize and expect an exception") def step_attempt_strategize_expect_exception(ctx: Any) -> None: # If the actor should fail, patch it before running if getattr(ctx, "_patch_strategize_actor_to_fail", False): ctx.executor._strategize_actor.execute = MagicMock( side_effect=RuntimeError("simulated actor failure") ) if getattr(ctx, "_patch_actor_to_fail", False): ctx.executor._strategize_actor.execute = MagicMock( side_effect=RuntimeError("simulated actor failure") ) ctx.pe_error = None ctx.raised_exception = None try: ctx.executor.run_strategize(ctx.plan_id) except Exception as exc: ctx.pe_error = exc ctx.raised_exception = exc @then("fail_strategize should have been called with the plan_id") def step_assert_fail_strategize_called(ctx: Any) -> None: ctx.lifecycle.fail_strategize.assert_called() call_args = ctx.lifecycle.fail_strategize.call_args assert ctx.plan_id == call_args[0][0], ( f"Expected plan_id={ctx.plan_id}, got {call_args[0][0]}" ) @then("the raised exception should be a RuntimeError") def step_assert_raised_runtime_error(ctx: Any) -> None: assert ctx.raised_exception is not None, "No exception was raised" assert isinstance(ctx.raised_exception, RuntimeError), ( f"Expected RuntimeError, got {type(ctx.raised_exception).__name__}" ) # --------------------------------------------------------------------------- # run_strategize exception during actor execute (except branch line 314) # --------------------------------------------------------------------------- @given( "the lifecycle returns a plan in STRATEGIZE phase that fails during actor execute" ) def step_lifecycle_strategize_fails_in_actor(ctx: Any) -> None: """Set up lifecycle so start_strategize succeeds but actor.execute raises.""" plan = _make_plan_mock( phase=PlanPhase.STRATEGIZE, definition_of_done="Step one\nStep two", ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) ctx.lifecycle.start_strategize = MagicMock() # succeeds ctx.mock_plan = plan ctx._patch_actor_to_fail = True @then("the plan error_details should contain the exception_type") def step_assert_error_details_exception_type(ctx: Any) -> None: plan = ctx.lifecycle.get_plan.return_value assert plan.error_details is not None if isinstance(plan.error_details, dict): assert "exception_type" in plan.error_details # --------------------------------------------------------------------------- # _guard_execute: wrong phase # --------------------------------------------------------------------------- @given("the lifecycle returns a plan in STRATEGIZE phase for execute guard") def step_lifecycle_returns_strategize_for_execute(ctx: Any) -> None: plan = _make_plan_mock(phase=PlanPhase.STRATEGIZE) ctx.lifecycle.get_plan = MagicMock(return_value=plan) @when("I attempt to run execute on the executor") def step_attempt_run_execute(ctx: Any) -> None: ctx.pe_error = None try: ctx.executor.run_execute(ctx.plan_id) except (PlanError, ValidationError) as exc: ctx.pe_error = exc # --------------------------------------------------------------------------- # _guard_execute: wrong state # --------------------------------------------------------------------------- @given("the lifecycle returns a plan in EXECUTE phase with PROCESSING state") def step_lifecycle_returns_execute_processing(ctx: Any) -> None: plan = _make_plan_mock( phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING, ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) # --------------------------------------------------------------------------- # _guard_execute: None decision_root_id # --------------------------------------------------------------------------- @given( "the lifecycle returns a plan in EXECUTE phase with QUEUED state but no decision root" ) def step_lifecycle_returns_execute_queued_no_root(ctx: Any) -> None: plan = _make_plan_mock( phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, decision_root_id=None, ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) # --------------------------------------------------------------------------- # run_execute routing to stub # --------------------------------------------------------------------------- @given("a PlanExecutor with the mock lifecycle service and no execution context") def step_given_pe_no_ctx(ctx: Any) -> None: ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle) @given("the lifecycle returns a fully valid plan for stub execution") def step_lifecycle_returns_valid_plan_for_stub(ctx: Any) -> None: root_id = str(ULID()) plan = _make_plan_mock( phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, decision_root_id=root_id, definition_of_done="Do thing one\nDo thing two", ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) ctx.mock_plan = plan @when("I run execute on the executor") def step_run_execute(ctx: Any) -> None: ctx.pe_error = None try: ctx.result = ctx.executor.run_execute(ctx.plan_id) except Exception as exc: ctx.pe_error = exc @then("the plan executor result should be an ExecuteResult") def step_assert_execute_result_type(ctx: Any) -> None: assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}" assert isinstance(ctx.result, ExecuteResult), ( f"Expected ExecuteResult, got {type(ctx.result).__name__}" ) @then("complete_execute should have been called") def step_assert_complete_execute_called(ctx: Any) -> None: ctx.lifecycle.complete_execute.assert_called_once() # --------------------------------------------------------------------------- # run_execute routing to runtime # --------------------------------------------------------------------------- @given( "a PlanExecutor with the mock lifecycle service and execution context and tool runner" ) def step_given_pe_with_ctx_and_runner(ctx: Any) -> None: ctx.tool_runner = ToolRunner(registry=ToolRegistry()) ctx.executor = PlanExecutor( lifecycle_service=ctx.lifecycle, tool_runner=ctx.tool_runner, execution_context=ctx.exec_ctx, ) @given("the lifecycle returns a fully valid plan for runtime execution") def step_lifecycle_returns_valid_plan_for_runtime(ctx: Any) -> None: root_id = str(ULID()) plan = _make_plan_mock( phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, decision_root_id=root_id, definition_of_done="Do thing one\nDo thing two", ) ctx.lifecycle.get_plan = MagicMock(return_value=plan) ctx.mock_plan = plan @then("the plan executor result should be a RuntimeExecuteResult") def step_assert_runtime_execute_result_type(ctx: Any) -> None: assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}" assert isinstance(ctx.result, RuntimeExecuteResult), ( f"Expected RuntimeExecuteResult, got {type(ctx.result).__name__}" ) # --------------------------------------------------------------------------- # StrategizeStubActor with stream_callback # --------------------------------------------------------------------------- @given("a stream event collector") def step_given_stream_collector(ctx: Any) -> None: ctx.stream_events = [] ctx.stream_callback = lambda event_type, data: ctx.stream_events.append( (event_type, data) ) @when("I execute the StrategizeStubActor with a plan_id and stream callback") def step_exec_strategize_actor_with_cb(ctx: Any) -> None: actor = StrategizeStubActor() plan_id = str(ULID()) ctx.actor_plan_id = plan_id ctx.strategize_result = actor.execute( plan_id=plan_id, definition_of_done="Step A\nStep B", stream_callback=ctx.stream_callback, ) @then('the collector should contain a "{event_type}" event') def step_assert_collector_contains_event(ctx: Any, event_type: str) -> None: event_types = [e[0] for e in ctx.stream_events] assert event_type in event_types, ( f"Expected '{event_type}' in events, got: {event_types}" ) @when("I execute the StrategizeStubActor with a plan_id and no callback") def step_exec_strategize_actor_no_cb(ctx: Any) -> None: actor = StrategizeStubActor() plan_id = str(ULID()) ctx.strategize_result = actor.execute( plan_id=plan_id, definition_of_done="Step A\nStep B", stream_callback=None, ) @then("the strategize result should have a valid decision_root_id") def step_assert_strategize_result_root_id(ctx: Any) -> None: assert ctx.strategize_result.decision_root_id assert len(ctx.strategize_result.decision_root_id) == 26 @then("the strategize result should have at least one decision") def step_assert_strategize_result_has_decisions(ctx: Any) -> None: assert len(ctx.strategize_result.decisions) >= 1 # --------------------------------------------------------------------------- # ExecuteStubActor with stream_callback # --------------------------------------------------------------------------- @given("a list of two strategy decisions") def step_given_two_decisions(ctx: Any) -> None: ctx.decisions = _make_decisions(2) @when("I execute the ExecuteStubActor with the decisions and stream callback") def step_exec_execute_actor_with_cb(ctx: Any) -> None: actor = ExecuteStubActor() plan_id = str(ULID()) ctx.actor_plan_id = plan_id ctx.execute_result = actor.execute( plan_id=plan_id, decisions=ctx.decisions, stream_callback=ctx.stream_callback, ) @then('the collector should contain an "{event_type}" event') def step_assert_collector_contains_event_an(ctx: Any, event_type: str) -> None: event_types = [e[0] for e in ctx.stream_events] assert event_type in event_types, ( f"Expected '{event_type}' in events, got: {event_types}" ) @when("I execute the ExecuteStubActor with the decisions and no callback") def step_exec_execute_actor_no_cb(ctx: Any) -> None: actor = ExecuteStubActor() plan_id = str(ULID()) ctx.execute_result = actor.execute( plan_id=plan_id, decisions=ctx.decisions, stream_callback=None, ) @then("the execute result should have a valid changeset_id") def step_assert_execute_result_changeset_id(ctx: Any) -> None: assert ctx.execute_result.changeset_id assert len(ctx.execute_result.changeset_id) == 26 @then("the execute result tool_calls_count should be zero") def step_assert_execute_result_tool_calls_zero(ctx: Any) -> None: assert ctx.execute_result.tool_calls_count == 0 # --------------------------------------------------------------------------- # run_strategize happy path with stream_callback # --------------------------------------------------------------------------- @when("I run strategize with the stream callback") def step_run_strategize_with_callback(ctx: Any) -> None: ctx.pe_error = None try: ctx.strategize_result = ctx.executor.run_strategize( ctx.plan_id, stream_callback=ctx.stream_callback, ) except Exception as exc: ctx.pe_error = exc @then("the strategize result should have decisions") def step_assert_result_has_decisions(ctx: Any) -> None: assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}" assert len(ctx.strategize_result.decisions) >= 1 @then("complete_strategize should have been called") def step_assert_complete_strategize_called(ctx: Any) -> None: ctx.lifecycle.complete_strategize.assert_called_once() # --------------------------------------------------------------------------- # PlanExecutor with execution context (for run_strategize + context scenario) # --------------------------------------------------------------------------- @given("a PlanExecutor with the mock lifecycle and execution context") def step_given_pe_with_lc_and_ctx(ctx: Any) -> None: ctx.executor = PlanExecutor( lifecycle_service=ctx.lifecycle, execution_context=ctx.exec_ctx, ) # --------------------------------------------------------------------------- # run_strategize sets execution_context.decision_root_id # --------------------------------------------------------------------------- @when("I run strategize successfully") def step_run_strategize_successfully(ctx: Any) -> None: ctx.pe_error = None try: ctx.strategize_result = ctx.executor.run_strategize(ctx.plan_id) except Exception as exc: ctx.pe_error = exc @then("the execution context decision_root_id should be set") def step_assert_exec_ctx_decision_root_id_set(ctx: Any) -> None: assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}" assert ctx.exec_ctx.decision_root_id is not None assert ctx.exec_ctx.decision_root_id == ctx.strategize_result.decision_root_id # --------------------------------------------------------------------------- # run_execute stub exception path # --------------------------------------------------------------------------- @given("the stub execute actor is patched to raise an error") def step_patch_stub_execute_to_fail(ctx: Any) -> None: ctx.executor._execute_actor.execute = MagicMock( side_effect=RuntimeError("simulated stub execution failure") ) @when("I attempt to run execute and expect an exception") def step_attempt_run_execute_expect_exception(ctx: Any) -> None: ctx.pe_error = None ctx.raised_exception = None try: ctx.result = ctx.executor.run_execute(ctx.plan_id) except Exception as exc: ctx.pe_error = exc ctx.raised_exception = exc @then("fail_execute should have been called with the plan_id") def step_assert_fail_execute_called(ctx: Any) -> None: ctx.lifecycle.fail_execute.assert_called() call_args = ctx.lifecycle.fail_execute.call_args assert ctx.plan_id == call_args[0][0], ( f"Expected plan_id={ctx.plan_id}, got {call_args[0][0]}" )