Files
temp/features/steps/automation_profile_cli_coverage_steps.py
CoreRasurae 007af498b8 refactor(autonomy): rename automation profile task flags to spec names
Renamed all 11 task-type confidence threshold fields in AutomationProfile
from phase-transition semantics to spec-defined task-type semantics.
Updated all 8 built-in profiles, CLI formatting, YAML schema, services,
and all Behave/Robot tests referencing the old field names.

Post-review fixes:
- Fixed 24 stale old field names in M6 fixture files
  (automation_profiles.json, autonomy_guardrails.json)
- Added model_validator(mode='before') to detect legacy field names
  and raise actionable ValueError with rename mapping
- Added semantic bridge comments in PlanLifecycleService mapping
  task-type thresholds to phase-transition gates
- Added threshold_field to structured log messages for observability
- Restored categorised CLI automation-profile show output to match
  spec (Phase Transitions / Decision Automation / Self-Repair /
  Execution Controls) instead of flat list
- Added missing access_network field to spec show output examples
  (Rich, Plain, JSON, YAML variants)
- Aligned ADR-017 profile fields table to all 11 fields with
  descriptions matching spec Automatable Tasks table
- Aligned automation_profiles.md threshold descriptions with spec
- Added spec section references in phase_reversion.md, error_recovery.md,
  and plan_execute.md for field naming context
- Extended repository roundtrip test to assert all 11 threshold fields
- Fixed benchmark _make_profile() passing safety fields as top-level
  kwargs instead of via SafetyProfile sub-model (incompatible with
  extra="forbid")
- Aligned CLI JSON/YAML output structure for automation-profile show
  with the specification grouped format (phase_transitions,
  decision_automation, self_repair, execution_controls)
- Moved safety boolean fields into the Execution Controls section
  of Rich output per spec examples
- Reverted auto profile description to "Fully automatic except apply"
  per specification (line 16703, line 28406)
- Improved bridge comments in test steps with semantic context for
  threshold-to-gate mappings

ISSUES CLOSED: #902
2026-03-30 13:18:07 +01:00

334 lines
12 KiB
Python

"""Step definitions for Automation Profile CLI coverage boost."""
from __future__ import annotations
import os
import tempfile
import warnings
from pathlib import Path
from unittest.mock import patch
import jsonschema
import yaml
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.automation_profile import (
_threshold_summary,
emit_automation_level_deprecation_warning,
)
from cleveragents.cli.commands.automation_profile import (
app as profile_app,
)
from cleveragents.core.exceptions import (
CleverAgentsError,
ValidationError,
)
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
AutomationProfile,
)
from cleveragents.infrastructure.database.repositories import (
AutomationProfileNotFoundError,
)
def _create_in_memory_profile_service():
"""Create an AutomationProfileService backed by an in-memory SQLite DB."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
AutomationProfileRepository,
)
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
repo = AutomationProfileRepository(session_factory=factory, auto_commit=True)
return AutomationProfileService(repo=repo)
def _make_profile(
name: str = "acme/strict",
description: str = "Strict review",
) -> AutomationProfile:
return AutomationProfile(
name=name,
description=description,
schema_version="1.0",
decompose_task=0.8,
create_tool=0.7,
select_tool=0.6,
)
def _write_temp(context: Context, content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
if not hasattr(context, "_temp_files"):
context._temp_files = []
context._temp_files.append(path)
return path
@given("an automation profile coverage CLI runner")
def step_coverage_runner(context: Context) -> None:
context.runner = CliRunner()
context.result = None
# Create an in-memory service and patch _get_service for this scenario
context._ap_service = _create_in_memory_profile_service()
context._ap_patcher = patch(
"cleveragents.cli.commands.automation_profile._get_service",
return_value=context._ap_service,
)
context._ap_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context._ap_patcher.stop)
@when("I delete a non-existent profile from the in-memory repo")
def step_delete_nonexistent_from_repo(context: Context) -> None:
context.repo_error = None
try:
context._ap_service._repo.delete("nonexistent/profile")
except AutomationProfileNotFoundError as exc:
context.repo_error = exc
@then("a NotFoundError should be raised from the repo")
def step_assert_not_found_error(context: Context) -> None:
assert context.repo_error is not None, "Expected NotFoundError but none was raised"
assert isinstance(context.repo_error, AutomationProfileNotFoundError)
@when("I call threshold summary on a built-in profile")
def step_call_threshold_summary(context: Context) -> None:
profile = BUILTIN_PROFILES["manual"]
context.threshold_result = _threshold_summary(profile)
@then("the threshold summary should contain strategize execute and apply values")
def step_assert_threshold_summary(context: Context) -> None:
result = context.threshold_result
assert "decompose_task=" in result
assert "create_tool=" in result
assert "select_tool=" in result
@when("I validate a guarded profile config against docs schema and model")
def step_validate_guarded_profile_schema_and_model(context: Context) -> None:
schema_path = Path("docs/schema/automation_profile.schema.yaml")
with open(schema_path) as fh:
schema = yaml.safe_load(fh)
config = {
"name": "acme/guarded",
"guards": {
"max_tool_calls_per_step": 5,
"max_total_cost": 25.0,
"tool_allowlist": ["read_file"],
"tool_denylist": ["shell_exec"],
"require_approval_for_writes": True,
"require_approval_for_apply": True,
},
}
context.guarded_profile_schema_error = None
context.guarded_profile_model_error = None
context.guarded_profile_model = None
try:
jsonschema.validate(instance=config, schema=schema)
except jsonschema.ValidationError as exc:
context.guarded_profile_schema_error = exc
try:
context.guarded_profile_model = AutomationProfile.from_config(config)
except Exception as exc: # pragma: no cover - assertion reports details
context.guarded_profile_model_error = exc
@then("the guarded profile config should pass schema and model validation")
def step_guarded_profile_schema_and_model_ok(context: Context) -> None:
assert context.guarded_profile_schema_error is None, (
"Expected docs schema validation to pass, got: "
f"{context.guarded_profile_schema_error}"
)
assert context.guarded_profile_model_error is None, (
"Expected AutomationProfile.from_config validation to pass, got: "
f"{context.guarded_profile_model_error}"
)
assert context.guarded_profile_model is not None
assert context.guarded_profile_model.guards is not None
assert context.guarded_profile_model.guards.max_tool_calls_per_step == 5
@given("a YAML file containing a list instead of a dict")
def step_non_dict_yaml(context: Context) -> None:
context.non_dict_yaml_path = _write_temp(context, "- item1\n- item2\n")
@when("I run automation-profile add with that non-dict YAML file")
def step_run_add_non_dict(context: Context) -> None:
context.result = context.runner.invoke(
profile_app, ["add", "--config", context.non_dict_yaml_path]
)
@then("the automation-profile coverage command should abort")
def step_coverage_command_abort(context: Context) -> None:
assert context.result is not None
assert context.result.exit_code != 0, (
f"Expected non-zero exit, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('the automation-profile coverage output should contain "{text}"')
def step_coverage_output_contains(context: Context, text: str) -> None:
assert context.result is not None
assert text.lower() in context.result.output.lower(), (
f"Expected '{text}' in output. Got: {context.result.output}"
)
@when("I run automation-profile add with a config that triggers FileNotFoundError")
def step_run_add_file_not_found(context: Context) -> None:
# Create a file that exists at check time but causes FileNotFoundError on open
path = _write_temp(
context,
"name: acme/test\ndescription: test\nschema_version: '1.0'\n",
)
original_open = open
def patched_open(p, *args, **kwargs):
if str(p) == path:
raise FileNotFoundError(f"File vanished: {p}")
return original_open(p, *args, **kwargs)
with patch("builtins.open", side_effect=patched_open):
context.result = context.runner.invoke(profile_app, ["add", "--config", path])
@given("a YAML file that triggers a ValidationError from the service")
def step_yaml_validation_error(context: Context) -> None:
context.validation_yaml_path = _write_temp(
context,
"name: acme/valerr\ndescription: test\nschema_version: '1.0'\n",
)
@when("I run automation-profile add with that validation-error YAML file")
def step_run_add_validation_error(context: Context) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.create_profile",
side_effect=ValidationError("Invalid profile data"),
):
context.result = context.runner.invoke(
profile_app, ["add", "--config", context.validation_yaml_path]
)
@given("a YAML file that triggers a CleverAgentsError from the service")
def step_yaml_ca_error(context: Context) -> None:
context.ca_error_yaml_path = _write_temp(
context,
"name: acme/caerr\ndescription: test\nschema_version: '1.0'\n",
)
@when("I run automation-profile add with that cleveragents-error YAML file")
def step_run_add_ca_error(context: Context) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.create_profile",
side_effect=CleverAgentsError("Something went wrong"),
):
context.result = context.runner.invoke(
profile_app, ["add", "--config", context.ca_error_yaml_path]
)
@given('a custom coverage profile "{name}" has been added')
def step_add_custom_coverage_profile(context: Context, name: str) -> None:
profile = _make_profile(name=name)
context._ap_service._repo.upsert(profile)
@when('I run automation-profile remove "{name}" without --yes and decline')
def step_remove_no_yes_decline(context: Context, name: str) -> None:
context.result = context.runner.invoke(profile_app, ["remove", name], input="n\n")
@when('I run automation-profile remove "{name}" without --yes and confirm')
def step_remove_no_yes_confirm(context: Context, name: str) -> None:
context.result = context.runner.invoke(profile_app, ["remove", name], input="y\n")
@then("the automation-profile coverage remove should succeed")
def step_coverage_remove_succeed(context: Context) -> None:
assert context.result is not None
assert context.result.exit_code == 0, (
f"Expected exit 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@when('I run automation-profile remove "{name}" with validation error')
def step_remove_validation_error(context: Context, name: str) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.delete_profile",
side_effect=ValidationError("Cannot delete"),
):
context.result = context.runner.invoke(profile_app, ["remove", name, "--yes"])
@when('I run automation-profile remove "{name}" with CleverAgentsError')
def step_remove_ca_error(context: Context, name: str) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.delete_profile",
side_effect=CleverAgentsError("Delete failed"),
):
context.result = context.runner.invoke(profile_app, ["remove", name, "--yes"])
@when("I run automation-profile list with a CleverAgentsError from service")
def step_list_ca_error(context: Context) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.list_profiles",
side_effect=CleverAgentsError("List failed"),
):
context.result = context.runner.invoke(profile_app, ["list"])
@when("I run automation-profile show with a CleverAgentsError from service")
def step_show_ca_error(context: Context) -> None:
with patch(
"cleveragents.cli.commands.automation_profile.AutomationProfileService.get_profile",
side_effect=CleverAgentsError("Show failed"),
):
context.result = context.runner.invoke(profile_app, ["show", "manual"])
@when("I call emit_automation_level_deprecation_warning")
def step_call_deprecation_warning(context: Context) -> None:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
emit_automation_level_deprecation_warning()
context.warnings = w
@then("a DeprecationWarning should be emitted")
def step_assert_deprecation_warning(context: Context) -> None:
assert len(context.warnings) >= 1, "Expected at least one warning"
assert any(issubclass(w.category, DeprecationWarning) for w in context.warnings), (
f"Expected DeprecationWarning, got: {[w.category for w in context.warnings]}"
)