Files
cleveragents-core/features/steps/plan_lifecycle_cli_steps.py
T
freemo fae438a7a7 refactor(acms): unify ContextFragment model hierarchies by extending CRP base types
Core domain types (FragmentProvenance, ContextFragment, ContextBudget,
ContextPayload) now extend their CRP counterparts via Pydantic v2
inheritance, ensuring isinstance compatibility across the model
hierarchy.

Key changes:
- CRP base types made frozen=True (no consumer mutates them)
- CRP AssembledContext fields changed from list to tuple (frozen consistency)
- Core types extend CRP bases: FragmentProvenance(CRPFragmentProvenance),
  ContextFragment(CRPContextFragment), ContextBudget(CRPContextBudget),
  ContextPayload(CRPAssembledContext)
- Removed duplicate ContextFragment dataclass from skeleton_compressor
- Updated project_context.py to pass tuples to frozen AssembledContext
- Added Behave tests (10 scenarios), Robot integration tests (3 cases),
  and ASV benchmarks for the unified hierarchy
- Updated Known Limitations table in docs/reference/acms.md

ISSUES CLOSED: #569
2026-03-05 21:38:55 +00:00

492 lines
16 KiB
Python

"""Step definitions for plan lifecycle CLI coverage."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.application.services.plan_lifecycle_service import (
ActionNotAvailableError,
InvalidPhaseTransitionError,
PlanLifecycleService,
PlanNotReadyError,
)
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import (
CleverAgentsError,
NotFoundError,
PlanError,
ValidationError,
)
from cleveragents.domain.models.core.action import ActionState
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
ProjectLink,
)
_ULIDS = [
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"01ARZ3NDEKTSV4RRFFQ69G5FAW",
"01ARZ3NDEKTSV4RRFFQ69G5FAX",
"01ARZ3NDEKTSV4RRFFQ69G5FAY",
"01ARZ3NDEKTSV4RRFFQ69G5FAZ",
"01ARZ3NDEKTSV4RRFFQ69G5FB0",
"01ARZ3NDEKTSV4RRFFQ69G5FB1",
"01ARZ3NDEKTSV4RRFFQ69G5FB2",
"01ARZ3NDEKTSV4RRFFQ69G5FB3",
"01ARZ3NDEKTSV4RRFFQ69G5FB4",
"01ARZ3NDEKTSV4RRFFQ69G5FB5",
"01ARZ3NDEKTSV4RRFFQ69G5FB6",
"01ARZ3NDEKTSV4RRFFQ69G5FB7",
"01ARZ3NDEKTSV4RRFFQ69G5FB8",
"01ARZ3NDEKTSV4RRFFQ69G5FB9",
"01ARZ3NDEKTSV4RRFFQ69G5FBA",
"01ARZ3NDEKTSV4RRFFQ69G5FBB",
"01ARZ3NDEKTSV4RRFFQ69G5FBC",
"01ARZ3NDEKTSV4RRFFQ69G5FBD",
"01ARZ3NDEKTSV4RRFFQ69G5FBE",
]
def _make_plan(
*,
plan_id: str,
name: str,
description: str = "Lifecycle plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.COMPLETE,
project_links: list[ProjectLink] | None = None,
error_message: str | None = None,
) -> Plan:
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name="local/test-action",
description=description,
definition_of_done=None,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
error_message=error_message,
reusable=True,
read_only=False,
)
def _output(context) -> str:
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
@given("a plan lifecycle CLI runner")
def step_plan_lifecycle_cli_runner(context) -> None:
context.runner = CliRunner()
@given("a mocked lifecycle service for plan commands")
def step_mocked_plan_lifecycle_service(context) -> None:
context.lifecycle_service = MagicMock()
patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.lifecycle_service,
)
patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(patcher.stop)
# Widen the Rich console so table columns do not wrap during tests
console_patcher = patch.object(plan_module.console, "width", 200)
console_patcher.start()
context._cleanup_handlers.append(console_patcher.stop)
@when("I call the plan lifecycle service helper")
def step_call_lifecycle_service_helper(context) -> None:
settings = MagicMock()
container = SimpleNamespace(settings=MagicMock(return_value=settings))
with patch(
"cleveragents.application.container.get_container", return_value=container
):
context.helper_settings = settings
context.helper_service = plan_module._get_lifecycle_service()
@then("the lifecycle service helper should return a plan lifecycle service")
def step_lifecycle_service_helper_returns(context) -> None:
svc = context.helper_service
assert isinstance(svc, PlanLifecycleService), (
f"Expected PlanLifecycleService, got {type(svc).__name__} "
f"(module={type(svc).__module__})"
)
assert svc.settings is context.helper_settings
@when("I run plan lifecycle use with parsed arguments")
def step_plan_use_with_parsed_arguments(context) -> None:
action = SimpleNamespace(namespaced_name="local/code-coverage")
context.lifecycle_service.get_action.side_effect = NotFoundError(
resource_type="action",
resource_id="missing",
)
context.lifecycle_service.get_action_by_name.return_value = action
plan = _make_plan(
plan_id=_ULIDS[1],
name="local/coverage-plan",
description="x" * 240,
error_message="Strategy failed",
project_links=[
ProjectLink(project_name="proj-1"),
ProjectLink(project_name="proj-2"),
],
)
context.lifecycle_service.use_action.return_value = plan
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/code-coverage",
"--project",
"proj-1",
"--project",
"proj-2",
"--arg",
"count=3",
"--arg",
"ratio=4.2",
"--arg",
"enabled=true",
"--arg",
"label=alpha",
],
)
@when('I run plan lifecycle use with invalid argument "{arg_value}"')
def step_plan_use_with_invalid_argument(context, arg_value: str) -> None:
action = SimpleNamespace(namespaced_name="local/code-coverage")
context.lifecycle_service.get_action.return_value = action
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/code-coverage",
"--project",
"proj-1",
"--arg",
arg_value,
],
)
@when('I run plan lifecycle use causing "{error_type}"')
def step_plan_use_error(context, error_type: str) -> None:
action = SimpleNamespace(namespaced_name="local/code-coverage")
context.lifecycle_service.get_action.return_value = action
if error_type == "action not available":
context.lifecycle_service.use_action.side_effect = ActionNotAvailableError(
str(action.namespaced_name),
ActionState.ARCHIVED,
)
elif error_type == "validation error":
context.lifecycle_service.use_action.side_effect = ValidationError(
"Invalid action"
)
elif error_type == "general error":
context.lifecycle_service.use_action.side_effect = CleverAgentsError(
"Use failed"
)
else:
raise ValueError(f"Unhandled error type: {error_type}")
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/code-coverage",
"--project",
"proj-1",
],
)
@when("I run plan execute without a plan id with {count:d} complete plans")
def step_plan_execute_without_plan_id(context, count: int) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[idx],
name=f"local/execute-plan-{idx}",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
)
for idx in range(count)
]
context.lifecycle_service.list_plans.return_value = plans
if count == 1:
context.lifecycle_service.execute_plan.return_value = plans[0]
context.execute_plans = plans
context.result = context.runner.invoke(plan_app, ["execute"])
@when('I run plan execute for plan id "{plan_id}" causing "{error_type}"')
def step_plan_execute_error(context, plan_id: str, error_type: str) -> None:
if error_type == "invalid transition":
context.lifecycle_service.execute_plan.side_effect = (
InvalidPhaseTransitionError(
PlanPhase.STRATEGIZE,
PlanPhase.EXECUTE,
)
)
elif error_type == "plan not ready":
context.lifecycle_service.execute_plan.side_effect = PlanNotReadyError(
plan_id,
PlanPhase.STRATEGIZE,
ProcessingState.QUEUED,
)
elif error_type == "general error":
context.lifecycle_service.execute_plan.side_effect = CleverAgentsError(
"Execute failed"
)
else:
raise ValueError(f"Unhandled error type: {error_type}")
context.result = context.runner.invoke(plan_app, ["execute", plan_id])
@when("I run plan lifecycle apply without a plan id with {count:d} complete plans")
def step_plan_lifecycle_apply_without_plan_id(context, count: int) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[10 + idx],
name=f"local/apply-plan-{idx}",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
for idx in range(count)
]
context.lifecycle_service.list_plans.return_value = plans
if count == 1:
context.lifecycle_service.apply_plan.return_value = plans[0]
context.apply_plans = plans
context.result = context.runner.invoke(plan_app, ["lifecycle-apply"])
@when('I run plan lifecycle apply for plan id "{plan_id}" causing "{error_type}"')
def step_plan_lifecycle_apply_error(context, plan_id: str, error_type: str) -> None:
if error_type == "invalid transition":
context.lifecycle_service.apply_plan.side_effect = InvalidPhaseTransitionError(
PlanPhase.EXECUTE,
PlanPhase.APPLY,
)
elif error_type == "plan not ready":
context.lifecycle_service.apply_plan.side_effect = PlanNotReadyError(
plan_id,
PlanPhase.EXECUTE,
ProcessingState.QUEUED,
)
elif error_type == "general error":
context.lifecycle_service.apply_plan.side_effect = CleverAgentsError(
"Apply failed"
)
else:
raise ValueError(f"Unhandled error type: {error_type}")
context.result = context.runner.invoke(plan_app, ["lifecycle-apply", plan_id])
@when("I run plan status without a plan id and {count:d} plans exist")
def step_plan_status_without_plan_id(context, count: int) -> None:
if count == 0:
context.lifecycle_service.list_plans.return_value = []
else:
plans = [
_make_plan(
plan_id=_ULIDS[5 + idx],
name=f"local/status-plan-{idx}",
phase=PlanPhase.EXECUTE if idx % 2 == 0 else PlanPhase.APPLY,
processing_state=ProcessingState.COMPLETE,
)
for idx in range(count)
]
context.lifecycle_service.list_plans.return_value = plans
context.status_plans = plans
context.result = context.runner.invoke(plan_app, ["status"])
@when('I run plan status for legacy plan id "{plan_id}"')
def step_plan_status_for_legacy_plan(context, plan_id: str) -> None:
context.lifecycle_service.get_plan.return_value = "legacy-plan"
context.result = context.runner.invoke(plan_app, ["status", plan_id])
@when("I run plan status causing a general error")
def step_plan_status_error(context) -> None:
context.lifecycle_service.list_plans.side_effect = CleverAgentsError(
"Status failed"
)
context.result = context.runner.invoke(plan_app, ["status"])
@when('I run plan lifecycle list with invalid phase "{phase}"')
def step_plan_lifecycle_list_invalid_phase(context, phase: str) -> None:
context.result = context.runner.invoke(
plan_app,
[
"lifecycle-list",
"--phase",
phase,
],
)
@when("I run plan lifecycle list with no plans")
def step_plan_lifecycle_list_no_plans(context) -> None:
context.lifecycle_service.list_plans.return_value = []
context.result = context.runner.invoke(plan_app, ["lifecycle-list"])
@when('I run plan lifecycle list with plans and phase "{phase}"')
def step_plan_lifecycle_list_with_plans(context, phase: str) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[12],
name="local/short-projects",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
project_links=[ProjectLink(project_name="proj-1")],
),
_make_plan(
plan_id=_ULIDS[13],
name="local/multi-projects",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-1"),
ProjectLink(project_name="proj-2"),
ProjectLink(project_name="proj-3"),
],
),
]
context.lifecycle_service.list_plans.return_value = plans
context.result = context.runner.invoke(
plan_app,
[
"lifecycle-list",
"--phase",
phase,
"--project",
"proj-1",
],
)
@when("I run plan lifecycle list causing a general error")
def step_plan_lifecycle_list_error(context) -> None:
context.lifecycle_service.list_plans.side_effect = CleverAgentsError("List failed")
context.result = context.runner.invoke(plan_app, ["lifecycle-list"])
@when('I run plan cancel for plan id "{plan_id}" with reason "{reason}"')
def step_plan_cancel_with_reason(context, plan_id: str, reason: str) -> None:
plan = _make_plan(
plan_id=plan_id,
name="local/cancel-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.PROCESSING,
)
context.lifecycle_service.cancel_plan.return_value = plan
context.result = context.runner.invoke(
plan_app,
[
"cancel",
plan_id,
"--reason",
reason,
],
)
@when('I run plan cancel for plan id "{plan_id}" without reason')
def step_plan_cancel_without_reason(context, plan_id: str) -> None:
plan = _make_plan(
plan_id=plan_id,
name="local/cancel-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.PROCESSING,
)
context.lifecycle_service.cancel_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["cancel", plan_id])
@when('I run plan cancel for plan id "{plan_id}" causing "{error_type}"')
def step_plan_cancel_error(context, plan_id: str, error_type: str) -> None:
if error_type == "plan error":
context.lifecycle_service.cancel_plan.side_effect = PlanError("Cannot cancel")
elif error_type == "general error":
context.lifecycle_service.cancel_plan.side_effect = CleverAgentsError(
"Cancel failed"
)
else:
raise ValueError(f"Unhandled error type: {error_type}")
context.result = context.runner.invoke(plan_app, ["cancel", plan_id])
@then("the plan lifecycle command should succeed")
def step_plan_lifecycle_command_succeeds(context) -> None:
assert context.result.exit_code == 0, f"Output: {_output(context)}"
@then("the plan lifecycle command should abort")
def step_plan_lifecycle_command_aborts(context) -> None:
assert context.result.exit_code != 0, f"Output: {_output(context)}"
@then('the plan lifecycle output should contain "{text}"')
def step_plan_lifecycle_output_contains(context, text: str) -> None:
output = _output(context)
assert text in output, f"Expected '{text}' in output: {output}"
@then('the plan lifecycle output should not contain "{text}"')
def step_plan_lifecycle_output_not_contains(context, text: str) -> None:
output = _output(context)
assert text not in output, f"Did not expect '{text}' in output: {output}"
@then("the plan lifecycle use should pass parsed arguments")
def step_plan_lifecycle_use_parsed_arguments(context) -> None:
context.lifecycle_service.get_action_by_name.assert_called_once()
kwargs = context.lifecycle_service.use_action.call_args.kwargs
links = kwargs["project_links"]
assert [link.project_name for link in links] == ["proj-1", "proj-2"]
assert kwargs["arguments"] == {
"count": 3,
"ratio": 4.2,
"enabled": True,
"label": "alpha",
}
@then("the execute command should run the single ready plan")
def step_execute_command_runs_single_plan(context) -> None:
assert len(context.execute_plans) == 1
plan_id = context.execute_plans[0].identity.plan_id
context.lifecycle_service.execute_plan.assert_called_once_with(plan_id)
@then("the lifecycle apply command should run the single ready plan")
def step_lifecycle_apply_runs_single_plan(context) -> None:
assert len(context.apply_plans) == 1
plan_id = context.apply_plans[0].identity.plan_id
context.lifecycle_service.apply_plan.assert_called_once_with(plan_id)