Files
cleveragents-core/features/steps/plan_dod_gating_steps.py
T
HAL9000 0dcf7bf152 fix(plan-lifecycle): gate Apply phase on Definition-of-Done evaluation
Before transitioning to the Apply phase, PlanLifecycleService.apply_plan
now evaluates the plan's definition_of_done criteria using DoDEvaluator
(TextMatchEvaluator). If any required criteria fail, a DoDGatingError is
raised and the plan remains in Execute/COMPLETE state. The evaluation
result is stored in plan.validation_summary with dod_evaluated=True and
dod_all_passed reflecting the outcome. Plans with no DoD text skip
evaluation and proceed normally.

Adds DoDGatingError exception class and _evaluate_dod() helper method.
Adds BDD feature file and step definitions for DoD gating scenarios.

ISSUES CLOSED: #7927
2026-06-02 05:14:43 -04:00

313 lines
12 KiB
Python

"""Step definitions for Definition-of-Done gating in the Apply phase.
Tests that ``PlanLifecycleService.apply_plan`` evaluates DoD criteria
before transitioning to the Apply phase and blocks the transition when
any required criteria fail.
"""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
DoDGatingError,
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Background / setup
# ---------------------------------------------------------------------------
@given("I have a plan lifecycle service for DoD gating")
def step_create_dod_lifecycle_service(context: Context) -> None:
"""Create a fresh PlanLifecycleService for DoD gating tests."""
settings = Settings()
context.dod_service = PlanLifecycleService(settings=settings)
context.dod_plan = None
context.dod_error = None
# ---------------------------------------------------------------------------
# Plan creation helpers
# ---------------------------------------------------------------------------
def _create_plan_in_execute_complete(
context: Context,
override_dod: str | None = None,
override_arguments: dict[str, str] | None = None,
) -> None:
"""Create an action + plan and drive it to Execute/COMPLETE state.
The action is always created with a valid DoD text. After plan
creation, the plan's ``definition_of_done`` and ``arguments`` fields
are overridden directly so tests can exercise arbitrary DoD text and
argument combinations without needing to declare them on the action.
"""
service: PlanLifecycleService = context.dod_service
action = service.create_action(
name="local/dod-test-action",
description="DoD gating test action",
definition_of_done="placeholder definition of done",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="test-project")],
)
# Override DoD and arguments directly on the plan object so tests
# can exercise arbitrary combinations without action-level validation.
if override_dod is not None:
plan.definition_of_done = override_dod
if override_arguments is not None:
plan.arguments = override_arguments
# Drive to Execute/COMPLETE
service.start_strategize(plan.identity.plan_id)
service.complete_strategize(plan.identity.plan_id)
service.execute_plan(plan.identity.plan_id)
service.start_execute(plan.identity.plan_id)
service.complete_execute(plan.identity.plan_id)
context.dod_plan = service.get_plan(plan.identity.plan_id)
@given("I have a plan with no definition of done in execute complete state")
def step_plan_no_dod(context: Context) -> None:
"""Create a plan with definition_of_done cleared to empty string."""
_create_plan_in_execute_complete(context, override_dod="")
@given("I have a plan with empty definition of done in execute complete state")
def step_plan_empty_dod(context: Context) -> None:
"""Create a plan with definition_of_done set to whitespace only."""
_create_plan_in_execute_complete(context, override_dod=" ")
@given(
'I have a plan with definition of done "{dod_text}" and argument "{arg_key}" equal to "{arg_value}"'
)
def step_plan_with_dod_and_arg(
context: Context, dod_text: str, arg_key: str, arg_value: str
) -> None:
"""Create a plan with a specific DoD text and a single argument."""
_create_plan_in_execute_complete(
context,
override_dod=dod_text,
override_arguments={arg_key: arg_value},
)
@given('I have a plan with definition of done "{dod_text}" and no arguments')
def step_plan_with_dod_no_args(context: Context, dod_text: str) -> None:
"""Create a plan with a specific DoD text and no arguments."""
# Replace literal \n with actual newlines for multi-line DoD
actual_dod = dod_text.replace("\\n", "\n")
_create_plan_in_execute_complete(
context,
override_dod=actual_dod,
override_arguments={},
)
# ---------------------------------------------------------------------------
# Apply actions
# ---------------------------------------------------------------------------
@when("I apply the plan with DoD gating")
def step_apply_plan_dod(context: Context) -> None:
"""Apply the plan (expecting success)."""
service: PlanLifecycleService = context.dod_service
plan_id = context.dod_plan.identity.plan_id
context.dod_error = None
context.dod_plan = service.apply_plan(plan_id)
@when("I try to apply the plan with DoD gating")
def step_try_apply_plan_dod(context: Context) -> None:
"""Attempt to apply the plan, capturing DoDGatingError if raised."""
service: PlanLifecycleService = context.dod_service
plan_id = context.dod_plan.identity.plan_id
context.dod_error = None
try:
context.dod_plan = service.apply_plan(plan_id)
except DoDGatingError as exc:
context.dod_error = exc
# Refresh plan state from service after the blocked attempt
context.dod_plan = service.get_plan(plan_id)
@when("I start the apply phase")
def step_start_apply_dod(context: Context) -> None:
"""Start the Apply phase processing."""
service: PlanLifecycleService = context.dod_service
plan_id = context.dod_plan.identity.plan_id
context.dod_plan = service.start_apply(plan_id)
@when("I complete the apply phase")
def step_complete_apply_dod(context: Context) -> None:
"""Complete the Apply phase."""
service: PlanLifecycleService = context.dod_service
plan_id = context.dod_plan.identity.plan_id
context.dod_plan = service.complete_apply(plan_id)
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@then("the plan should transition to apply phase")
def step_assert_plan_in_apply(context: Context) -> None:
"""Assert the plan is now in the Apply phase."""
plan = context.dod_plan
assert plan.phase == PlanPhase.APPLY, (
f"Expected plan phase 'apply', got '{plan.phase.value}'"
)
@then("the plan should remain in execute phase")
def step_assert_plan_in_execute(context: Context) -> None:
"""Assert the plan is still in the Execute phase."""
plan = context.dod_plan
assert plan.phase == PlanPhase.EXECUTE, (
f"Expected plan phase 'execute', got '{plan.phase.value}'"
)
@then('the dod plan phase should be "{expected_phase}"')
def step_assert_dod_plan_phase(context: Context, expected_phase: str) -> None:
"""Assert the DoD plan phase matches the expected value."""
plan = context.dod_plan
assert plan.phase.value == expected_phase, (
f"Expected plan phase '{expected_phase}', got '{plan.phase.value}'"
)
@then('the dod plan processing state should be "{expected_state}"')
def step_assert_dod_plan_processing_state(
context: Context, expected_state: str
) -> None:
"""Assert the DoD plan processing state matches the expected value."""
plan = context.dod_plan
assert plan.processing_state.value == expected_state, (
f"Expected processing state '{expected_state}', "
f"got '{plan.processing_state.value}'"
)
@then("the plan validation summary should be empty")
def step_assert_validation_summary_empty(context: Context) -> None:
"""Assert the plan's validation_summary is None (no DoD evaluated)."""
plan = context.dod_plan
assert plan.validation_summary is None, (
f"Expected validation_summary to be None, got {plan.validation_summary!r}"
)
@then("the plan validation summary should record dod_evaluated as true")
def step_assert_dod_evaluated_true(context: Context) -> None:
"""Assert validation_summary has dod_evaluated=True."""
plan = context.dod_plan
assert plan.validation_summary is not None, (
"Expected validation_summary to be set, but it is None"
)
assert plan.validation_summary.get("dod_evaluated") is True, (
f"Expected dod_evaluated=True, got {plan.validation_summary!r}"
)
@then("the plan validation summary should record dod_all_passed as true")
def step_assert_dod_all_passed_true(context: Context) -> None:
"""Assert validation_summary has dod_all_passed=True."""
plan = context.dod_plan
assert plan.validation_summary is not None, (
"Expected validation_summary to be set, but it is None"
)
assert plan.validation_summary.get("dod_all_passed") is True, (
f"Expected dod_all_passed=True, got {plan.validation_summary!r}"
)
@then("the plan validation summary should record dod_all_passed as false")
def step_assert_dod_all_passed_false(context: Context) -> None:
"""Assert validation_summary has dod_all_passed=False."""
plan = context.dod_plan
assert plan.validation_summary is not None, (
"Expected validation_summary to be set, but it is None"
)
assert plan.validation_summary.get("dod_all_passed") is False, (
f"Expected dod_all_passed=False, got {plan.validation_summary!r}"
)
@then("a DoD gating error should be raised")
def step_assert_dod_gating_error(context: Context) -> None:
"""Assert a DoDGatingError was raised."""
assert context.dod_error is not None, (
"Expected a DoDGatingError to be raised, but no error was captured"
)
assert isinstance(context.dod_error, DoDGatingError), (
f"Expected DoDGatingError, got {type(context.dod_error).__name__}"
)
@then(
"the DoD gating error should report {failed:d} failed criteria out of {total:d} total"
)
def step_assert_dod_error_counts(context: Context, failed: int, total: int) -> None:
"""Assert the DoDGatingError has the expected failure counts."""
error = context.dod_error
assert isinstance(error, DoDGatingError), (
f"Expected DoDGatingError, got {type(error).__name__}"
)
assert error.failed_count == failed, (
f"Expected failed_count={failed}, got {error.failed_count}"
)
assert error.total_count == total, (
f"Expected total_count={total}, got {error.total_count}"
)
@then('the plan validation summary should contain key "{key}" with value true')
def step_assert_validation_summary_key_true(context: Context, key: str) -> None:
"""Assert validation_summary contains a key with value True."""
plan = context.dod_plan
assert plan.validation_summary is not None, (
"Expected validation_summary to be set, but it is None"
)
assert key in plan.validation_summary, (
f"Expected key '{key}' in validation_summary, "
f"got keys: {list(plan.validation_summary.keys())}"
)
assert plan.validation_summary[key] is True, (
f"Expected validation_summary['{key}']=True, "
f"got {plan.validation_summary[key]!r}"
)
@then('the plan validation summary should contain key "{key}"')
def step_assert_validation_summary_has_key(context: Context, key: str) -> None:
"""Assert validation_summary contains a specific key."""
plan = context.dod_plan
assert plan.validation_summary is not None, (
"Expected validation_summary to be set, but it is None"
)
assert key in plan.validation_summary, (
f"Expected key '{key}' in validation_summary, "
f"got keys: {list(plan.validation_summary.keys())}"
)