Files
temp/features/steps/plan_cli_legacy_r2_steps.py
brent.edwards 84b0c10dbf fix(cli): plan correct active-plan resolution in isolated environments (#1184)
## Summary
- tighten `plan correct` active-plan fallback so it only runs for isolated `CLEVERAGENTS_HOME` mismatch cases and never when explicit DB env overrides are configured
- narrow fallback exception handling in `_resolve_active_plan_id()` to expected DB/path/service failures; unexpected errors now surface instead of being silently swallowed
- add BDD regression coverage for both safeguards in `features/consolidated_plan_misc.feature` + `features/steps/plan_cli_legacy_r2_steps.py`

## Validation
- `nox -e lint`: PASS
- `nox -e typecheck`: PASS
- `nox -e unit_tests`: PASS
- `nox -e integration_tests`: FAIL in current branch baseline (29 failing Robot integration tests in this environment)
- `nox -e e2e_tests`: FAIL in current branch baseline (45 failing E2E tests in this environment)
- `nox -e coverage_report`: PASS (97%)

Closes #1025

Reviewed-on: cleveragents/cleveragents-core#1184
Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-03-31 01:49:16 +00:00

550 lines
18 KiB
Python

"""Step definitions for plan_cli_legacy_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 3 of 3.
Covers:
- Legacy wrappers: no-project branch, continue_command prompt/no-prompt
- ``_resolve_active_plan_id``: no active plans, service error
- ``build_command``: None changes -> empty list
- ``list_command``: None plans -> empty list
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
import os
import tempfile
import warnings
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import typer
from behave import then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import (
_resolve_active_plan_id,
)
from cleveragents.core.exceptions import CleverAgentsError
from cleveragents.domain.models.core.plan import (
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
_runner = CliRunner()
def _ulid(suffix: str = "A1") -> str:
"""Return a valid 26-char ULID for tests.
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
"""
# Map potentially invalid chars to valid Crockford Base32
cleaned = (
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
)
base = _ULID_BASE + cleaned
return base[:26]
def _make_plan(
*,
plan_id: str | None = None,
name: str = "local/r2-plan",
description: str = "Test plan for r2 coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
validation_summary: dict[str, Any] | None = None,
error_message: str | None = None,
last_completed_step: int = -1,
last_checkpoint_id: str | None = None,
definition_of_done: str | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
action_name: str = "local/test-action",
) -> Plan:
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
definition_of_done=definition_of_done,
phase=phase,
processing_state=processing_state,
strategy_actor=None,
execution_actor=None,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
validation_summary=validation_summary,
error_message=error_message,
last_completed_step=last_completed_step,
last_checkpoint_id=last_checkpoint_id,
arguments=arguments or {},
arguments_order=arguments_order or [],
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
created_by=None,
reusable=True,
read_only=False,
)
def _mock_container(project_exists: bool = True) -> MagicMock:
"""Build a mock container for legacy wrapper tests."""
container = MagicMock()
project_service = MagicMock()
plan_service = MagicMock()
if project_exists:
project_service.get_current_project.return_value = SimpleNamespace(name="proj")
else:
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
container.plan_service.return_value = plan_service
return container
# ---------------------------------------------------------------------------
# When steps - legacy programmatic wrappers (no project)
# ---------------------------------------------------------------------------
@when("r2plan-I call tell_command with no project")
def step_tell_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import tell_command
tell_command("do stuff")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call build_command with no project")
def step_build_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import build_command
build_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call apply_command with no project")
def step_apply_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import apply_command
apply_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call new_command with no project")
def step_new_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import new_command
new_command("test")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call current_command with no project")
def step_current_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import current_command
current_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call list_command with no project")
def step_list_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import list_command
list_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call cd_command with no project")
def step_cd_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import cd_command
cd_command("some-plan")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call continue_command with no project")
def step_continue_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - continue_command with prompt / no-prompt
# ---------------------------------------------------------------------------
@when('r2plan-I call continue_command with prompt "{prompt}"')
def step_continue_with_prompt(context: Any, prompt: str) -> None:
container = _mock_container(project_exists=True)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=prompt)
context.r2_plan_service = container.plan_service()
@when("r2plan-I call continue_command with no prompt and no current plan")
def step_continue_no_prompt_no_plan(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.get_current_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=None)
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - build_command returns None / list_command returns None
# ---------------------------------------------------------------------------
@when("r2plan-I call build_command with build returning None")
def step_build_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.build_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import build_command
context.r2_build_result = build_command()
@when("r2plan-I call list_command with list returning None")
def step_list_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.list_plans.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import list_command
context.r2_list_result = list_command()
# ---------------------------------------------------------------------------
# When steps - _resolve_active_plan_id
# ---------------------------------------------------------------------------
@when("r2plan-I call _resolve_active_plan_id with no active plans")
def step_resolve_no_active(context: Any) -> None:
mock_svc = MagicMock()
# All plans are terminal
p = _make_plan(phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED)
mock_svc.list_plans.return_value = [p]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
@when("r2plan-I call _resolve_active_plan_id with service error")
def step_resolve_service_error(context: Any) -> None:
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
side_effect=CleverAgentsError("service unavailable"),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
@when(
"r2plan-I call _resolve_active_plan_id with explicit DB env override and no active plans"
)
def step_resolve_skips_home_fallback_when_db_override(context: Any) -> None:
mock_svc = MagicMock()
terminal_plan = _make_plan(
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
mock_svc.list_plans.return_value = [terminal_plan]
with tempfile.TemporaryDirectory(prefix="r2plan-db-override-") as tmp:
home_dir = os.path.join(tmp, "home")
os.makedirs(home_dir, exist_ok=True)
mock_uow = MagicMock()
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
return_value=mock_uow,
),
patch.dict(
os.environ,
{
"CLEVERAGENTS_HOME": home_dir,
"CLEVERAGENTS_DATABASE_URL": "sqlite:////tmp/explicit-test.db",
},
clear=False,
),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
context.r2_home_fallback_attempted = mock_uow.called
@when("r2plan-I call _resolve_active_plan_id with unexpected fallback error")
def step_resolve_unexpected_fallback_error(context: Any) -> None:
mock_svc = MagicMock()
terminal_plan = _make_plan(
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
mock_svc.list_plans.return_value = [terminal_plan]
with tempfile.TemporaryDirectory(prefix="r2plan-fallback-error-") as tmp:
home_dir = os.path.join(tmp, "home")
workspace_dir = os.path.join(tmp, "workspace")
os.makedirs(home_dir, exist_ok=True)
os.makedirs(workspace_dir, exist_ok=True)
original_cwd = os.getcwd()
os.chdir(workspace_dir)
try:
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
side_effect=RuntimeError("unexpected fallback crash"),
),
patch.dict(
os.environ,
{
"CLEVERAGENTS_HOME": home_dir,
"CLEVERAGENTS_DATABASE_URL": "",
"CLEVERAGENTS_TEST_DATABASE_URL": "",
},
clear=False,
),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
except RuntimeError as exc:
context.r2_error = exc
finally:
os.chdir(original_cwd)
# ---------------------------------------------------------------------------
# Then steps - legacy wrapper assertions
# ---------------------------------------------------------------------------
@then('r2plan-a CleverAgentsError should be raised with message "{fragment}"')
def step_agents_error(context: Any, fragment: str) -> None:
assert context.r2_error is not None, "Expected CleverAgentsError but none raised"
assert isinstance(context.r2_error, CleverAgentsError), (
f"Expected CleverAgentsError, got {type(context.r2_error).__name__}"
)
assert fragment in context.r2_error.message, (
f"Expected '{fragment}' in '{context.r2_error.message}'"
)
@then("r2plan-the continue_plan service method should be called")
def step_continue_called(context: Any) -> None:
context.r2_plan_service.continue_plan.assert_called_once()
@then("r2plan-the build result should be an empty list")
def step_build_empty(context: Any) -> None:
assert context.r2_build_result == [], f"Expected [], got {context.r2_build_result}"
@then("r2plan-the list result should be an empty list")
def step_list_empty(context: Any) -> None:
assert context.r2_list_result == [], f"Expected [], got {context.r2_list_result}"
@then("r2plan-a typer Abort should be raised")
def step_typer_abort(context: Any) -> None:
assert context.r2_error is not None, "Expected typer.Abort but none raised"
assert isinstance(context.r2_error, typer.Abort), (
f"Expected typer.Abort, got {type(context.r2_error).__name__}"
)
@then("r2plan-the home DB fallback should not be attempted")
def step_home_fallback_not_attempted(context: Any) -> None:
attempted = getattr(context, "r2_home_fallback_attempted", None)
assert attempted is False, "Expected fallback UnitOfWork not to be called"
@then("r2plan-a RuntimeError should be raised")
def step_runtime_error(context: Any) -> None:
assert context.r2_error is not None, "Expected RuntimeError but none raised"
assert isinstance(context.r2_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.r2_error).__name__}"
)