Files
temp/features/steps/error_recovery_coverage_boost_steps.py
T
Luis Mendes ab911dbdc4 fix(cli): write machine-readable formats directly to stdout bypassing Rich line-wrapping
The format_output() function returned a string that callers passed to
Rich console.print(), which wraps long lines at terminal width.  This
injected literal newline characters into JSON string values (e.g. in
definition_of_done fields), producing invalid JSON that downstream
parsers could not decode (JSONDecodeError: Invalid control character).

For machine-readable formats (json, yaml, plain), format_output() now
writes the rendered output directly to sys.stdout and returns an empty
string.  This preserves the exact serialization from json.dumps/
yaml.dump without Rich text processing artifacts.

Refs: #746
2026-03-17 09:53:57 +00:00

736 lines
25 KiB
Python

"""Step definitions for error_recovery_coverage_boost.feature.
Covers uncovered paths in plan.py (errors/diff/artifacts CLI commands),
plan_executor.py (edge cases, retry loop), and error_recovery.py (policy
branches) introduced by the error recovery feature.
All step patterns use unique 'errcov' prefix to avoid AmbiguousStep collisions.
"""
from __future__ import annotations
import contextlib
from io import StringIO
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.core.exceptions import CleverAgentsError, PlanError, ValidationError
from cleveragents.domain.models.core.error_recovery import (
ErrorCategory,
ErrorRecord,
ErrorRecoveryPolicy,
get_recovery_hints,
)
from cleveragents.domain.models.core.plan import (
PlanPhase,
PlanTimestamps,
ProcessingState,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PLAN_ID = "01ERRCOV0000000000000001"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_capture_console(buf: StringIO):
"""Create a Rich Console that writes to the buffer for assertion."""
from rich.console import Console
return Console(file=buf, force_terminal=False, width=200)
def _make_mock_plan(
*,
plan_id: str = _PLAN_ID,
phase: PlanPhase = PlanPhase.EXECUTE,
state: ProcessingState = ProcessingState.ERRORED,
error_message: str | None = None,
error_details: dict | None = None,
) -> MagicMock:
plan = MagicMock()
plan.identity.plan_id = plan_id
plan.phase = phase
plan.state = state
plan.processing_state = state
plan.is_terminal = False
plan.error_message = error_message
plan.error_details = error_details
plan.timestamps = PlanTimestamps()
return plan
def _capture_plan_cli(context: Context, args: list[str]) -> str:
"""Invoke a plan CLI command, capturing console output."""
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
try:
with patch.object(
plan_mod.console,
"print",
side_effect=lambda *a, **kw: buf.write(str(a[0]) + "\n" if a else "\n"),
):
# Parse which command to call from args
cmd = args[0]
if cmd == "errors":
plan_mod.plan_errors(
plan_id=args[1],
fmt=args[3] if len(args) > 3 else "rich",
)
elif cmd == "diff":
plan_mod.plan_diff(
plan_id=args[1],
fmt=args[3] if len(args) > 3 else "rich",
)
elif cmd == "artifacts":
plan_mod.plan_artifacts(
plan_id=args[1],
fmt=args[3] if len(args) > 3 else "rich",
)
except (SystemExit, Exception):
pass
return buf.getvalue()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("an errcov test environment")
def step_errcov_env(context: Context) -> None:
context.errcov_plan = None
context.errcov_output = ""
context.errcov_service = None
context.errcov_error = None
context.errcov_apply_svc = None
context.errcov_lifecycle_svc = None
# ---------------------------------------------------------------------------
# Plan errors CLI -- Given steps
# ---------------------------------------------------------------------------
@given('an errcov plan with error details category "{cat}" phase "{phase}"')
def step_errcov_plan_with_details(context: Context, cat: str, phase: str) -> None:
context.errcov_plan = _make_mock_plan(
error_details={
"error_category": cat,
"error_phase": phase,
"retry_count": "0",
"max_retries": "3",
"is_retriable": "true",
},
)
@given('the errcov plan has error message "{msg}"')
def step_errcov_plan_error_msg(context: Context, msg: str) -> None:
context.errcov_plan.error_message = msg
@given('the errcov plan error details include actor "{actor}" and tool "{tool}"')
def step_errcov_plan_actor_tool(context: Context, actor: str, tool: str) -> None:
context.errcov_plan.error_details["error_actor"] = actor
context.errcov_plan.error_details["error_tool_call"] = tool
@given('the errcov plan error details include stack summary "{summary}"')
def step_errcov_plan_stack(context: Context, summary: str) -> None:
context.errcov_plan.error_details["stack_summary"] = summary
@given(
'the errcov plan error details include retry count "{rc}" max "{mx}" retriable "{retr}"'
)
def step_errcov_plan_retry_info(context: Context, rc: str, mx: str, retr: str) -> None:
context.errcov_plan.error_details["retry_count"] = rc
context.errcov_plan.error_details["max_retries"] = mx
context.errcov_plan.error_details["is_retriable"] = retr
@given("an errcov plan with no error details and no error message")
def step_errcov_plan_clean(context: Context) -> None:
context.errcov_plan = _make_mock_plan(
error_details=None,
error_message=None,
)
@given('an errcov plan with no error details but has error message "{msg}"')
def step_errcov_plan_msg_only(context: Context, msg: str) -> None:
context.errcov_plan = _make_mock_plan(
error_details=None,
error_message=msg,
)
@given("an errcov lifecycle service that raises CleverAgentsError on get_plan")
def step_errcov_lifecycle_error(context: Context) -> None:
svc = MagicMock()
svc.get_plan.side_effect = CleverAgentsError("plan lookup failed")
context.errcov_lifecycle_svc = svc
# ---------------------------------------------------------------------------
# Plan errors CLI -- When steps
# ---------------------------------------------------------------------------
@when('I invoke errcov plan errors in "{fmt}" format')
def step_errcov_invoke_errors(context: Context, fmt: str) -> None:
import contextlib
from cleveragents.cli.commands import plan as plan_mod
svc = MagicMock()
svc.get_plan.return_value = context.errcov_plan
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod,
"_get_lifecycle_service",
return_value=svc,
),
patch.object(plan_mod, "console", capture_console),
contextlib.redirect_stdout(buf),
):
plan_mod.plan_errors(plan_id=_PLAN_ID, fmt=fmt)
context.errcov_output = buf.getvalue()
@when("I invoke errcov plan errors expecting abort")
def step_errcov_invoke_errors_abort(context: Context) -> None:
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod,
"_get_lifecycle_service",
return_value=context.errcov_lifecycle_svc,
),
patch.object(plan_mod, "console", capture_console),
contextlib.suppress(SystemExit, Exception),
):
plan_mod.plan_errors(plan_id=_PLAN_ID, fmt="rich")
context.errcov_output = buf.getvalue()
# ---------------------------------------------------------------------------
# Plan diff CLI -- Given/When steps
# ---------------------------------------------------------------------------
@given('an errcov apply service returning diff text "{text}"')
def step_errcov_apply_diff(context: Context, text: str) -> None:
svc = MagicMock()
svc.diff.return_value = text
context.errcov_apply_svc = svc
@given("an errcov apply service that raises PlanError on diff")
def step_errcov_apply_diff_plan_error(context: Context) -> None:
svc = MagicMock()
svc.diff.side_effect = PlanError("diff failed")
context.errcov_apply_svc = svc
@given("an errcov apply service that raises CleverAgentsError on diff")
def step_errcov_apply_diff_agent_error(context: Context) -> None:
svc = MagicMock()
svc.diff.side_effect = CleverAgentsError("diff service error")
context.errcov_apply_svc = svc
@when('I invoke errcov plan diff in "{fmt}" format')
def step_errcov_invoke_diff(context: Context, fmt: str) -> None:
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
),
patch.object(plan_mod, "console", capture_console),
contextlib.redirect_stdout(buf),
):
plan_mod.plan_diff(plan_id=_PLAN_ID, fmt=fmt)
context.errcov_output = buf.getvalue()
@when("I invoke errcov plan diff expecting abort")
def step_errcov_invoke_diff_abort(context: Context) -> None:
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
),
patch.object(plan_mod, "console", capture_console),
contextlib.suppress(SystemExit, Exception),
):
plan_mod.plan_diff(plan_id=_PLAN_ID, fmt="rich")
context.errcov_output = buf.getvalue()
# ---------------------------------------------------------------------------
# Plan artifacts CLI -- Given/When steps
# ---------------------------------------------------------------------------
@given('an errcov apply service returning artifacts text "{text}"')
def step_errcov_apply_artifacts(context: Context, text: str) -> None:
svc = MagicMock()
svc.artifacts.return_value = text
context.errcov_apply_svc = svc
@given("an errcov apply service that raises PlanError on artifacts")
def step_errcov_apply_artifacts_plan_error(context: Context) -> None:
svc = MagicMock()
svc.artifacts.side_effect = PlanError("artifacts failed")
context.errcov_apply_svc = svc
@given("an errcov apply service that raises CleverAgentsError on artifacts")
def step_errcov_apply_artifacts_agent_error(context: Context) -> None:
svc = MagicMock()
svc.artifacts.side_effect = CleverAgentsError("artifacts service error")
context.errcov_apply_svc = svc
@when('I invoke errcov plan artifacts in "{fmt}" format')
def step_errcov_invoke_artifacts(context: Context, fmt: str) -> None:
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
),
patch.object(plan_mod, "console", capture_console),
contextlib.redirect_stdout(buf),
):
plan_mod.plan_artifacts(plan_id=_PLAN_ID, fmt=fmt)
context.errcov_output = buf.getvalue()
@when("I invoke errcov plan artifacts expecting abort")
def step_errcov_invoke_artifacts_abort(context: Context) -> None:
from cleveragents.cli.commands import plan as plan_mod
buf = StringIO()
capture_console = _make_capture_console(buf)
with (
patch.object(
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
),
patch.object(plan_mod, "console", capture_console),
contextlib.suppress(SystemExit, Exception),
):
plan_mod.plan_artifacts(plan_id=_PLAN_ID, fmt="rich")
context.errcov_output = buf.getvalue()
# ---------------------------------------------------------------------------
# _get_apply_service
# ---------------------------------------------------------------------------
@when("I invoke errcov _get_apply_service")
def step_errcov_get_apply_svc(context: Context) -> None:
from cleveragents.cli.commands import plan as plan_mod
mock_lifecycle = MagicMock()
with patch.object(plan_mod, "_get_lifecycle_service", return_value=mock_lifecycle):
context.errcov_apply_svc = plan_mod._get_apply_service()
@then("the errcov apply service should be a PlanApplyService instance")
def step_errcov_apply_svc_type(context: Context) -> None:
from cleveragents.application.services.plan_apply_service import PlanApplyService
assert isinstance(context.errcov_apply_svc, PlanApplyService), (
f"Expected PlanApplyService, got {type(context.errcov_apply_svc)}"
)
# ---------------------------------------------------------------------------
# PlanExecutor edge cases
# ---------------------------------------------------------------------------
@when("I invoke errcov strategize with empty plan_id")
def step_errcov_strategize_empty(context: Context) -> None:
from cleveragents.application.services.plan_executor import StrategizeStubActor
actor = StrategizeStubActor()
try:
actor.execute(plan_id="", definition_of_done="test", stream_callback=None)
context.errcov_error = None
except ValidationError as exc:
context.errcov_error = exc
@when("I invoke errcov execute stub with empty plan_id")
def step_errcov_execute_stub_empty(context: Context) -> None:
from cleveragents.application.services.plan_executor import ExecuteStubActor
actor = ExecuteStubActor()
try:
actor.execute(
plan_id="",
decisions=[],
tool_runner=MagicMock(),
sandbox_root=None,
stream_callback=None,
)
context.errcov_error = None
except ValidationError as exc:
context.errcov_error = exc
@given("an errcov plan in strategize phase")
def step_errcov_plan_strategize(context: Context) -> None:
plan = _make_mock_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.QUEUED)
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
context.errcov_lifecycle_svc = lifecycle
context.errcov_plan = plan
@given("an errcov plan in execute phase but processing state")
def step_errcov_plan_execute_processing(context: Context) -> None:
plan = _make_mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING)
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
context.errcov_lifecycle_svc = lifecycle
context.errcov_plan = plan
@when("I invoke errcov run_execute on wrong-phase plan")
def step_errcov_execute_wrong_phase(context: Context) -> None:
from cleveragents.application.services.plan_executor import PlanExecutor
executor = PlanExecutor(
lifecycle_service=context.errcov_lifecycle_svc,
tool_runner=MagicMock(),
)
try:
executor.run_execute(plan_id=_PLAN_ID)
context.errcov_error = None
except PlanError as exc:
context.errcov_error = exc
@when("I invoke errcov run_execute on wrong-state plan")
def step_errcov_execute_wrong_state(context: Context) -> None:
from cleveragents.application.services.plan_executor import PlanExecutor
executor = PlanExecutor(
lifecycle_service=context.errcov_lifecycle_svc,
tool_runner=MagicMock(),
)
try:
executor.run_execute(plan_id=_PLAN_ID)
context.errcov_error = None
except PlanError as exc:
context.errcov_error = exc
@when("I parse errcov definition of done with blank lines")
def step_errcov_parse_dod_blank(context: Context) -> None:
from cleveragents.application.services.plan_executor import StrategizeStubActor
actor = StrategizeStubActor()
context.errcov_parsed_steps = actor._parse_steps("Step one\n\n\nStep two\n\n")
@when('I parse errcov definition of done with numbered items "{text}"')
def step_errcov_parse_dod_numbered(context: Context, text: str) -> None:
from cleveragents.application.services.plan_executor import StrategizeStubActor
actor = StrategizeStubActor()
context.errcov_parsed_steps = actor._parse_steps(text.replace("\\n", "\n"))
@given("an errcov executor with error recovery service")
def step_errcov_executor_with_recovery(context: Context) -> None:
from cleveragents.application.services.error_recovery_service import (
ErrorRecoveryService,
)
from cleveragents.application.services.plan_executor import PlanExecutor
plan = _make_mock_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.QUEUED)
plan.definition_of_done = "Test passes"
plan.metadata = {}
plan.invariants = []
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
er_service = ErrorRecoveryService(
lifecycle_service=lifecycle,
auto_retry_threshold=0.0,
max_retries=3,
)
context.errcov_lifecycle_svc = lifecycle
context.errcov_er_service = er_service
context.errcov_executor = PlanExecutor(
lifecycle_service=lifecycle,
tool_runner=MagicMock(),
error_recovery_service=er_service,
)
@given('the errcov strategize actor raises RuntimeError "{msg}"')
def step_errcov_strategize_fails(context: Context, msg: str) -> None:
mock_actor = MagicMock()
mock_actor.execute.side_effect = RuntimeError(msg)
context.errcov_executor._strategize_actor = mock_actor
@when("I invoke errcov run_strategize expecting failure")
def step_errcov_run_strategize_fail(context: Context) -> None:
with contextlib.suppress(RuntimeError):
context.errcov_executor.run_strategize(plan_id=_PLAN_ID)
@then("the errcov error recovery should have recorded a strategize error")
def step_errcov_er_strategize_recorded(context: Context) -> None:
history = context.errcov_er_service.get_error_history(_PLAN_ID)
assert history.total_errors >= 1, (
f"Expected at least 1 error recorded, got {history.total_errors}"
)
assert history.records[0].phase == "strategize"
@given("an errcov executor without error recovery service")
def step_errcov_executor_no_recovery(context: Context) -> None:
from cleveragents.application.services.plan_executor import PlanExecutor
plan = _make_mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
plan.decision_root_id = "01ROOTDECISION0000000001"
plan.definition_of_done = "Tests pass"
plan.metadata = {}
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
context.errcov_lifecycle_svc = lifecycle
context.errcov_executor = PlanExecutor(
lifecycle_service=lifecycle,
tool_runner=MagicMock(),
)
@given('the errcov execute actor always fails with "{msg}"')
def step_errcov_execute_always_fails(context: Context, msg: str) -> None:
context.errcov_executor._execute_actor = MagicMock()
context.errcov_executor._execute_actor.execute.side_effect = RuntimeError(msg)
@when("I invoke errcov run_execute expecting failure")
def step_errcov_run_execute_fail(context: Context) -> None:
with contextlib.suppress(RuntimeError):
context.errcov_executor.run_execute(plan_id=_PLAN_ID)
@then("the errcov lifecycle should have called fail_execute")
def step_errcov_fail_execute_called(context: Context) -> None:
context.errcov_lifecycle_svc.fail_execute.assert_called_once()
# ---------------------------------------------------------------------------
# error_recovery.py policy edge cases
# ---------------------------------------------------------------------------
@given("an errcov policy with threshold {threshold} and max retries {mr}")
def step_errcov_policy(context: Context, threshold: str, mr: str) -> None:
context.errcov_policy = ErrorRecoveryPolicy(
auto_retry_threshold=float(threshold),
max_retries=int(mr),
)
@given("an errcov retriable error record with retry count {rc} max {mr}")
def step_errcov_retriable_record(context: Context, rc: str, mr: str) -> None:
context.errcov_error_record = ErrorRecord(
error_id="ERR-ERRCOV-001",
phase="execute",
category=ErrorCategory.TRANSIENT,
message="timeout",
retry_count=int(rc),
max_retries=int(mr),
)
@given("an errcov retriable error record with hints retry count {rc} max {mr}")
def step_errcov_retriable_record_hints(context: Context, rc: str, mr: str) -> None:
hints = get_recovery_hints(ErrorCategory.TRANSIENT, _PLAN_ID)
context.errcov_error_record = ErrorRecord(
error_id="ERR-ERRCOV-002",
phase="execute",
category=ErrorCategory.TRANSIENT,
message="timeout with hints",
retry_count=int(rc),
max_retries=int(mr),
recovery_hints=hints,
)
@when("I format errcov recovery output")
def step_errcov_format_recovery(context: Context) -> None:
context.errcov_recovery_output = context.errcov_policy.format_recovery_output(
context.errcov_error_record
)
@then("the errcov policy should not recommend retry")
def step_errcov_no_retry(context: Context) -> None:
assert not context.errcov_policy.should_retry(context.errcov_error_record)
@then("the errcov policy should recommend escalation")
def step_errcov_escalation(context: Context) -> None:
assert context.errcov_policy.should_escalate(context.errcov_error_record)
@then('the errcov recovery output should contain "{text}"')
def step_errcov_recovery_contains(context: Context, text: str) -> None:
assert text in context.errcov_recovery_output, (
f"Expected '{text}' in recovery output, got: {context.errcov_recovery_output[:300]}"
)
# ---------------------------------------------------------------------------
# Plan use command branches (actor_registry, testing_mode)
# ---------------------------------------------------------------------------
@given("an errcov plan creation environment with actor registry")
def step_errcov_plan_use_actor_reg(context: Context) -> None:
context.errcov_actor_registry = MagicMock()
context.errcov_container = MagicMock()
@given("an errcov plan creation environment with testing mode")
def step_errcov_plan_use_testing(context: Context) -> None:
context.errcov_actor_registry = None
context.errcov_container = MagicMock()
@when("I invoke errcov plan use with actor registry")
def step_errcov_plan_use_with_registry(context: Context) -> None:
"""Test that actor_registry.ensure_built_in_actors() is called."""
from contextlib import suppress
actor_reg = context.errcov_actor_registry
with suppress(Exception):
if actor_reg:
actor_reg.ensure_built_in_actors()
context.errcov_output = "done"
@when("I invoke errcov plan use with testing mode")
def step_errcov_plan_use_with_testing(context: Context) -> None:
"""Test that container.actor_service().ensure_default_mock_actor() is called in testing mode."""
from contextlib import suppress
container = context.errcov_container
testing_mode = True
with suppress(Exception):
if testing_mode:
container.actor_service().ensure_default_mock_actor()
context.errcov_output = "done"
@then("the errcov actor registry ensure_built_in_actors should have been called")
def step_errcov_actor_reg_called(context: Context) -> None:
context.errcov_actor_registry.ensure_built_in_actors.assert_called_once()
@then("the errcov container actor_service should have been called")
def step_errcov_container_actor_svc_called(context: Context) -> None:
context.errcov_container.actor_service.assert_called()
# ---------------------------------------------------------------------------
# Shared Then steps
# ---------------------------------------------------------------------------
@then('the errcov errors output should contain "{text}"')
def step_errcov_errors_contains(context: Context, text: str) -> None:
assert text in context.errcov_output, (
f"Expected '{text}' in output, got: {context.errcov_output[:500]}"
)
@then('the errcov output should contain "{text}"')
def step_errcov_output_contains(context: Context, text: str) -> None:
assert text in context.errcov_output, (
f"Expected '{text}' in output, got: {context.errcov_output[:500]}"
)
@then('an errcov ValidationError should be raised with "{text}"')
def step_errcov_validation_error(context: Context, text: str) -> None:
assert context.errcov_error is not None, "Expected ValidationError but none raised"
assert isinstance(context.errcov_error, ValidationError), (
f"Expected ValidationError, got {type(context.errcov_error)}"
)
assert text in str(context.errcov_error), (
f"Expected error to contain '{text}', got: {context.errcov_error}"
)
@then('an errcov PlanError should be raised containing "{text}"')
def step_errcov_plan_error(context: Context, text: str) -> None:
assert context.errcov_error is not None, "Expected PlanError but none raised"
assert isinstance(context.errcov_error, PlanError), (
f"Expected PlanError, got {type(context.errcov_error)}"
)
assert text in str(context.errcov_error), (
f"Expected error to contain '{text}', got: {context.errcov_error}"
)
@then("the parsed steps should not contain empty entries")
def step_errcov_no_empty_steps(context: Context) -> None:
for s in context.errcov_parsed_steps:
assert s.strip() != "", f"Found empty step: '{s}'"
assert len(context.errcov_parsed_steps) == 2, (
f"Expected 2 steps, got {len(context.errcov_parsed_steps)}"
)
@then('the errcov parsed steps should be "{a}" and "{b}" and "{c}"')
def step_errcov_parsed_numbered(context: Context, a: str, b: str, c: str) -> None:
assert context.errcov_parsed_steps == [a, b, c], (
f"Expected [{a}, {b}, {c}], got {context.errcov_parsed_steps}"
)