Files
cleveragents-core/features/steps/plan_cancelled_enrichment_steps.py
freemo 65aa7a7842
CI / typecheck (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / build (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / security (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
feat(events): enrich PLAN_CANCELLED event with progress and resource cleanup context (#1301)
Enriches the PLAN_CANCELLED domain event emitted by PlanLifecycleService.cancel_plan() with additional context fields to improve audit trail quality (SEC7 Audit Logging).

Progress context fields: cancelled_phase, cancelled_processing_state, last_completed_step, subplan_count
Resource cleanup context fields: sandbox_refs, changeset_id, resources_pending_cleanup

Existing fields (reason, project_names) preserved for backward compatibility.
15 BDD scenarios added covering all new fields, accuracy, and backward compatibility.

Closes #717

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
2026-04-02 17:07:41 +00:00

253 lines
9.2 KiB
Python

"""Step definitions for plan_cancelled_enrichment.feature.
Tests that the PLAN_CANCELLED domain event is enriched with:
- Progress context: cancelled_phase, cancelled_processing_state,
last_completed_step, subplan_count
- Resource cleanup context: sandbox_refs, changeset_id,
resources_pending_cleanup
- Cancellation reason (existing field, verified still present)
- Backward-compatible fields: project_names, reason
Related: Forgejo issue #717
"""
from __future__ import annotations
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import ProjectLink
from cleveragents.infrastructure.events.types import EventType
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _RecordingBus:
"""Minimal event bus that records every emitted event."""
def __init__(self) -> None:
self.events: list = []
def emit(self, event) -> None:
self.events.append(event)
def subscribe(self, event_type, handler) -> None:
pass
def _get_cancelled_event(context: Context):
"""Return the first PLAN_CANCELLED event from the recording bus."""
events = [
e
for e in context.recording_bus.events
if e.event_type == EventType.PLAN_CANCELLED
]
assert events, (
f"No PLAN_CANCELLED event found. "
f"All events: {[e.event_type for e in context.recording_bus.events]}"
)
return events[0]
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("I have a plan lifecycle service for cancelled enrichment tests")
def step_create_service_for_enrichment(context: Context) -> None:
"""Create a PlanLifecycleService with a recording event bus."""
Settings._instance = None
settings = Settings()
context.recording_bus = _RecordingBus()
context.service = PlanLifecycleService(
settings=settings,
event_bus=context.recording_bus,
)
context.error = None
context.plan = None
# ---------------------------------------------------------------------------
# Given: action and plan creation
# ---------------------------------------------------------------------------
@given('an action "{action_name}" exists for enrichment tests')
def step_create_action_for_enrichment(context: Context, action_name: str) -> None:
"""Create an action with the given namespaced name."""
context.action = context.service.create_action(
name=action_name,
description=f"Action {action_name} for enrichment tests",
definition_of_done="Tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
)
@given('a plan created from "{action_name}" for enrichment tests')
def step_create_plan_for_enrichment(context: Context, action_name: str) -> None:
"""Create a plan from the given action."""
context.plan = context.service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="test-project")],
)
@given('a plan with sandbox refs created from "{action_name}"')
def step_create_plan_with_sandbox_refs(context: Context, action_name: str) -> None:
"""Create a plan and inject sandbox refs to simulate active sandboxes."""
plan = context.service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="test-project")],
)
# Inject sandbox refs directly (model is frozen, use model_copy)
updated_plan = plan.model_copy(
update={"sandbox_refs": ["sandbox-001", "sandbox-002"]}
)
# Update the in-memory store so cancel_plan retrieves the updated plan
context.service._plans[updated_plan.identity.plan_id] = updated_plan
context.plan = updated_plan
# ---------------------------------------------------------------------------
# When: cancel the plan
# ---------------------------------------------------------------------------
@when('I cancel the plan for enrichment tests with reason "{reason}"')
def step_cancel_plan_with_reason(context: Context, reason: str) -> None:
"""Cancel the plan with the given reason."""
context.error = None
try:
with patch(
"cleveragents.application.services.cleanup_service"
".stop_all_active_containers",
return_value=[],
):
context.plan = context.service.cancel_plan(
context.plan.identity.plan_id,
reason=reason,
)
except Exception as exc:
context.error = exc
@when("I cancel the plan for enrichment tests without a reason")
def step_cancel_plan_without_reason(context: Context) -> None:
"""Cancel the plan without providing a reason."""
context.error = None
try:
with patch(
"cleveragents.application.services.cleanup_service"
".stop_all_active_containers",
return_value=[],
):
context.plan = context.service.cancel_plan(
context.plan.identity.plan_id,
)
except Exception as exc:
context.error = exc
# ---------------------------------------------------------------------------
# Then: assertions on event details
# ---------------------------------------------------------------------------
@then('the PLAN_CANCELLED event details should include "{field}"')
def step_event_details_include_field(context: Context, field: str) -> None:
"""Assert that the PLAN_CANCELLED event details dict contains the field."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
assert field in event.details, (
f"Expected field '{field}' in PLAN_CANCELLED event details. "
f"Actual details keys: {list(event.details.keys())}"
)
@then('the PLAN_CANCELLED event details should have reason "{expected_reason}"')
def step_event_details_reason(context: Context, expected_reason: str) -> None:
"""Assert the reason field in the event details matches expected value."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
actual = event.details.get("reason")
assert actual == expected_reason, (
f"Expected reason '{expected_reason}', got '{actual}'"
)
@then("the PLAN_CANCELLED event reason field should be empty string")
def step_event_details_reason_empty(context: Context) -> None:
"""Assert the reason field in the event details is an empty string."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
actual = event.details.get("reason")
assert actual == "", f"Expected empty string reason, got '{actual}'"
@then('the PLAN_CANCELLED event details should have cancelled_phase "{expected_phase}"')
def step_event_details_cancelled_phase(context: Context, expected_phase: str) -> None:
"""Assert the cancelled_phase field matches the expected phase string."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
actual = event.details.get("cancelled_phase")
assert actual == expected_phase, (
f"Expected cancelled_phase '{expected_phase}', got '{actual}'"
)
@then(
"the PLAN_CANCELLED event details should have last_completed_step {expected_step:d}"
)
def step_event_details_last_completed_step(
context: Context, expected_step: int
) -> None:
"""Assert the last_completed_step field matches the expected value."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
actual = event.details.get("last_completed_step")
assert actual == expected_step, (
f"Expected last_completed_step {expected_step}, got {actual}"
)
@then(
"the PLAN_CANCELLED event details should have sandbox_refs count {expected_count:d}"
)
def step_event_details_sandbox_refs_count(
context: Context, expected_count: int
) -> None:
"""Assert the sandbox_refs list has the expected number of entries."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
sandbox_refs = event.details.get("sandbox_refs", [])
actual_count = len(sandbox_refs)
assert actual_count == expected_count, (
f"Expected {expected_count} sandbox_refs, got {actual_count}: {sandbox_refs}"
)
@then(
"the PLAN_CANCELLED event details should have resources_pending_cleanup"
" {expected_count:d}"
)
def step_event_details_resources_pending_cleanup(
context: Context, expected_count: int
) -> None:
"""Assert the resources_pending_cleanup count matches expected value."""
assert context.error is None, f"Unexpected error: {context.error}"
event = _get_cancelled_event(context)
actual = event.details.get("resources_pending_cleanup")
assert actual == expected_count, (
f"Expected resources_pending_cleanup {expected_count}, got {actual}"
)