Files
cleveragents-core/features/steps/cli_extensions_steps.py
T
freemo aa008ba5d7
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 30s
CI / security (pull_request) Successful in 32s
CI / integration_tests (pull_request) Successful in 4m2s
CI / unit_tests (pull_request) Successful in 7m26s
CI / docker (pull_request) Successful in 9s
CI / benchmark-regression (pull_request) Successful in 16m11s
CI / coverage (pull_request) Successful in 23m7s
CI / lint (push) Successful in 26s
CI / quality (push) Successful in 19s
CI / typecheck (push) Successful in 1m4s
CI / build (push) Successful in 39s
CI / security (push) Successful in 1m2s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 4m15s
CI / benchmark-publish (push) Successful in 11m19s
CI / unit_tests (push) Successful in 14m6s
CI / docker (push) Successful in 9s
CI / coverage (push) Successful in 37m7s
feat(cli): add action and plan CLI extensions
Add extended CLI flags and output rendering for plan and action
commands in the v3 plan lifecycle.

Plan use command:
- --automation-profile flag validates against BUILTIN_PROFILES and
  persists to plan metadata with PLAN provenance
- --invariant flag (repeatable) passes PlanInvariant objects with
  InvariantSource.PLAN to PlanLifecycleService.use_action()
- --strategy-actor, --execution-actor, --estimation-actor, and
  --invariant-actor flags validate namespace/name format via
  validate_namespaced_actor() before setting on the plan

Plan status/list output:
- Profile and Invariants columns added to plan status summary table
- Profile and Invariants columns added to lifecycle-list table
- _plan_spec_dict includes estimation_actor, invariant_actor, and
  invariants fields in all output formats (json, yaml, plain, table)
- _print_lifecycle_plan rich panel displays invariants with source
  provenance tags

Action show output:
- _print_action displays estimation_actor, invariant_actor,
  invariants list, inputs_schema, and automation_profile when present
- _action_spec_dict conditionally includes estimation_actor,
  invariant_actor, and inputs_schema fields

Documentation:
- docs/reference/plan_cli.md documents all extended flags, actor
  override validation, and usage examples
- docs/reference/action_cli.md documents extended output fields and
  YAML configuration options

Tests:
- 29 Behave scenarios in features/cli_extensions.feature covering
  automation profile, invariant flags, actor override validation,
  plan status/list rendering, and action show extended fields
- 5 Robot Framework integration tests in robot/cli_extensions.robot
  for plan use with invariants, profiles, and actor validation
- 6 ASV benchmark suites in benchmarks/cli_extensions_bench.py
  measuring parsing overhead for all new flags and rendering paths

ISSUES CLOSED: #325
2026-02-24 13:41:11 +00:00

635 lines
23 KiB
Python

"""Step definitions for CLI extensions feature (plan + action)."""
from __future__ import annotations
from datetime import datetime
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.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.commands.plan import validate_namespaced_actor
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _make_plan(
*,
name: str = "local/test-plan",
action_name: str = "local/test-action",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
arguments: dict[str, object] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
strategy_actor: str | None = "openai/gpt-4",
execution_actor: str | None = "openai/gpt-4",
estimation_actor: str | None = None,
invariant_actor: str | None = None,
) -> Plan:
"""Create a Plan instance for CLI extension tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="Test plan description",
definition_of_done="All tests pass",
action_name=action_name,
phase=phase,
processing_state=state,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=[],
automation_profile=automation_profile,
invariants=invariants or [],
strategy_actor=strategy_actor,
execution_actor=execution_actor,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _make_action(
name: str = "local/test-action",
*,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
invariants: list[str] | None = None,
inputs_schema: dict[str, object] | None = None,
automation_profile: str | None = None,
) -> Action:
"""Create an Action for CLI extension tests."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="Test action description",
long_description=None,
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
automation_profile=automation_profile,
invariants=invariants or [],
inputs_schema=inputs_schema,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a cli extensions test runner")
def step_cli_ext_runner(context: Context) -> None:
"""Set up the CLI runner."""
context.runner = CliRunner()
@given("a cli extensions mocked lifecycle service")
def step_cli_ext_mock_service(context: Context) -> None:
"""Set up the mocked lifecycle service."""
context.mock_service = MagicMock()
context.plan_patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
context.action_patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=context.mock_service,
)
context.plan_patcher.start()
context.action_patcher.start()
# Store the last plan created for inspection
context.last_plan = None
context.last_result = None
# ---------------------------------------------------------------------------
# plan use: action setup
# ---------------------------------------------------------------------------
@given("a cli extensions action exists")
def step_cli_ext_action_exists(context: Context) -> None:
"""Set up an action for plan use tests."""
context.mock_service.get_action_by_name.return_value = _make_action()
context.mock_service.use_action.return_value = _make_plan()
# ---------------------------------------------------------------------------
# plan use: automation profile
# ---------------------------------------------------------------------------
@when('I run plan use with automation profile flag "{profile}"')
def step_run_plan_use_with_profile(context: Context, profile: str) -> None:
"""Run plan use with --automation-profile."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--automation-profile", profile],
)
context.last_result = result
context.last_profile = profile
@then("the cli extensions plan use should succeed")
def step_plan_use_should_succeed(context: Context) -> None:
"""Verify plan use succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit code 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the cli extensions plan use should fail")
def step_plan_use_should_fail(context: Context) -> None:
"""Verify plan use failed."""
assert context.last_result is not None
assert context.last_result.exit_code != 0
@then('the cli extensions plan should have automation profile "{profile}"')
def step_plan_has_profile(context: Context, profile: str) -> None:
"""Verify plan output mentions automation profile."""
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: invariant flags
# ---------------------------------------------------------------------------
@when('I run plan use with invariant flags "{inv1}" and "{inv2}"')
def step_run_plan_use_with_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Run plan use with two --invariant flags."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--invariant",
inv1,
"--invariant",
inv2,
],
)
context.last_result = result
@then('the cli extensions service received invariants "{inv1}" and "{inv2}"')
def step_service_received_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Verify invariants were passed to the service."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None, "use_action was not called"
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None, "No invariants passed to use_action"
texts = [inv.text for inv in invariants]
assert inv1 in texts, f"Expected '{inv1}' in {texts}"
assert inv2 in texts, f"Expected '{inv2}' in {texts}"
@when('I run plan use with single invariant "{inv_text}"')
def step_run_plan_use_with_single_invariant(context: Context, inv_text: str) -> None:
"""Run plan use with a single --invariant flag."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant", inv_text],
)
context.last_result = result
@then('the cli extensions service received single invariant "{inv_text}"')
def step_service_received_single_invariant(context: Context, inv_text: str) -> None:
"""Verify single invariant was passed."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None
texts = [inv.text for inv in invariants]
assert inv_text in texts, f"Expected '{inv_text}' in {texts}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (valid)
# ---------------------------------------------------------------------------
@when('I run plan use with strategy actor flag "{actor}"')
def step_run_plan_use_strategy_actor(context: Context, actor: str) -> None:
"""Run plan use with --strategy-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--strategy-actor", actor],
)
context.last_result = result
@when('I run plan use with execution actor flag "{actor}"')
def step_run_plan_use_execution_actor(context: Context, actor: str) -> None:
"""Run plan use with --execution-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--execution-actor", actor],
)
context.last_result = result
@when('I run plan use with estimation actor flag "{actor}"')
def step_run_plan_use_estimation_actor(context: Context, actor: str) -> None:
"""Run plan use with --estimation-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--estimation-actor", actor],
)
context.last_result = result
@when('I run plan use with invariant actor flag "{actor}"')
def step_run_plan_use_invariant_actor(context: Context, actor: str) -> None:
"""Run plan use with --invariant-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant-actor", actor],
)
context.last_result = result
@then('the cli extensions plan has strategy actor "{actor}"')
def step_plan_has_strategy_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the strategy actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has execution actor "{actor}"')
def step_plan_has_execution_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the execution actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has estimation actor "{actor}"')
def step_plan_has_estimation_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the estimation actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has invariant actor "{actor}"')
def step_plan_has_invariant_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the invariant actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (invalid format)
# ---------------------------------------------------------------------------
@then("the cli extensions plan use should fail with actor validation error")
def step_plan_use_fail_actor_validation(context: Context) -> None:
"""Verify plan use failed due to actor validation."""
assert context.last_result is not None
assert context.last_result.exit_code != 0, (
f"Expected non-zero exit, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# plan status: automation profile
# ---------------------------------------------------------------------------
@given('cli extensions plans exist with automation profile "{profile}"')
def step_plans_with_profile(context: Context, profile: str) -> None:
"""Set up plans that have an automation profile."""
plan = _make_plan(
automation_profile=AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
@when("I run cli extensions plan status")
def step_run_plan_status(context: Context) -> None:
"""Run plan status (no plan_id = list all)."""
result = context.runner.invoke(plan_app, ["status"])
context.last_result = result
@then('the cli extensions status output should contain "{text}"')
def step_status_contains(context: Context, text: str) -> None:
"""Verify status output contains text."""
assert context.last_result.exit_code == 0
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@when('I run cli extensions plan status with format "{fmt}"')
def step_run_plan_status_fmt(context: Context, fmt: str) -> None:
"""Run plan status with --format."""
result = context.runner.invoke(plan_app, ["status", "--format", fmt])
context.last_result = result
@then(
"the cli extensions status json output should contain automation profile "
'"{profile}"'
)
def step_status_json_profile(context: Context, profile: str) -> None:
"""Verify JSON output contains automation profile."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in: {output}"
# ---------------------------------------------------------------------------
# plan lifecycle-list: invariants
# ---------------------------------------------------------------------------
@given("cli extensions plans exist with invariants")
def step_plans_with_invariants(context: Context) -> None:
"""Set up plans that have invariants."""
plan = _make_plan(
invariants=[
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
]
)
context.mock_service.list_plans.return_value = [plan]
@when("I run cli extensions plan lifecycle-list")
def step_run_lifecycle_list(context: Context) -> None:
"""Run plan lifecycle-list."""
result = context.runner.invoke(plan_app, ["lifecycle-list"])
context.last_result = result
@then("the cli extensions list output should contain invariant count")
def step_list_has_invariant_count(context: Context) -> None:
"""Verify list output contains invariant count."""
assert context.last_result.exit_code == 0
# The table should show "2" for invariant count
assert "2" in context.last_result.output, (
f"Expected '2' invariant count in: {context.last_result.output}"
)
@when('I run cli extensions plan lifecycle-list with format "{fmt}"')
def step_run_lifecycle_list_fmt(context: Context, fmt: str) -> None:
"""Run plan lifecycle-list with --format."""
result = context.runner.invoke(plan_app, ["lifecycle-list", "--format", fmt])
context.last_result = result
@then("the cli extensions list json output should contain invariants")
def step_list_json_invariants(context: Context) -> None:
"""Verify JSON output contains invariants field."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert "invariants" in output, f"Expected 'invariants' in: {output}"
assert "No warnings" in output, f"Expected 'No warnings' in: {output}"
# ---------------------------------------------------------------------------
# action show: optional fields
# ---------------------------------------------------------------------------
@given('a cli extensions action with estimation actor "{actor}"')
def step_action_with_estimation_actor(context: Context, actor: str) -> None:
"""Set up an action with estimation actor."""
action = _make_action(estimation_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given('a cli extensions action with invariant actor "{actor}"')
def step_action_with_invariant_actor(context: Context, actor: str) -> None:
"""Set up an action with invariant actor."""
action = _make_action(invariant_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with invariants")
def step_action_with_invariants(context: Context) -> None:
"""Set up an action with invariants."""
action = _make_action(invariants=["No regressions", "Keep backward compat"])
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with inputs schema")
def step_action_with_inputs_schema(context: Context) -> None:
"""Set up an action with inputs_schema."""
action = _make_action(
inputs_schema={
"type": "object",
"properties": {"coverage": {"type": "integer"}},
}
)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@when("I run cli extensions action show")
def step_run_action_show(context: Context) -> None:
"""Run action show."""
result = context.runner.invoke(action_app, ["show", "local/test-action"])
context.last_result = result
@when('I run cli extensions action show with format "{fmt}"')
def step_run_action_show_fmt(context: Context, fmt: str) -> None:
"""Run action show with --format."""
result = context.runner.invoke(
action_app, ["show", "local/test-action", "--format", fmt]
)
context.last_result = result
@then('the cli extensions action output should contain "{text}"')
def step_action_output_contains(context: Context, text: str) -> None:
"""Verify action output contains text."""
assert context.last_result.exit_code == 0, (
f"Exit code: {context.last_result.exit_code}, "
f"output: {context.last_result.output}"
)
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@then('the cli extensions action json should contain "{key}"')
def step_action_json_contains(context: Context, key: str) -> None:
"""Verify JSON output contains key."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert key in output, f"Expected '{key}' in: {output}"
# ---------------------------------------------------------------------------
# plan use: combined profile + invariant
# ---------------------------------------------------------------------------
@when('I run plan use with profile "{profile}" and invariant "{inv_text}"')
def step_run_plan_use_profile_invariant(
context: Context, profile: str, inv_text: str
) -> None:
"""Run plan use with both --automation-profile and --invariant."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--automation-profile",
profile,
"--invariant",
inv_text,
],
)
context.last_result = result
# ---------------------------------------------------------------------------
# plan status: single plan view with invariants / profile
# ---------------------------------------------------------------------------
@given("cli extensions single plan exists with invariants")
def step_single_plan_with_invariants(context: Context) -> None:
"""Set up a single plan with invariants for detail view."""
plan = _make_plan(
invariants=[
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
]
)
context.mock_service.get_plan.return_value = plan
context.test_plan_id = _PLAN_ULID
@given('cli extensions single plan exists with profile "{profile}"')
def step_single_plan_with_profile(context: Context, profile: str) -> None:
"""Set up a single plan with automation profile for detail view."""
plan = _make_plan(
automation_profile=AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
)
context.mock_service.get_plan.return_value = plan
context.test_plan_id = _PLAN_ULID
@when("I run cli extensions plan status for single plan")
def step_run_plan_status_single(context: Context) -> None:
"""Run plan status with a specific plan ID."""
result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
context.last_result = result
# ---------------------------------------------------------------------------
# action show: automation profile display
# ---------------------------------------------------------------------------
@given('a cli extensions action with automation profile "{profile}"')
def step_action_with_automation_profile(context: Context, profile: str) -> None:
"""Set up an action with automation_profile."""
action = _make_action(automation_profile=profile)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action without invariant actor")
def step_action_without_invariant_actor(context: Context) -> None:
"""Set up an action with estimation actor but no invariant actor."""
action = _make_action(estimation_actor="openai/gpt-4", invariant_actor=None)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@then('the cli extensions action json should not contain "{key}"')
def step_action_json_not_contains(context: Context, key: str) -> None:
"""Verify JSON output does NOT contain key."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert key not in output, f"Expected '{key}' NOT in: {output}"
# ---------------------------------------------------------------------------
# validate_namespaced_actor unit tests
# ---------------------------------------------------------------------------
@then('validate_namespaced_actor accepts "{actor}"')
def step_validate_actor_accepts(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor accepts a valid name."""
result = validate_namespaced_actor(actor, "--test")
assert result == actor
@then('validate_namespaced_actor rejects "{actor}"')
def step_validate_actor_rejects(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor rejects an invalid name."""
try:
validate_namespaced_actor(actor, "--test")
raise AssertionError(f"Expected ValidationError for '{actor}'")
except ValidationError:
pass # Expected
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers."""
for name in ("plan_patcher", "action_patcher"):
patcher = getattr(context, name, None)
if patcher:
patcher.stop()