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
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>
513 lines
18 KiB
Python
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}"
|
|
)
|