forked from HAL9000/cleveragents-core
007af498b8
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
334 lines
12 KiB
Python
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]}"
|
|
)
|