Files
cleveragents-core/features/steps/estimation_skip_steps.py
aditya 3f6b255747 feat(estimation): add cost and risk estimation actor
Add --no-estimate opt-out flag for plan use command and persist
EstimationSkipped reason when estimation is skipped or fails.

Add EstimationReport Pydantic domain model (identical to #649) with
spec-aligned multi-dimensional output: cost range (min/max USD),
expected steps, expected child plans, rollback risk (0.0-1.0),
estimated duration in minutes, confidence (0.0-1.0), rationale, and
optional historical basis.

Add EstimationSkipped domain model with reason, timestamp, and
optional actor_name fields for tracking when estimation is skipped
via --no-estimate or when the estimation actor fails.

Add estimation_produced to DecisionType enum and STRATEGIZE_TYPES.

Add Alembic migration m6_006 adding estimation_report_json and
estimation_skipped_json columns to v3_plans and updating the
ck_decisions_type CHECK constraint.

Wire --no-estimate through plan use CLI: when set, creates
EstimationSkipped with reason and clears the estimation actor.
Update _run_estimation() to persist EstimationSkipped on actor
failure instead of only logging.

Add docs/reference/estimation.md documenting the estimation feature,
EstimationReport schema, EstimationSkipped schema, and CLI examples.

Add benchmarks/estimation_actor_bench.py with ASV benchmarks for
EstimationReport and EstimationSkipped operations.

Add 13 Behave scenarios (estimation_skip.feature) and 6 Robot
integration tests (estimation_skip.robot) covering EstimationSkipped
model, EstimationReport model, plan field mutual exclusion, and
--no-estimate CLI behavior.

ISSUES CLOSED: #209
2026-04-02 10:26:22 +00:00

324 lines
11 KiB
Python

"""Step definitions for estimation skip behavior tests."""
from __future__ import annotations
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.estimation import (
EstimationReport,
EstimationSkipped,
)
# ------------------------------------------------------------------
# EstimationSkipped model
# ------------------------------------------------------------------
@when('I create an EstimationSkipped with only reason "{reason}"')
def step_create_skipped(context: Context, reason: str) -> None:
context.estimation_skipped = EstimationSkipped(reason=reason)
@when('I create an EstimationSkipped with reason "{reason}" and actor "{actor}"')
def step_create_skipped_with_actor(context: Context, reason: str, actor: str) -> None:
context.estimation_skipped = EstimationSkipped(reason=reason, actor_name=actor)
@then("the EstimationSkipped should be created successfully")
def step_skipped_created(context: Context) -> None:
assert context.estimation_skipped is not None
@then('the EstimationSkipped reason should be "{reason}"')
def step_skipped_reason(context: Context, reason: str) -> None:
assert context.estimation_skipped.reason == reason
@then("the EstimationSkipped actor_name should be None")
def step_skipped_actor_none(context: Context) -> None:
assert context.estimation_skipped.actor_name is None
@then('the EstimationSkipped actor_name should be "{actor}"')
def step_skipped_actor_value(context: Context, actor: str) -> None:
assert context.estimation_skipped.actor_name == actor
@then("modifying the EstimationSkipped reason should raise an error")
def step_skipped_frozen(context: Context) -> None:
try:
context.estimation_skipped.reason = "modified" # type: ignore[misc]
raise AssertionError("Should have raised an error for frozen model")
except (ValidationError, TypeError, AttributeError):
pass # Expected: frozen model
@then("the EstimationSkipped should round-trip through model_dump and model_validate")
def step_skipped_round_trip(context: Context) -> None:
data = context.estimation_skipped.model_dump()
restored = EstimationSkipped.model_validate(data)
assert restored.reason == context.estimation_skipped.reason
assert restored.actor_name == context.estimation_skipped.actor_name
@when("I try to create an EstimationSkipped with empty reason")
def step_create_skipped_empty(context: Context) -> None:
context.skip_error = None
try:
EstimationSkipped(reason="")
except ValidationError as exc:
context.skip_error = exc
@then("an estimation skip validation error should be raised")
def step_skip_validation_error(context: Context) -> None:
assert context.skip_error is not None
# ------------------------------------------------------------------
# EstimationReport model
# ------------------------------------------------------------------
def _make_valid_report() -> EstimationReport:
return EstimationReport(
cost_range_usd_min=0.01,
cost_range_usd_max=0.50,
expected_steps=10,
expected_child_plans=3,
rollback_risk=0.15,
estimated_duration_minutes=5.5,
confidence=0.85,
rationale="Based on similar tasks.",
historical_basis=("plan-001", "plan-002"),
)
@when("I create a valid EstimationReport")
def step_create_report(context: Context) -> None:
context.estimation_report = _make_valid_report()
@then("the EstimationReport should be created successfully")
def step_report_created(context: Context) -> None:
assert context.estimation_report is not None
@then("the EstimationReport confidence should be {value:g}")
def step_report_confidence(context: Context, value: float) -> None:
assert context.estimation_report.confidence == value
@when("I try to create an EstimationReport with max cost less than min cost")
def step_report_bad_cost(context: Context) -> None:
context.report_error = None
try:
EstimationReport(
cost_range_usd_min=1.0,
cost_range_usd_max=0.5,
expected_steps=1,
expected_child_plans=0,
rollback_risk=0.1,
estimated_duration_minutes=1.0,
confidence=0.5,
rationale="Bad cost range.",
)
except ValidationError as exc:
context.report_error = exc
@when("I try to create an EstimationReport with confidence {value:g}")
def step_report_bad_confidence(context: Context, value: float) -> None:
context.report_error = None
try:
EstimationReport(
cost_range_usd_min=0.0,
cost_range_usd_max=0.0,
expected_steps=0,
expected_child_plans=0,
rollback_risk=0.0,
estimated_duration_minutes=0.0,
confidence=value,
rationale="Bad confidence.",
)
except ValidationError as exc:
context.report_error = exc
@when("I try to create an EstimationReport with rollback risk {value}")
def step_report_bad_risk(context: Context, value: str) -> None:
context.report_error = None
try:
EstimationReport(
cost_range_usd_min=0.0,
cost_range_usd_max=0.0,
expected_steps=0,
expected_child_plans=0,
rollback_risk=float(value),
estimated_duration_minutes=0.0,
confidence=0.5,
rationale="Bad risk.",
)
except ValidationError as exc:
context.report_error = exc
@when("I try to create an EstimationReport with {count:d} historical basis entries")
def step_report_too_many_basis(context: Context, count: int) -> None:
context.report_error = None
try:
EstimationReport(
cost_range_usd_min=0.0,
cost_range_usd_max=0.0,
expected_steps=0,
expected_child_plans=0,
rollback_risk=0.0,
estimated_duration_minutes=0.0,
confidence=0.5,
rationale="Too many basis entries.",
historical_basis=tuple(f"plan-{i}" for i in range(count)),
)
except ValidationError as exc:
context.report_error = exc
@then("an estimation report validation error should be raised")
def step_report_validation_error(context: Context) -> None:
assert context.report_error is not None
@then("modifying the EstimationReport rationale should raise an error")
def step_report_frozen(context: Context) -> None:
try:
context.estimation_report.rationale = "modified" # type: ignore[misc]
raise AssertionError("Should have raised an error for frozen model")
except (ValidationError, TypeError, AttributeError):
pass # Expected: frozen model
# ------------------------------------------------------------------
# Plan fields
# ------------------------------------------------------------------
@when("I create a plan with estimation_skipped set")
def step_plan_with_skipped(context: Context) -> None:
from ulid import ULID
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
)
context.plan = Plan(
identity=PlanIdentity(plan_id=str(ULID())),
namespaced_name=NamespacedName.parse("local/test-skip"),
action_name="local/test-action",
description="Test plan with estimation skipped",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
estimation_skipped=EstimationSkipped(reason="Skipped for test"),
)
@when("I create a plan with estimation_report set")
def step_plan_with_report(context: Context) -> None:
from ulid import ULID
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
)
context.plan = Plan(
identity=PlanIdentity(plan_id=str(ULID())),
namespaced_name=NamespacedName.parse("local/test-report"),
action_name="local/test-action",
description="Test plan with estimation report",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
estimation_report=_make_valid_report(),
)
@then("the plan estimation_skipped should not be None")
def step_plan_skipped_not_none(context: Context) -> None:
assert context.plan.estimation_skipped is not None
@then("the plan estimation_report should be None")
def step_plan_report_none(context: Context) -> None:
assert context.plan.estimation_report is None
@then("the plan estimation_report should not be None")
def step_plan_report_not_none(context: Context) -> None:
assert context.plan.estimation_report is not None
@then("the plan estimation_skipped should be None")
def step_plan_skipped_none(context: Context) -> None:
assert context.plan.estimation_skipped is None
# ------------------------------------------------------------------
# --no-estimate lifecycle integration
# ------------------------------------------------------------------
@given("a lifecycle service with a test action")
def step_lifecycle_service(context: Context) -> None:
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
settings = Settings()
context.lifecycle_service = PlanLifecycleService(settings=settings)
context.lifecycle_service.create_action(
name="local/test-est-skip",
description="Test action for estimation skip",
definition_of_done="All tests pass",
strategy_actor="local/mock-strategy",
execution_actor="local/mock-execute",
estimation_actor="local/mock-estimation",
)
@when("I use the action with --no-estimate")
def step_use_action_no_estimate(context: Context) -> None:
plan = context.lifecycle_service.use_action(
action_name="local/test-est-skip",
)
# Simulate --no-estimate behavior
plan.estimation_skipped = EstimationSkipped(
reason="User opted out via --no-estimate",
actor_name=plan.estimation_actor,
)
plan.estimation_actor = None
context.plan = plan
# Also set est_plan for compatibility with shared step from estimation_actor_steps.py
context.est_plan = plan
@then("the plan should have estimation_skipped set")
def step_plan_has_skipped(context: Context) -> None:
assert context.plan.estimation_skipped is not None
@then('the estimation_skipped reason should mention "{text}"')
def step_skipped_reason_mention(context: Context, text: str) -> None:
assert text in context.plan.estimation_skipped.reason
# Note: "the plan estimation_actor should be None" is defined in
# estimation_actor_steps.py and reused here.