Files
placeholder/features/steps/plan_cli_coverage_boost_steps.py
hamza.khyari 06428a5db6 feat(plan): implement git worktree sandbox for execute and merge-based apply
Replace flat shutil.copy2 apply with spec-aligned git worktree flow
(specification.md §13225-13276):

Execute phase: creates an isolated git worktree via GitWorktreeSandbox
for the plan's linked git-checkout resource.  LLM file output is
written to the worktree and committed on branch
cleveragents/plan-<plan_id> — no merge yet.

Apply phase: merges the worktree branch into the project's current
branch via git merge.  Prints spec-aligned panels:
  - Apply Summary: Plan ID, artifacts count, insertions/deletions,
    project name, applied-at timestamp
  - Sandbox Cleanup: worktree removed, branch merged to main
  - Footer: ✓ OK Changes applied

Non-git projects (fs-directory resources) fall back to the original
flat directory sandbox with shutil.copy2.

Also fixes:
- context_tier_hydrator metadata types (int/float → string) that
  caused Pydantic validation errors during context assembly
- A2A facade duplicate execute dispatch (idempotent handler)
- Logs actual error from context assembly failures

ISSUES CLOSED: #4454
2026-04-09 14:17:50 +00:00

605 lines
20 KiB
Python

"""Step definitions for plan_cli_coverage_boost.feature.
Covers uncovered branches in cleveragents/cli/commands/plan.py:
- _plan_spec_dict: error_message truthy branch, legacy fallback
- _print_lifecycle_plan: all optional timestamps, estimation_actor,
invariant_actor, non-Plan fallback
- list_plans: non-rich format path
- execute_plan: non-rich format path
- apply_plan: non-rich format path
- list_plans: regex, state, processing_state filtering
- cancel_plan: rich and non-rich paths, with and without reason
"""
from __future__ import annotations
from datetime import datetime, timedelta
from io import StringIO
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import (
_plan_spec_dict,
_print_lifecycle_plan,
)
from cleveragents.cli.commands.plan import (
app as plan_app,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# Valid ULIDs for test plans
_ULIDS = [
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"01ARZ3NDEKTSV4RRFFQ69G5FAW",
"01ARZ3NDEKTSV4RRFFQ69G5FAX",
"01ARZ3NDEKTSV4RRFFQ69G5FAY",
"01ARZ3NDEKTSV4RRFFQ69G5FAZ",
"01ARZ3NDEKTSV4RRFFQ69G5FB0",
]
def _make_plan(
*,
plan_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
name: str = "local/test-plan",
description: str = "Test plan for coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
error_message: str | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
project_links: list[ProjectLink] | None = None,
action_name: str = "local/test-action",
) -> Plan:
"""Build a real Plan object for testing."""
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
error_message=error_message,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
reusable=True,
read_only=False,
)
# --------------------------------------------------------------------------
# _plan_spec_dict helpers
# --------------------------------------------------------------------------
@given('a v3 Plan with error_message set to "{error_msg}"')
def step_plan_with_error_message(context, error_msg: str) -> None:
context.test_plan = _make_plan(error_message=error_msg)
@given("a v3 Plan with error_message set to None")
def step_plan_with_no_error_message(context) -> None:
context.test_plan = _make_plan(error_message=None)
@given('a non-Plan object with string value "{value}"')
def step_non_plan_object(context, value: str) -> None:
context.test_plan = value
@when("I call _plan_spec_dict on the plan")
def step_call_plan_spec_dict_on_plan(context) -> None:
context.spec_dict = _plan_spec_dict(context.test_plan)
@when("I call _plan_spec_dict on the object")
def step_call_plan_spec_dict_on_object(context) -> None:
context.spec_dict = _plan_spec_dict(context.test_plan)
@then('the spec dict should contain key "{key}" with value "{value}"')
def step_spec_dict_contains_key_value(context, key: str, value: str) -> None:
assert key in context.spec_dict, (
f"Key '{key}' not in spec dict: {context.spec_dict}"
)
assert str(context.spec_dict[key]) == value, (
f"Expected '{value}', got '{context.spec_dict[key]}'"
)
@then("the spec dict should equal {expected}")
def step_spec_dict_equals(context, expected: str) -> None:
import json
expected_dict = json.loads(expected.replace("'", '"'))
assert context.spec_dict == expected_dict, (
f"Expected {expected_dict}, got {context.spec_dict}"
)
@then('the spec dict should not contain key "{key}"')
def step_spec_dict_not_contains_key(context, key: str) -> None:
assert key not in context.spec_dict, (
f"Key '{key}' should not be in spec dict: {context.spec_dict}"
)
# --------------------------------------------------------------------------
# _print_lifecycle_plan helpers
# --------------------------------------------------------------------------
@given("a v3 Plan with all timestamps populated")
def step_plan_with_all_timestamps(context) -> None:
now = datetime.now()
ts = PlanTimestamps(
created_at=now - timedelta(hours=2),
updated_at=now,
strategize_started_at=now - timedelta(hours=1, minutes=50),
strategize_completed_at=now - timedelta(hours=1, minutes=40),
execute_started_at=now - timedelta(hours=1, minutes=30),
execute_completed_at=now - timedelta(hours=1),
applied_at=now - timedelta(minutes=30),
)
context.test_plan = _make_plan(
timestamps=ts,
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
@given('a v3 Plan with estimation_actor set to "{actor}"')
def step_plan_with_estimation_actor(context, actor: str) -> None:
context.test_plan = _make_plan(estimation_actor=actor)
@given('a v3 Plan with invariant_actor set to "{actor}"')
def step_plan_with_invariant_actor(context, actor: str) -> None:
context.test_plan = _make_plan(invariant_actor=actor)
@when("I call _print_lifecycle_plan on the plan")
def step_call_print_lifecycle_plan_on_plan(context) -> None:
buf = StringIO()
from rich.console import Console
test_console = Console(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(context.test_plan, title="Test Plan")
context.printed_output = buf.getvalue()
@when("I call _print_lifecycle_plan on the object")
def step_call_print_lifecycle_plan_on_object(context) -> None:
buf = StringIO()
from rich.console import Console
test_console = Console(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(context.test_plan, title="Legacy Plan")
context.printed_output = buf.getvalue()
@then('the printed output should contain "{text}"')
def step_printed_output_contains(context, text: str) -> None:
assert text in context.printed_output, (
f"Expected '{text}' in output:\n{context.printed_output}"
)
# --------------------------------------------------------------------------
# CLI runner steps for list_plans, execute, apply, list,
# cancel in non-rich and rich formats
# --------------------------------------------------------------------------
def _output(context) -> str:
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
@given("a plan lifecycle CLI runner for coverage")
def step_cli_runner_for_coverage(context) -> None:
context.runner = CliRunner()
@given("a mocked lifecycle service for plan coverage commands")
def step_mocked_lifecycle_service_for_coverage(context) -> None:
context.mock_lifecycle_service = MagicMock()
patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_lifecycle_service,
)
patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(patcher.stop)
# Mock _apply_sandbox_changes to avoid hitting the DI container
sandbox_patcher = patch(
"cleveragents.cli.commands.plan._apply_sandbox_changes",
)
sandbox_patcher.start()
context._cleanup_handlers.append(sandbox_patcher.stop)
# Mock _create_sandbox_for_plan for execute tests
create_sandbox_patcher = patch(
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
return_value=(None, None),
)
create_sandbox_patcher.start()
context._cleanup_handlers.append(create_sandbox_patcher.stop)
# Widen the Rich console so table columns do not wrap during tests
console_patcher = patch.object(plan_module.console, "width", 200)
console_patcher.start()
context._cleanup_handlers.append(console_patcher.stop)
@given("the mocked plan service returns legacy plans")
def step_mocked_plan_service_returns_legacy_plans(context) -> None:
"""Set up the container mock so list_plans returns legacy Plan objects."""
mock_plan = MagicMock()
mock_plan.name = "test-plan"
mock_plan.status = "active"
mock_plan.created_at = datetime.now()
mock_plan.current = False
mock_plan.id = "plan-001"
mock_project = MagicMock()
mock_project.name = "test-project"
mock_plan_service = MagicMock()
mock_plan_service.list_plans.return_value = [mock_plan]
mock_project_service = MagicMock()
mock_project_service.get_current_project.return_value = mock_project
mock_container = MagicMock()
mock_container.plan_service.return_value = mock_plan_service
mock_container.project_service.return_value = mock_project_service
patcher = patch(
"cleveragents.application.container.get_container",
return_value=mock_container,
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("the service has a complete strategize plan for execute")
def step_service_has_strategize_plan(context) -> None:
plan = _make_plan(
plan_id=_ULIDS[0],
name="local/exec-plan",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
)
context.mock_lifecycle_service.get_plan.return_value = plan
context.mock_lifecycle_service.execute_plan.return_value = plan
context._execute_plan_id = _ULIDS[0]
# Mock the plan executor so the execute phase runs with a mock
executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
executor_patcher.start()
context._cleanup_handlers.append(executor_patcher.stop)
@given("the service has a complete execute plan for apply")
def step_service_has_execute_plan(context) -> None:
pre_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
apply_queued_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.QUEUED,
)
applied_plan = _make_plan(
plan_id=_ULIDS[1],
name="local/apply-plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
# apply_plan calls get_plan twice: first to check read_only
# and phase, then again after apply_plan to drive _complete_apply_if_queued.
context.mock_lifecycle_service.get_plan.side_effect = [
pre_plan,
apply_queued_plan,
]
context.mock_lifecycle_service.apply_plan.return_value = apply_queued_plan
context.mock_lifecycle_service._complete_apply_if_queued.return_value = applied_plan
context._apply_plan_id = _ULIDS[1]
@given("the service has multiple plans for lifecycle list")
def step_service_has_multiple_plans(context) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[0],
name="local/alpha-plan",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
),
_make_plan(
plan_id=_ULIDS[1],
name="local/beta-plan",
action_name="local/other-action",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
),
]
context.mock_lifecycle_service.list_plans.return_value = plans
@given("the service has plans in different processing states")
def step_service_has_plans_different_states(context) -> None:
plans = [
_make_plan(
plan_id=_ULIDS[0],
name="local/plan-processing",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
),
_make_plan(
plan_id=_ULIDS[1],
name="local/plan-complete",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
),
]
context.mock_lifecycle_service.list_plans.return_value = plans
@given("the service can cancel a plan")
def step_service_can_cancel(context) -> None:
plan = _make_plan(
plan_id=_ULIDS[2],
name="local/cancel-me",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.CANCELLED,
)
context.mock_lifecycle_service.cancel_plan.return_value = plan
context._cancel_plan_id = _ULIDS[2]
# ---- When steps for CLI invocations ----
@when('I invoke list_plans with "--format" "{fmt}"')
def step_invoke_list_plans_format(context, fmt: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--format", fmt],
)
@when('I invoke execute with "--format" "json" and plan id')
def step_invoke_execute_json(context) -> None:
context.result = context.runner.invoke(
plan_app,
["execute", context._execute_plan_id, "--format", "json"],
)
@when('I invoke apply with "--format" "json" and plan id')
def step_invoke_apply_json(context) -> None:
context.result = context.runner.invoke(
plan_app,
["apply", "--yes", context._apply_plan_id, "--format", "json"],
)
@when('I invoke list with regex "{pattern}"')
def step_invoke_list_regex(context, pattern: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", pattern],
)
@when('I invoke list with "--state" "{state}"')
def step_invoke_list_state(context, state: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--state", state],
)
@when('I invoke list with "--processing-state" "{state}"')
def step_invoke_list_processing_state(context, state: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--processing-state", state],
)
@when('I invoke list with "--action" "{action}"')
def step_invoke_list_action(context, action: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--action", action],
)
@when('I invoke list with "--format" "{fmt}"')
def step_invoke_list_format(context, fmt: str) -> None:
context.result = context.runner.invoke(
plan_app,
["list", "--format", fmt],
)
@when('I invoke cancel with "--format" "json" and no reason')
def step_invoke_cancel_json_no_reason(context) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", "json"],
)
@when('I invoke cancel with "--format" "json" and reason "{reason}"')
def step_invoke_cancel_json_with_reason(context, reason: str) -> None:
context.result = context.runner.invoke(
plan_app,
[
"cancel",
context._cancel_plan_id,
"--format",
"json",
"--reason",
reason,
],
)
@when('I invoke cancel in rich format with reason "{reason}"')
def step_invoke_cancel_rich_with_reason(context, reason: str) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--reason", reason],
)
@when("I invoke cancel in rich format without reason")
def step_invoke_cancel_rich_no_reason(context) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id],
)
# ---- Then steps for CLI assertions ----
@then("the plan coverage command should succeed")
def step_plan_coverage_command_succeeds(context) -> None:
assert context.result.exit_code == 0, (
f"Expected success but got exit code {context.result.exit_code}.\n"
f"Output: {_output(context)}"
)
@then("the plan coverage command should abort")
def step_plan_coverage_command_aborts(context) -> None:
assert context.result.exit_code != 0, (
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
)
@then('the plan coverage output should contain "{text}"')
def step_plan_coverage_output_contains(context, text: str) -> None:
output = _output(context)
assert text in output, f"Expected '{text}' in output:\n{output}"
@then('the plan coverage output should not contain "{text}"')
def step_plan_coverage_output_not_contains(context, text: str) -> None:
output = _output(context)
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
@then("the execute JSON output has the spec-required envelope fields")
def step_execute_json_envelope_fields(context) -> None:
"""Verify the execute JSON output has the spec-required top-level envelope."""
import json
output = _output(context)
# The output may have a trailing newline; strip it
parsed = json.loads(output.strip())
required_fields = {"command", "status", "exit_code", "data", "timing", "messages"}
missing = required_fields - set(parsed.keys())
assert not missing, (
f"Missing envelope fields {missing} in execute JSON output:\n{output}"
)
assert parsed["command"] == "plan execute", (
f"Expected command='plan execute', got '{parsed['command']}'"
)
assert parsed["status"] == "ok", f"Expected status='ok', got '{parsed['status']}'"
assert parsed["exit_code"] == 0, (
f"Expected exit_code=0, got '{parsed['exit_code']}'"
)
assert isinstance(parsed["data"], dict), (
f"Expected data to be a dict, got {type(parsed['data'])}"
)
assert isinstance(parsed["messages"], list), (
f"Expected messages to be a list, got {type(parsed['messages'])}"
)
# Verify data has required fields
data = parsed["data"]
required_data_fields = {
"plan_id",
"phase",
"sandbox",
"worker",
"attempt",
"strategy_summary",
"progress",
}
missing_data = required_data_fields - set(data.keys())
assert not missing_data, (
f"Missing data fields {missing_data} in execute JSON output data:\n{output}"
)
@then("the execute JSON output data has sandbox with strategy field")
def step_execute_json_sandbox_strategy(context) -> None:
"""Verify the sandbox dict in execute JSON output has a strategy field."""
import json
output = _output(context)
parsed = json.loads(output.strip())
data = parsed["data"]
sandbox = data.get("sandbox")
assert isinstance(sandbox, dict), (
f"Expected sandbox to be a dict, got {type(sandbox)}: {sandbox}"
)
assert "strategy" in sandbox, f"Expected 'strategy' key in sandbox dict: {sandbox}"
@then("the execute JSON output data has progress list with label and status")
def step_execute_json_progress_list(context) -> None:
"""Verify the progress list in execute JSON output has label and status fields."""
import json
output = _output(context)
parsed = json.loads(output.strip())
data = parsed["data"]
progress = data.get("progress")
assert isinstance(progress, list), (
f"Expected progress to be a list, got {type(progress)}: {progress}"
)
assert len(progress) > 0, "Expected at least one progress step"
for step in progress:
assert isinstance(step, dict), (
f"Expected each progress step to be a dict, got {type(step)}: {step}"
)
assert "label" in step, f"Expected 'label' key in progress step: {step}"
assert "status" in step, f"Expected 'status' key in progress step: {step}"