d98666651f
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m24s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 9m27s
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / coverage (pull_request) Successful in 6m55s
CI / docker (pull_request) Successful in 39s
- Enforce config-only 'action create --config <file>' (remove legacy inline flags: --name, --strategy-actor, --execution-actor, --definition-of-done, --arg) - Load config via ActionConfigSchema.from_yaml_file() then Action.from_config() with clear validation error surfacing - Remove 'action available' subcommand (no draft state; actions are available by default) - Add REGEX positional arg to 'action list' for name filtering - Add Short Name and Definition of Done summary to all CLI outputs - Add 'action list --namespace/-n' and '--state/-s' filters - Update existing tests to match new config-only create flow - Add features/action_cli_spec_alignment.feature (19 scenarios) - Add robot/action_cli_spec.robot smoke tests - Add benchmarks/action_cli_bench.py ASV benchmarks - Add docs/reference/action_cli.md
536 lines
19 KiB
Python
536 lines
19 KiB
Python
"""Step definitions for the Action CLI feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
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.core.exceptions import (
|
|
CleverAgentsError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from cleveragents.domain.models.core.action import (
|
|
Action,
|
|
ActionArgument,
|
|
ActionState,
|
|
ArgumentRequirement,
|
|
ArgumentType,
|
|
)
|
|
from cleveragents.domain.models.core.plan import NamespacedName
|
|
|
|
_MINIMAL_YAML = """\
|
|
name: local/test-action
|
|
description: Test action
|
|
strategy_actor: openai/gpt-4
|
|
execution_actor: openai/gpt-4
|
|
definition_of_done: Test passes
|
|
"""
|
|
|
|
_FULL_YAML = """\
|
|
name: local/full-action
|
|
description: Increase test coverage
|
|
strategy_actor: openai/gpt-4
|
|
execution_actor: anthropic/claude-3
|
|
definition_of_done: Coverage reaches target
|
|
reusable: true
|
|
read_only: false
|
|
arguments:
|
|
- name: coverage
|
|
type: integer
|
|
required: true
|
|
description: Target coverage
|
|
"""
|
|
|
|
|
|
def _make_cli_action(
|
|
*,
|
|
name: str = "local/test-action",
|
|
state: ActionState = ActionState.AVAILABLE,
|
|
definition_of_done: str = "Test passes",
|
|
strategy_actor: str = "openai/gpt-4",
|
|
execution_actor: str = "openai/gpt-4",
|
|
arguments: list[ActionArgument] | None = None,
|
|
reusable: bool = True,
|
|
read_only: bool = False,
|
|
created_by: str | None = None,
|
|
description: str = "Test action",
|
|
long_description: str | None = "A test action for testing",
|
|
) -> Action:
|
|
"""Create a mock Action for CLI testing."""
|
|
return Action(
|
|
namespaced_name=NamespacedName.parse(name),
|
|
description=description,
|
|
long_description=long_description,
|
|
definition_of_done=definition_of_done,
|
|
strategy_actor=strategy_actor,
|
|
execution_actor=execution_actor,
|
|
arguments=arguments or [],
|
|
reusable=reusable,
|
|
read_only=read_only,
|
|
state=state,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
def _write_temp_yaml(context: Context, content: str) -> str:
|
|
"""Write YAML content to a temporary file and register cleanup."""
|
|
fd, path = tempfile.mkstemp(suffix=".yaml")
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write(content)
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(
|
|
lambda p=path: os.unlink(p) if os.path.exists(p) else None
|
|
)
|
|
return path
|
|
|
|
|
|
@given("an action CLI runner with mocks")
|
|
def step_action_cli_runner_with_mocks(context: Context) -> None:
|
|
"""Set up the CLI runner for testing."""
|
|
context.runner = CliRunner()
|
|
|
|
|
|
@given("a mocked plan lifecycle service")
|
|
def step_mocked_lifecycle_service(context: Context) -> None:
|
|
"""Set up a mock PlanLifecycleService."""
|
|
context.mock_service = MagicMock()
|
|
context.service_patcher = patch(
|
|
"cleveragents.cli.commands.action._get_lifecycle_service",
|
|
return_value=context.mock_service,
|
|
)
|
|
context.service_patcher.start()
|
|
|
|
# Store cleanup handler
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(context.service_patcher.stop)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config file Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a valid action config YAML file")
|
|
def step_valid_config_yaml(context: Context) -> None:
|
|
"""Write a valid action YAML config file."""
|
|
context.config_path = _write_temp_yaml(context, _MINIMAL_YAML)
|
|
|
|
|
|
@given("a full action config YAML file")
|
|
def step_full_config_yaml(context: Context) -> None:
|
|
"""Write a fully-populated action YAML config file."""
|
|
context.config_path = _write_temp_yaml(context, _FULL_YAML)
|
|
|
|
|
|
@given("there are mocked existing actions")
|
|
def step_mocked_existing_actions(context: Context) -> None:
|
|
"""Set up existing actions in the mock service."""
|
|
context.actions = [
|
|
_make_cli_action(
|
|
name="local/action-one",
|
|
state=ActionState.AVAILABLE,
|
|
),
|
|
_make_cli_action(
|
|
name="local/action-two",
|
|
state=ActionState.AVAILABLE,
|
|
),
|
|
_make_cli_action(
|
|
name="myorg/action-three",
|
|
state=ActionState.AVAILABLE,
|
|
),
|
|
]
|
|
context.mock_service.list_actions.return_value = context.actions
|
|
|
|
|
|
@given("there are no mocked actions")
|
|
def step_no_mocked_actions(context: Context) -> None:
|
|
"""Set up empty actions list."""
|
|
context.mock_service.list_actions.return_value = []
|
|
|
|
|
|
@given('there is a mocked action with name "{name}"')
|
|
def step_mocked_action_by_name(context: Context, name: str) -> None:
|
|
"""Set up an existing action with specific name."""
|
|
context.existing_action = _make_cli_action(name=name)
|
|
context.mock_service.get_action.return_value = context.existing_action
|
|
context.mock_service.get_action_by_name.return_value = context.existing_action
|
|
|
|
|
|
@given("there is a mocked action without descriptions")
|
|
def step_mocked_action_without_descriptions(context: Context) -> None:
|
|
"""Set up an action without optional descriptions."""
|
|
arguments = [
|
|
ActionArgument(
|
|
name="target",
|
|
arg_type=ArgumentType.INTEGER,
|
|
requirement=ArgumentRequirement.REQUIRED,
|
|
description="",
|
|
)
|
|
]
|
|
context.existing_action = _make_cli_action(
|
|
name="local/no-description",
|
|
arguments=arguments,
|
|
description="No description action",
|
|
long_description=None,
|
|
)
|
|
context.mock_service.get_action_by_name.return_value = context.existing_action
|
|
|
|
|
|
@given("there is a mocked available action")
|
|
def step_mocked_available_action(context: Context) -> None:
|
|
"""Set up an available action."""
|
|
context.existing_action = _make_cli_action(state=ActionState.AVAILABLE)
|
|
context.mock_service.get_action_by_name.return_value = context.existing_action
|
|
context.mock_service.archive_action.return_value = _make_cli_action(
|
|
state=ActionState.ARCHIVED
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create command When steps (config-only)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run action CLI create with --config pointing to the YAML file")
|
|
def step_run_create_with_config(context: Context) -> None:
|
|
"""Run action create command with --config."""
|
|
created_action = _make_cli_action(name="local/test-action")
|
|
context.mock_service.create_action.return_value = created_action
|
|
|
|
result = context.runner.invoke(
|
|
action_app,
|
|
["create", "--config", context.config_path],
|
|
)
|
|
context.result = result
|
|
context.created_action = created_action
|
|
|
|
|
|
@when("I run action CLI create with --config pointing to the full YAML file")
|
|
def step_run_create_with_full_config(context: Context) -> None:
|
|
"""Run action create command with --config pointing to full YAML."""
|
|
args = [
|
|
ActionArgument(
|
|
name="coverage",
|
|
arg_type=ArgumentType.INTEGER,
|
|
requirement=ArgumentRequirement.REQUIRED,
|
|
description="Target coverage",
|
|
)
|
|
]
|
|
created_action = _make_cli_action(
|
|
name="local/full-action",
|
|
arguments=args,
|
|
)
|
|
context.mock_service.create_action.return_value = created_action
|
|
|
|
result = context.runner.invoke(
|
|
action_app,
|
|
["create", "--config", context.config_path],
|
|
)
|
|
context.result = result
|
|
context.created_action = created_action
|
|
|
|
|
|
@when("I run action CLI create with --config pointing to a missing file")
|
|
def step_run_create_missing_config(context: Context) -> None:
|
|
"""Run action create with a config file that doesn't exist."""
|
|
result = context.runner.invoke(
|
|
action_app,
|
|
["create", "--config", "/tmp/does_not_exist_action_config.yaml"],
|
|
)
|
|
context.result = result
|
|
|
|
|
|
@when("I run action CLI create with validation error from service")
|
|
def step_run_create_validation_error(context: Context) -> None:
|
|
"""Run action create where the service raises a validation error."""
|
|
context.mock_service.create_action.side_effect = ValidationError("Missing fields")
|
|
result = context.runner.invoke(
|
|
action_app,
|
|
["create", "--config", context.config_path],
|
|
)
|
|
context.result = result
|
|
context.expected_error = "Missing fields"
|
|
|
|
|
|
@when("I run action CLI create with service error from config")
|
|
def step_run_create_service_error(context: Context) -> None:
|
|
"""Run action create where the service raises CleverAgentsError."""
|
|
context.mock_service.create_action.side_effect = CleverAgentsError("Create failed")
|
|
result = context.runner.invoke(
|
|
action_app,
|
|
["create", "--config", context.config_path],
|
|
)
|
|
context.result = result
|
|
context.expected_error = "Create failed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List command When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I run action CLI list with invalid state "{state}"')
|
|
def step_run_action_cli_list_invalid_state(context: Context, state: str) -> None:
|
|
"""Run action list command with invalid state filter."""
|
|
result = context.runner.invoke(action_app, ["list", "--state", state])
|
|
context.result = result
|
|
context.expected_state = state
|
|
|
|
|
|
@when("I run action CLI list with service error")
|
|
def step_run_action_cli_list_service_error(context: Context) -> None:
|
|
"""Run action list command when service fails."""
|
|
context.mock_service.list_actions.side_effect = CleverAgentsError("List failed")
|
|
result = context.runner.invoke(action_app, ["list"])
|
|
context.result = result
|
|
context.expected_error = "List failed"
|
|
|
|
|
|
@when("I run action CLI list")
|
|
def step_run_action_cli_list(context: Context) -> None:
|
|
"""Run action list command."""
|
|
result = context.runner.invoke(action_app, ["list"])
|
|
context.result = result
|
|
|
|
|
|
@when('I run action CLI list with namespace filter "{namespace}"')
|
|
def step_run_action_cli_list_namespace(context: Context, namespace: str) -> None:
|
|
"""Run action list command with namespace filter."""
|
|
filtered = [a for a in context.actions if a.namespaced_name.namespace == namespace]
|
|
context.mock_service.list_actions.return_value = filtered
|
|
|
|
result = context.runner.invoke(action_app, ["list", "--namespace", namespace])
|
|
context.result = result
|
|
|
|
|
|
@when('I run action CLI list with state filter "{state_value}"')
|
|
def step_run_action_cli_list_state(context: Context, state_value: str) -> None:
|
|
"""Run action list command with state filter."""
|
|
result = context.runner.invoke(action_app, ["list", "--state", state_value])
|
|
context.result = result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Show command When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run action CLI show with that ID")
|
|
def step_run_action_cli_show_id(context: Context) -> None:
|
|
"""Run action show command with the stored action ID."""
|
|
result = context.runner.invoke(
|
|
action_app, ["show", str(context.existing_action.namespaced_name)]
|
|
)
|
|
context.result = result
|
|
|
|
|
|
@when('I run action CLI show with name "{name}"')
|
|
def step_run_action_cli_show_name(context: Context, name: str) -> None:
|
|
"""Run action show command with name."""
|
|
result = context.runner.invoke(action_app, ["show", name])
|
|
context.result = result
|
|
|
|
|
|
@when("I run action CLI show with service error")
|
|
def step_run_action_cli_show_service_error(context: Context) -> None:
|
|
"""Run action show command when service fails."""
|
|
context.mock_service.get_action_by_name.side_effect = CleverAgentsError(
|
|
"Show failed"
|
|
)
|
|
result = context.runner.invoke(action_app, ["show", "local/show-fail"])
|
|
context.result = result
|
|
context.expected_error = "Show failed"
|
|
|
|
|
|
@when("I run action CLI show with unknown ID")
|
|
def step_run_action_cli_show_unknown(context: Context) -> None:
|
|
"""Run action show command with unknown name."""
|
|
context.mock_service.get_action_by_name.side_effect = NotFoundError(
|
|
resource_type="action", resource_id="unknown"
|
|
)
|
|
|
|
result = context.runner.invoke(action_app, ["show", "unknown-id"])
|
|
context.result = result
|
|
|
|
|
|
@when("I run action CLI show by name lookup")
|
|
def step_run_action_cli_show_by_name_lookup(context: Context) -> None:
|
|
"""Run 'show' by name, falling back through get_action_by_name."""
|
|
context.mock_service.get_action_by_name.return_value = context.existing_action
|
|
result = context.runner.invoke(
|
|
action_app, ["show", str(context.existing_action.namespaced_name)]
|
|
)
|
|
context.result = result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Archive command When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run action CLI archive with the action ID")
|
|
def step_run_action_cli_archive(context: Context) -> None:
|
|
"""Run action archive command."""
|
|
result = context.runner.invoke(
|
|
action_app, ["archive", str(context.existing_action.namespaced_name)]
|
|
)
|
|
context.result = result
|
|
|
|
|
|
@when("I run action CLI archive with service error")
|
|
def step_run_action_cli_archive_service_error(context: Context) -> None:
|
|
"""Run action archive command when service fails."""
|
|
context.mock_service.get_action_by_name.return_value = context.existing_action
|
|
context.mock_service.archive_action.side_effect = CleverAgentsError(
|
|
"Archive failed"
|
|
)
|
|
result = context.runner.invoke(
|
|
action_app, ["archive", str(context.existing_action.namespaced_name)]
|
|
)
|
|
context.result = result
|
|
context.expected_error = "Archive failed"
|
|
|
|
|
|
@when("I run action CLI archive with unknown ID")
|
|
def step_run_action_cli_archive_unknown(context: Context) -> None:
|
|
"""Run action archive command with unknown name."""
|
|
context.mock_service.get_action_by_name.side_effect = NotFoundError(
|
|
resource_type="action", resource_id="unknown"
|
|
)
|
|
|
|
result = context.runner.invoke(action_app, ["archive", "unknown-id"])
|
|
context.result = result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the action CLI create should succeed")
|
|
def step_action_cli_create_success(context: Context) -> None:
|
|
"""Verify action was created."""
|
|
assert context.result.exit_code == 0, f"CLI failed: {context.result.output}"
|
|
context.mock_service.create_action.assert_called_once()
|
|
|
|
|
|
@then('the action CLI created name should be "{name}"')
|
|
def step_action_cli_created_name(context: Context, name: str) -> None:
|
|
"""Verify created action name."""
|
|
call_kwargs = context.mock_service.create_action.call_args[1]
|
|
assert call_kwargs["name"] == name
|
|
|
|
|
|
@then("the action CLI should have specified arguments")
|
|
def step_action_cli_has_arguments(context: Context) -> None:
|
|
"""Verify action has arguments."""
|
|
call_kwargs = context.mock_service.create_action.call_args[1]
|
|
assert len(call_kwargs.get("arguments", [])) > 0
|
|
|
|
|
|
@then("the action CLI should create in available state")
|
|
def step_action_cli_available_state(context: Context) -> None:
|
|
"""Verify action was created (actions default to available)."""
|
|
assert context.result.exit_code == 0, f"CLI failed: {context.result.output}"
|
|
context.mock_service.create_action.assert_called_once()
|
|
|
|
|
|
@then("the action CLI command should abort")
|
|
def step_action_cli_abort(context: Context) -> None:
|
|
"""Verify CLI aborted."""
|
|
assert context.result.exit_code != 0
|
|
|
|
|
|
@then("the action CLI should show all actions in a table")
|
|
def step_action_cli_show_all_actions(context: Context) -> None:
|
|
"""Verify all actions are displayed."""
|
|
assert context.result.exit_code == 0
|
|
assert "Actions" in context.result.output
|
|
# Table truncates names in narrow terminals; verify the count is correct
|
|
assert f"({len(context.actions)} total)" in context.result.output
|
|
|
|
|
|
@then("the action CLI should show only local namespace actions")
|
|
def step_action_cli_show_local_actions(context: Context) -> None:
|
|
"""Verify only local actions are displayed."""
|
|
assert context.result.exit_code == 0
|
|
assert "local/" in context.result.output
|
|
|
|
|
|
@then("the action CLI list should succeed with results")
|
|
def step_action_cli_list_succeeds_with_results(context: Context) -> None:
|
|
"""Verify the list command succeeded."""
|
|
assert context.result.exit_code == 0
|
|
|
|
|
|
@then("the action CLI should show no actions message")
|
|
def step_action_cli_show_no_actions(context: Context) -> None:
|
|
"""Verify no actions message is displayed."""
|
|
assert context.result.exit_code == 0
|
|
assert "No actions found" in context.result.output
|
|
|
|
|
|
@then("the action CLI should show the action details")
|
|
def step_action_cli_show_action_details(context: Context) -> None:
|
|
"""Verify action details are displayed."""
|
|
assert context.result.exit_code == 0
|
|
assert "Action" in context.result.output
|
|
|
|
|
|
@then("the action CLI command should abort for missing action")
|
|
def step_action_cli_abort_missing(context: Context) -> None:
|
|
"""Verify CLI aborts for missing action."""
|
|
assert context.result.exit_code != 0
|
|
assert "not found" in context.result.output.lower()
|
|
|
|
|
|
@then("the action CLI should resolve action by name")
|
|
def step_action_cli_resolve_by_name(context: Context) -> None:
|
|
"""Verify name-based lookup was used."""
|
|
context.mock_service.get_action_by_name.assert_called_with(
|
|
str(context.existing_action.namespaced_name)
|
|
)
|
|
|
|
|
|
@then("the action CLI should abort with validation error")
|
|
def step_action_cli_abort_validation_error(context: Context) -> None:
|
|
"""Verify CLI aborts with validation error output."""
|
|
assert context.result.exit_code != 0
|
|
assert "Validation Error" in context.result.output
|
|
assert context.expected_error in context.result.output
|
|
|
|
|
|
@then("the action CLI should abort with service error")
|
|
def step_action_cli_abort_service_error(context: Context) -> None:
|
|
"""Verify CLI aborts with service error output."""
|
|
assert context.result.exit_code != 0
|
|
assert "Error" in context.result.output
|
|
assert context.expected_error in context.result.output
|
|
|
|
|
|
@then("the action CLI should abort with invalid state")
|
|
def step_action_cli_abort_invalid_state(context: Context) -> None:
|
|
"""Verify CLI aborts with invalid state output."""
|
|
assert context.result.exit_code != 0
|
|
assert "Invalid state" in context.result.output
|
|
assert "Valid values" in context.result.output
|
|
|
|
|
|
@then("the action CLI should archive the action")
|
|
def step_action_cli_archived(context: Context) -> None:
|
|
"""Verify action was archived."""
|
|
assert context.result.exit_code == 0
|
|
context.mock_service.archive_action.assert_called_once()
|