Files
cleveragents-core/features/steps/plan_cli_coverage_steps.py
T
freemo 48cff5cfe0
CI / build (push) Successful in 18s
CI / lint (push) Failing after 31s
CI / helm (push) Successful in 33s
CI / typecheck (push) Successful in 50s
CI / security (push) Failing after 51s
CI / coverage (push) Has been skipped
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Failing after 1m50s
CI / docker (push) Has been skipped
CI / quality (push) Successful in 3m43s
CI / integration_tests (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / status-check (push) Has been cancelled
refactor(cli): rename plan lifecycle-list and lifecycle-apply to match specification
Renames `plan lifecycle-list` to `plan list` and `plan lifecycle-apply` to `plan apply` to align with the specification's canonical command names. Removes legacy V2 plan commands that occupied those names.

- Renamed CLI command registrations from lifecycle-list/lifecycle-apply to list/apply
- Removed legacy V2 apply and list commands (~200 lines)
- Updated apply shortcut in main.py to delegate to v3 lifecycle
- Added defensive null check for plan existence in apply command
- Updated 63+ test, doc, and benchmark files for consistency

Closes #881

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
2026-04-02 19:09:04 +00:00

513 lines
18 KiB
Python

"""Step definitions for plan_cli_coverage.feature.
Covers uncovered lines/branches in cleveragents/cli/commands/plan.py:
- Lines 818, 827-828: legacy apply Progress block and PlanError handler
- Branch 999: legacy list when plan.current is False
- Lines 1017-1035: legacy cd command full body
- Lines 1314-1315: invariant_actor parameter in use_action
- Lines 1391-1392: building PlanInvariant list from --invariant flags
- Lines 1573-1578: apply auto-discovery with 0 plans
- Lines 1594-1596: apply read-only guard
- Lines 1700-1701: status CleverAgentsError handler
- Branch 1738: errors command when has_error_recovery is False
"""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import CleverAgentsError, PlanError
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA"
_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB"
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project"
def _make_lifecycle_plan(
*,
plan_id: str = _ULID_A,
name: str = "local/test-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
error_message: str | None = None,
error_details: dict[str, str] | None = None,
read_only: bool = False,
) -> Plan:
"""Build a real Plan domain object for testing."""
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name="local/test-action",
description="Coverage test plan",
definition_of_done=None,
strategy_actor=None,
execution_actor=None,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 0, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
),
error_message=error_message,
error_details=error_details,
reusable=True,
read_only=read_only,
created_by=None,
)
def _make_legacy_project() -> SimpleNamespace:
"""Build a minimal project stub for legacy commands."""
return SimpleNamespace(name="test-project", path="/tmp/test")
def _make_legacy_plan(
*,
name: str = "my-plan",
status: str = "active",
current: bool = True,
) -> SimpleNamespace:
"""Build a minimal legacy plan stub."""
return SimpleNamespace(
name=name,
status=status,
current=current,
created_at=datetime(2025, 1, 1),
prompt="do stuff",
)
# ----------------------------------------------------------------------
# Given — CLI runner
# ----------------------------------------------------------------------
@given("a plan-cov CLI runner")
def step_plan_cov_cli_runner(context: Context) -> None:
"""Initialise a CliRunner and cleanup list."""
context.plan_cov_runner = CliRunner()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
# ----------------------------------------------------------------------
# Given — Legacy apply mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov apply that succeeds")
def step_mock_apply_success(context: Context) -> None:
"""Set up mocks so legacy apply command succeeds through Progress block."""
context.plan_cov_apply_changes_return = 1
context.plan_cov_apply_changes_side_effect = None
@given("a mocked legacy container for plan-cov apply that raises PlanError")
def step_mock_apply_plan_error(context: Context) -> None:
"""Set up mocks so legacy apply raises PlanError during apply_changes."""
context.plan_cov_apply_changes_return = None
context.plan_cov_apply_changes_side_effect = PlanError("Sandbox conflict")
# ----------------------------------------------------------------------
# Given — Legacy list mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov list with non-current plans")
def step_mock_list_non_current(context: Context) -> None:
"""Mark that legacy list should produce plans with current=False."""
context.plan_cov_list_plans = [
_make_legacy_plan(name="alpha", current=False),
_make_legacy_plan(name="beta", current=False),
]
# ----------------------------------------------------------------------
# Given — Legacy cd mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov cd that succeeds")
def step_mock_cd_success(context: Context) -> None:
"""Mark that legacy cd should succeed."""
context.plan_cov_cd_return = _make_legacy_plan(name="my-plan")
context.plan_cov_cd_side_effect = None
@given("a mocked legacy container for plan-cov cd that raises CleverAgentsError")
def step_mock_cd_error(context: Context) -> None:
"""Mark that legacy cd should raise CleverAgentsError."""
context.plan_cov_cd_return = None
context.plan_cov_cd_side_effect = CleverAgentsError("not found")
# ----------------------------------------------------------------------
# Given — Lifecycle service for use_action
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov use action")
def step_mock_lifecycle_for_use(context: Context) -> None:
"""Set up lifecycle service mock for the use command."""
mock_service = MagicMock()
mock_action = MagicMock()
mock_action.namespaced_name = "local/test-action"
mock_service.get_action_by_name.return_value = mock_action
mock_plan = _make_lifecycle_plan()
mock_service.use_action.return_value = mock_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
context.plan_cov_service = mock_service
context.plan_cov_plan = mock_plan
# ----------------------------------------------------------------------
# Given — Lifecycle service for apply
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov that returns no execute-complete plans")
def step_mock_apply_no_plans(context: Context) -> None:
"""Set up lifecycle service that returns no complete plans in Execute phase."""
mock_service = MagicMock()
mock_service.list_plans.return_value = []
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given(
"a mocked lifecycle service for plan-cov that returns multiple execute-complete plans"
)
def step_mock_apply_multiple_plans(context: Context) -> None:
"""Set up lifecycle service that returns multiple complete Execute plans."""
mock_service = MagicMock()
plan_a = _make_lifecycle_plan(
plan_id=_ULID_A,
name="local/plan-a",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
plan_b = _make_lifecycle_plan(
plan_id=_ULID_B,
name="local/plan-b",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
mock_service.list_plans.return_value = [plan_a, plan_b]
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov that returns a read-only plan")
def step_mock_apply_readonly(context: Context) -> None:
"""Set up lifecycle service that returns a read-only plan."""
mock_service = MagicMock()
ro_plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
read_only=True,
)
mock_service.get_plan.return_value = ro_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for status command error
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov status that raises CleverAgentsError")
def step_mock_lifecycle_status_error(context: Context) -> None:
"""Set up lifecycle service that raises CleverAgentsError on get_plan."""
mock_service = MagicMock()
mock_service.get_plan.side_effect = CleverAgentsError("service unavailable")
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for errors command
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov errors with no error_category")
def step_mock_lifecycle_errors_no_category(context: Context) -> None:
"""Set up lifecycle service returning a plan with error_details lacking error_category."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.ERRORED,
error_message="Something went wrong",
error_details={"error_phase": "execute"},
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov errors with empty details")
def step_mock_lifecycle_errors_empty_details(context: Context) -> None:
"""Set up lifecycle service returning a plan with no error_details or error_message."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.QUEUED,
error_message=None,
error_details=None,
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# When — Legacy apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy apply with --yes")
def step_invoke_legacy_apply(context: Context) -> None:
"""Invoke the legacy apply command with auto-confirm."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.get_pending_changes.return_value = [
SimpleNamespace(operation="modify", file_path="a.py"),
]
if context.plan_cov_apply_changes_side_effect:
mock_plan_service.apply_changes.side_effect = (
context.plan_cov_apply_changes_side_effect
)
else:
mock_plan_service.apply_changes.return_value = (
context.plan_cov_apply_changes_return
)
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["apply", "--yes"]
)
# ----------------------------------------------------------------------
# When — Legacy list
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy list")
def step_invoke_legacy_list(context: Context) -> None:
"""Invoke the legacy list command in rich format."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.list_plans.return_value = context.plan_cov_list_plans
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["list"])
# ----------------------------------------------------------------------
# When — Legacy cd
# ----------------------------------------------------------------------
@when('I invoke plan-cov legacy cd "{name}"')
def step_invoke_legacy_cd(context: Context, name: str) -> None:
"""Invoke the legacy cd command with a plan name."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
if context.plan_cov_cd_side_effect:
mock_plan_service.switch_to_plan.side_effect = context.plan_cov_cd_side_effect
else:
mock_plan_service.switch_to_plan.return_value = context.plan_cov_cd_return
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["cd", name])
# ----------------------------------------------------------------------
# When — use action with actor overrides
# ----------------------------------------------------------------------
@when('I invoke plan-cov use with estimation-actor "{est}" and invariant-actor "{inv}"')
def step_invoke_use_actor_overrides(context: Context, est: str, inv: str) -> None:
"""Invoke use command with estimation-actor and invariant-actor."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--estimation-actor",
est,
"--invariant-actor",
inv,
],
)
@when('I invoke plan-cov use with invariant "{text}"')
def step_invoke_use_with_invariant(context: Context, text: str) -> None:
"""Invoke use command with --invariant flag."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--invariant",
text,
],
)
# ----------------------------------------------------------------------
# When — apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov apply without plan_id")
def step_invoke_apply_no_id(context: Context) -> None:
"""Invoke apply without specifying a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["apply", "--yes"]
)
@when('I invoke plan-cov apply with plan_id "{pid}"')
def step_invoke_apply_with_id(context: Context, pid: str) -> None:
"""Invoke apply with an explicit plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["apply", "--yes", pid]
)
# ----------------------------------------------------------------------
# When — status
# ----------------------------------------------------------------------
@when('I invoke plan-cov status "{pid}"')
def step_invoke_status(context: Context, pid: str) -> None:
"""Invoke the status command with a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["status", pid])
# ----------------------------------------------------------------------
# When — errors
# ----------------------------------------------------------------------
@when('I invoke plan-cov errors "{pid}" with format "{fmt}"')
def step_invoke_errors_json(context: Context, pid: str, fmt: str) -> None:
"""Invoke the errors command with a given format."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["errors", pid, "--format", fmt]
)
@when('I invoke plan-cov errors "{pid}"')
def step_invoke_errors_rich(context: Context, pid: str) -> None:
"""Invoke the errors command in default rich format."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["errors", pid])
# ----------------------------------------------------------------------
# Then — result assertions
# ----------------------------------------------------------------------
@then("the plan-cov command should exit normally")
def step_assert_exit_ok(context: Context) -> None:
"""Assert the CLI exited with code 0."""
assert context.plan_cov_result.exit_code == 0, (
f"Expected exit_code=0, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then("the plan-cov command should abort")
def step_assert_abort(context: Context) -> None:
"""Assert the CLI exited with a non-zero code (abort)."""
assert context.plan_cov_result.exit_code != 0, (
f"Expected non-zero exit_code, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then('the plan-cov output should contain "{text}"')
def step_assert_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the expected text."""
assert text in context.plan_cov_result.output, (
f"Expected output to contain '{text}'. "
f"Actual output: {context.plan_cov_result.output}"
)
@then("the plan-cov created plan should have estimation_actor set")
def step_assert_estimation_actor(context: Context) -> None:
"""Assert the plan was updated with estimation_actor."""
plan = context.plan_cov_plan
assert plan.estimation_actor == "openai/gpt-4", (
f"Expected estimation_actor='openai/gpt-4', got {plan.estimation_actor}"
)