fix(cli): add missing --yes flag to plan apply command #1127

Merged
hurui200320 merged 1 commits from fix/m4-plan-apply-yes-flag into master 2026-03-26 07:50:10 +00:00
26 changed files with 381 additions and 79 deletions
+2 -2
View File
@@ -221,11 +221,11 @@ class CLIRobotLifecycleSuite:
def time_plan_apply(self) -> None:
"""Benchmark plan lifecycle-apply via CLI."""
_runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
_runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
def time_full_lifecycle_flow(self) -> None:
"""Benchmark the full create -> use -> execute -> apply flow."""
_runner.invoke(action_app, ["create", "--config", self._yaml_path])
_runner.invoke(plan_app, ["use", "local/bench-lifecycle-action", "proj-bench"])
_runner.invoke(plan_app, ["execute", _PLAN_ULID])
_runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
_runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
+1 -1
View File
@@ -208,7 +208,7 @@ class M1PlanApplySuite:
def time_plan_lifecycle_apply(self) -> None:
"""Benchmark plan lifecycle-apply command."""
_runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
_runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
class M1FixtureLoadSuite:
+1 -1
View File
@@ -276,7 +276,7 @@ class PlanLifecycleCmdParsingSuite:
def time_lifecycle_apply(self) -> None:
"""Benchmark plan lifecycle-apply."""
_runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
_runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
def time_status_by_id(self) -> None:
"""Benchmark plan status with specific ID."""
+20 -2
View File
@@ -147,12 +147,30 @@ for details.
## `agents plan lifecycle-apply`
Transition a plan from Execute to Apply phase.
Transition a plan from Execute to Apply phase. Because Apply is a
destructive operation (it merges sandbox changesets into real project
resources), a confirmation prompt is displayed by default. Pass
`--yes` / `-y` to skip the prompt in scripts or CI pipelines.
Outdated
Review

[L2] The synopsis was updated to include [--yes|-y], but the description text (line 150) still only says "Transition a plan from Execute to Apply phase." Consider expanding to mention the confirmation prompt and the purpose of --yes.


Fixed. Expanded the description to: "Transition a plan from Execute to Apply phase. Because Apply is a destructive operation (it merges sandbox changesets into real project resources), a confirmation prompt is displayed by default. Pass --yes / -y to skip the prompt in scripts or CI pipelines."

**[L2]** The synopsis was updated to include `[--yes|-y]`, but the description text (line 150) still only says "Transition a plan from Execute to Apply phase." Consider expanding to mention the confirmation prompt and the purpose of `--yes`. --- ✅ **Fixed.** Expanded the description to: *"Transition a plan from Execute to Apply phase. Because Apply is a destructive operation (it merges sandbox changesets into real project resources), a confirmation prompt is displayed by default. Pass `--yes` / `-y` to skip the prompt in scripts or CI pipelines."*
### Synopsis
```bash
agents plan lifecycle-apply [PLAN_ID] [--format FORMAT]
agents plan lifecycle-apply [--yes|-y] [PLAN_ID] [--format FORMAT]
```
### Options
| Flag | Description |
|-------------------------|-------------------------------------------------------|
| `--yes`, `-y` | Skip the confirmation prompt and apply immediately |
| `--format`, `-f` | Output format: json, yaml, plain, table, rich |
### Arguments
| Argument | Description |
|------------|----------------------------------------------------------------|
| `PLAN_ID` | Plan ID to apply (optional; auto-selects if only one eligible) |
## `agents plan cancel`
Cancel a non-terminal plan.
@@ -1003,19 +1003,21 @@ def step_lc_plan_apply_general_error(context: Context) -> None:
def step_lc_plan_apply(context: Context) -> None:
pid = getattr(context, "lc_plan", None)
plan_id = pid.identity.plan_id if pid else _PLAN_ULID
context.lc_result = context.lc_runner.invoke(plan_app, ["lifecycle-apply", plan_id])
context.lc_result = context.lc_runner.invoke(
plan_app, ["lifecycle-apply", "--yes", plan_id]
)
@when("I run lifecycle coverage plan lifecycle-apply without ID")
def step_lc_plan_apply_no_id(context: Context) -> None:
context.lc_result = context.lc_runner.invoke(plan_app, ["lifecycle-apply"])
context.lc_result = context.lc_runner.invoke(plan_app, ["lifecycle-apply", "--yes"])
@when('I run lifecycle coverage plan lifecycle-apply with plan ID and format "{fmt}"')
def step_lc_plan_apply_fmt(context: Context, fmt: str) -> None:
pid = context.lc_plan.identity.plan_id
context.lc_result = context.lc_runner.invoke(
plan_app, ["lifecycle-apply", pid, "--format", fmt]
plan_app, ["lifecycle-apply", "--yes", pid, "--format", fmt]
)
@@ -228,7 +228,7 @@ def step_robot_alignment_plan_apply(context: Context) -> None:
),
)
context.ra_apply_result = context.ra_runner.invoke(
plan_app, ["lifecycle-apply", _PLAN_ULID]
plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID]
)
+1 -1
View File
@@ -574,7 +574,7 @@ def step_m1_plan_in_apply(context: Context) -> None:
@when("I m1 smoke invoke plan lifecycle-apply")
def step_m1_plan_apply(context: Context) -> None:
"""Invoke plan lifecycle-apply."""
result = context.runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
result = context.runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
context.last_result = result
+1 -1
View File
@@ -323,7 +323,7 @@ def step_invoke_execute_no_id(context: Any) -> None:
@when("r2plan-I invoke lifecycle-apply without plan_id")
def step_invoke_apply_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-apply"])
context.r2_result = _runner.invoke(plan_app, ["lifecycle-apply", "--yes"])
# ---------------------------------------------------------------------------
@@ -378,7 +378,7 @@ def step_invoke_execute_json(context) -> None:
def step_invoke_lifecycle_apply_json(context) -> None:
context.result = context.runner.invoke(
plan_app,
["lifecycle-apply", context._apply_plan_id, "--format", "json"],
["lifecycle-apply", "--yes", context._apply_plan_id, "--format", "json"],
)
+2 -2
View File
@@ -429,7 +429,7 @@ def step_invoke_use_with_invariant(context: Context, text: str) -> None:
def step_invoke_lifecycle_apply_no_id(context: Context) -> None:
"""Invoke lifecycle-apply without specifying a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply"]
plan_app, ["lifecycle-apply", "--yes"]
)
@@ -437,7 +437,7 @@ def step_invoke_lifecycle_apply_no_id(context: Context) -> None:
def step_invoke_lifecycle_apply_with_id(context: Context, pid: str) -> None:
"""Invoke lifecycle-apply with an explicit plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply", pid]
plan_app, ["lifecycle-apply", "--yes", pid]
)
@@ -740,7 +740,7 @@ def step_pec_invoke_execute_readonly(context: Context) -> None:
def step_pec_invoke_apply_readonly(context: Context) -> None:
with patch(_PATCH_LIFECYCLE, return_value=context.pec_lifecycle_svc):
context.pec_result = runner.invoke(
plan_app, ["lifecycle-apply", context.pec_plan_id]
plan_app, ["lifecycle-apply", "--yes", context.pec_plan_id]
)
+4 -2
View File
@@ -301,7 +301,7 @@ def step_plan_lifecycle_apply_without_plan_id(context, count: int) -> None:
if count == 1:
context.lifecycle_service.apply_plan.return_value = plans[0]
context.apply_plans = plans
context.result = context.runner.invoke(plan_app, ["lifecycle-apply"])
context.result = context.runner.invoke(plan_app, ["lifecycle-apply", "--yes"])
@when('I run plan lifecycle apply for plan id "{plan_id}" causing "{error_type}"')
@@ -324,7 +324,9 @@ def step_plan_lifecycle_apply_error(context, plan_id: str, error_type: str) -> N
else:
raise ValueError(f"Unhandled error type: {error_type}")
context.result = context.runner.invoke(plan_app, ["lifecycle-apply", plan_id])
context.result = context.runner.invoke(
plan_app, ["lifecycle-apply", "--yes", plan_id]
)
@when("I run plan status without a plan id and {count:d} plans exist")
@@ -753,7 +753,7 @@ def step_invoke_lifecycle_apply_with_id(context):
return_value=mock_service,
):
result = runner.invoke(
plan_app, ["lifecycle-apply", "01JAAAAAAAAAAAAAAAAAAAAAAA"]
plan_app, ["lifecycle-apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"]
)
context.result = result
@@ -774,7 +774,7 @@ def step_invoke_lifecycle_apply_auto_select(context):
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-apply"])
result = runner.invoke(plan_app, ["lifecycle-apply", "--yes"])
context.result = result
+253 -27
View File
@@ -1,25 +1,88 @@
"""Step definitions for TDD Bug #932 — plan apply missing --yes flag.
These steps verify that the ``lifecycle-apply`` CLI command accepts the
``--yes`` / ``-y`` flag as required by the specification. The spec
mandates ``agents plan apply [--yes|-y] <PLAN_ID>`` to skip the
confirmation prompt before applying plan changes, but the current
implementation does not recognise this flag.
``--yes`` / ``-y`` flag as required by the specification, and that the
confirmation prompt behaves correctly:
When the flag is missing, Typer (Click) rejects the option with
``"No such option: --yes"`` and exit code 2. The assertion that this
error is absent will **fail** proving the bug. The
``@tdd_expected_fail`` tag on the feature inverts this failure to a
pass while the bug remains open.
- With ``--yes`` / ``-y``: no confirmation prompt is shown.
- Without ``--yes``: a confirmation prompt appears; declining aborts with
``"Apply cancelled."``, accepting proceeds with the apply.
"""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
)
_PATCH_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
# Confirmation prompt text fragment used to detect whether the prompt appeared.
_CONFIRM_FRAGMENT = "Apply changes for plan"
def _make_mock_plan(
plan_id: str = "01JAAAAAAAAAAAAAAAAAAAAAAA",
phase: PlanPhase = PlanPhase.EXECUTE,
state: ProcessingState = ProcessingState.COMPLETE,
) -> LifecyclePlan:
"""Build a lightweight mock Plan for confirmation-prompt tests."""
from cleveragents.domain.models.core.plan import (
NamespacedName,
PlanIdentity,
PlanTimestamps,
ProjectLink,
)
now = datetime.now(tz=UTC)
return LifecyclePlan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName(
server=None, namespace="local", name="test-plan"
),
action_name="local/test-action",
description="Test plan for yes-flag scenarios",
definition_of_done="All tests pass",
phase=phase,
processing_state=state,
project_links=[ProjectLink(project_name="proj-1")],
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
created_by="test-user",
reusable=True,
read_only=False,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _build_mock_service() -> MagicMock:
"""Build a mock lifecycle service for --yes flag tests.
Returns a ``MagicMock`` whose ``get_plan`` returns a valid plan in
Execute/complete state and whose ``apply_plan`` returns a plan in
Apply/queued state.
"""
mock_service = MagicMock()
mock_service.get_plan.return_value = _make_mock_plan()
mock_service.apply_plan.return_value = _make_mock_plan(
phase=PlanPhase.APPLY, state=ProcessingState.QUEUED
)
return mock_service
# ---------------------------------------------------------------------------
# GIVEN
# ---------------------------------------------------------------------------
@given("a plan CLI runner for the yes-flag test")
@@ -28,28 +91,101 @@ def step_plan_cli_runner(context: Context) -> None:
context.apply_yes_runner = CliRunner()
@when("I invoke lifecycle-apply with --yes flag")
def step_invoke_lifecycle_apply_yes(context: Context) -> None:
"""Invoke ``plan lifecycle-apply --yes <PLAN_ID>``.
# ---------------------------------------------------------------------------
# WHEN — flag recognition (with mocked service for proper prompt testing)
# ---------------------------------------------------------------------------
We pass a dummy plan ID because we only care whether the ``--yes``
flag is recognised by the CLI framework, not whether the plan
exists. If ``--yes`` is unknown, Typer exits with code 2 *before*
any business logic runs.
@when("I invoke lifecycle-apply with --yes flag and a mocked service")
def step_invoke_lifecycle_apply_yes_mocked(context: Context) -> None:
"""Invoke ``plan lifecycle-apply --yes <PLAN_ID>`` with a mocked service.
The lifecycle service is mocked so the command completes the full
happy path. This allows us to verify that ``--yes`` genuinely
suppresses the confirmation prompt (not just that the command
crashes before reaching it).
"""
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "--yes", "DUMMY_PLAN_ID"],
)
mock_service = _build_mock_service()
context.apply_yes_mock_service = mock_service
with patch(_PATCH_LIFECYCLE, return_value=mock_service):
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"],
)
@when("I invoke lifecycle-apply with -y flag")
def step_invoke_lifecycle_apply_y(context: Context) -> None:
"""Invoke ``plan lifecycle-apply -y <PLAN_ID>``."""
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "-y", "DUMMY_PLAN_ID"],
)
@when("I invoke lifecycle-apply with -y flag and a mocked service")
def step_invoke_lifecycle_apply_y_mocked(context: Context) -> None:
"""Invoke ``plan lifecycle-apply -y <PLAN_ID>`` with a mocked service."""
mock_service = _build_mock_service()
context.apply_yes_mock_service = mock_service
with patch(_PATCH_LIFECYCLE, return_value=mock_service):
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "-y", "01JAAAAAAAAAAAAAAAAAAAAAAA"],
)
# ---------------------------------------------------------------------------
# WHEN — confirmation prompt behaviour
# ---------------------------------------------------------------------------
@when("I invoke lifecycle-apply without --yes and decline")
def step_invoke_lifecycle_apply_decline(context: Context) -> None:
"""Invoke ``lifecycle-apply`` without ``--yes``, supply ``n`` to the prompt."""
mock_service = _build_mock_service()
context.apply_yes_mock_service = mock_service
with patch(_PATCH_LIFECYCLE, return_value=mock_service):
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "01JAAAAAAAAAAAAAAAAAAAAAAA"],
input="n\n",
)
@when("I invoke lifecycle-apply without --yes and accept")
def step_invoke_lifecycle_apply_accept(context: Context) -> None:
"""Invoke ``lifecycle-apply`` without ``--yes``, supply ``y`` to the prompt."""
mock_service = _build_mock_service()
context.apply_yes_mock_service = mock_service
with patch(_PATCH_LIFECYCLE, return_value=mock_service):
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "01JAAAAAAAAAAAAAAAAAAAAAAA"],
input="y\n",
)
# ---------------------------------------------------------------------------
# WHEN — unexpected error scenario
# ---------------------------------------------------------------------------
@when("I invoke lifecycle-apply --yes with a service that raises an unexpected error")
def step_invoke_lifecycle_apply_unexpected_error(context: Context) -> None:
"""Invoke ``lifecycle-apply --yes`` with a service whose ``apply_plan``
raises a ``RuntimeError`` to exercise the catch-all ``except Exception``
handler.
"""
mock_service = _build_mock_service()
mock_service.apply_plan.side_effect = RuntimeError("Simulated unexpected failure")
context.apply_yes_mock_service = mock_service
with patch(_PATCH_LIFECYCLE, return_value=mock_service):
context.apply_yes_result = context.apply_yes_runner.invoke(
plan_app,
["lifecycle-apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"],
)
# ---------------------------------------------------------------------------
# THEN — flag recognition
# ---------------------------------------------------------------------------
@then("the lifecycle-apply --yes invocation should not report an unknown option")
@@ -89,3 +225,93 @@ def step_assert_y_recognised(context: Context) -> None:
f"Exit code: {exit_code}\n"
f"Output:\n{output}"
)
# ---------------------------------------------------------------------------
# THEN — prompt suppression (unified for both --yes and -y)
# ---------------------------------------------------------------------------
@then("the lifecycle-apply {flag} output should not contain the confirmation prompt")
def step_assert_flag_no_prompt(context: Context, flag: str) -> None:
"""When ``--yes`` or ``-y`` is supplied, the confirmation prompt must not appear."""
output = context.apply_yes_result.output
assert _CONFIRM_FRAGMENT not in output, (
f"The {flag} flag should suppress the confirmation prompt, "
f"but the output contained '{_CONFIRM_FRAGMENT}'.\n"
f"Output:\n{output}"
)
# ---------------------------------------------------------------------------
# THEN — exit code
# ---------------------------------------------------------------------------
@then("the lifecycle-apply exit code should be {expected_code:d}")
def step_assert_exit_code(context: Context, expected_code: int) -> None:
"""Assert the CLI invocation exited with the expected code."""
actual = context.apply_yes_result.exit_code
output = context.apply_yes_result.output
assert actual == expected_code, (
f"Expected exit code {expected_code}, got {actual}.\nOutput:\n{output}"
)
@then("the lifecycle-apply exit code should be non-zero")
def step_assert_exit_code_nonzero(context: Context) -> None:
"""Assert the CLI invocation exited with a non-zero code."""
actual = context.apply_yes_result.exit_code
output = context.apply_yes_result.output
assert actual != 0, f"Expected non-zero exit code, got {actual}.\nOutput:\n{output}"
# ---------------------------------------------------------------------------
# THEN — mock service call verification
# ---------------------------------------------------------------------------
@then("the lifecycle-apply should have called apply")
def step_assert_apply_called(context: Context) -> None:
"""Assert that ``apply_plan`` was called on the mock service."""
mock_service: MagicMock = context.apply_yes_mock_service
mock_service.apply_plan.assert_called_once()
@then("the lifecycle-apply should not have called apply")
def step_assert_apply_not_called(context: Context) -> None:
"""Assert that ``apply_plan`` was NOT called on the mock service."""
mock_service: MagicMock = context.apply_yes_mock_service
mock_service.apply_plan.assert_not_called()
# ---------------------------------------------------------------------------
# THEN — confirmation prompt behaviour
# ---------------------------------------------------------------------------
@then('the lifecycle-apply output should contain "{text}"')
def step_assert_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the given text."""
output = context.apply_yes_result.output
assert text in output, f"Expected output to contain '{text}'.\nOutput:\n{output}"
@then('the lifecycle-apply output should not contain "{text}"')
def step_assert_output_not_contains(context: Context, text: str) -> None:
"""Assert the CLI output does NOT contain the given text."""
output = context.apply_yes_result.output
assert text not in output, (
f"Expected output NOT to contain '{text}'.\nOutput:\n{output}"
)
@then("the lifecycle-apply output should contain the confirmation prompt")
def step_assert_output_has_prompt(context: Context) -> None:
"""Assert the confirmation prompt text appeared in the output."""
output = context.apply_yes_result.output
assert _CONFIRM_FRAGMENT in output, (
f"Expected output to contain the confirmation prompt "
f"'{_CONFIRM_FRAGMENT}', but it was not found.\n"
f"Output:\n{output}"
)
+36 -8
View File
@@ -1,22 +1,50 @@
@tdd_expected_fail @tdd_bug @tdd_bug_932
@tdd_bug @tdd_bug_932
Feature: TDD Bug #932 — plan apply missing --yes flag
As a developer
I want to verify that `agents plan lifecycle-apply` accepts the --yes
flag required by the specification
So that the bug is captured and will be caught by a regression test
The specification mandates `agents plan apply [--yes|-y] <PLAN_ID>`
but the current `lifecycle-apply` implementation does not accept
`--yes` or `-y`. Other destructive CLI commands (`session delete`,
`project delete`, `plan correct`, `plan rollback`) correctly implement
the `--yes`/`-y` pattern to skip confirmation prompts.
These scenarios verify that `lifecycle-apply` correctly handles
`--yes`/`-y` flags as a regression guard. Other destructive CLI
commands (`session delete`, `project delete`, `plan correct`,
`plan rollback`) correctly implement the `--yes`/`-y` pattern to skip
confirmation prompts.
Scenario: lifecycle-apply recognises the --yes long flag
Given a plan CLI runner for the yes-flag test
When I invoke lifecycle-apply with --yes flag
When I invoke lifecycle-apply with --yes flag and a mocked service
Then the lifecycle-apply --yes invocation should not report an unknown option
And the lifecycle-apply --yes output should not contain the confirmation prompt
And the lifecycle-apply exit code should be 0
And the lifecycle-apply should have called apply
Scenario: lifecycle-apply recognises the -y short flag
Given a plan CLI runner for the yes-flag test
When I invoke lifecycle-apply with -y flag
When I invoke lifecycle-apply with -y flag and a mocked service
Then the lifecycle-apply -y invocation should not report an unknown option
And the lifecycle-apply -y output should not contain the confirmation prompt
And the lifecycle-apply exit code should be 0
And the lifecycle-apply should have called apply
Scenario: lifecycle-apply without --yes prompts for confirmation and user declines
Given a plan CLI runner for the yes-flag test
When I invoke lifecycle-apply without --yes and decline
Outdated
Review

[M2] This scenario does not assert the exit code. All three other scenarios in this feature assert the lifecycle-apply exit code should be 0. The decline path currently exits with code 1 (from typer.Abort()), but this is untested.


Fixed. Added And the lifecycle-apply exit code should be 0 to the decline scenario. This assertion now passes because M1 was fixed to use typer.Exit(0) instead of typer.Abort().

**[M2]** This scenario does not assert the exit code. All three other scenarios in this feature assert `the lifecycle-apply exit code should be 0`. The decline path currently exits with code 1 (from `typer.Abort()`), but this is untested. --- ✅ **Fixed.** Added `And the lifecycle-apply exit code should be 0` to the decline scenario. This assertion now passes because M1 was fixed to use `typer.Exit(0)` instead of `typer.Abort()`.
Then the lifecycle-apply output should contain "Apply cancelled."
And the lifecycle-apply exit code should be 0
And the lifecycle-apply should not have called apply
Scenario: lifecycle-apply without --yes prompts for confirmation and user accepts
Given a plan CLI runner for the yes-flag test
When I invoke lifecycle-apply without --yes and accept
Then the lifecycle-apply output should not contain "Apply cancelled."
And the lifecycle-apply output should contain the confirmation prompt
And the lifecycle-apply exit code should be 0
And the lifecycle-apply should have called apply
Scenario: lifecycle-apply catches unexpected exceptions cleanly
Given a plan CLI runner for the yes-flag test
When I invoke lifecycle-apply --yes with a service that raises an unexpected error
Then the lifecycle-apply output should contain "Unexpected error"
And the lifecycle-apply output should not contain "Traceback"
And the lifecycle-apply exit code should be non-zero
+1 -1
View File
@@ -117,7 +117,7 @@ M2 Full Actor Compiler And LLM Integration
# ---- Step 9: Plan apply ----
${r_apply}= Run CleverAgents Command
... plan lifecycle-apply ${plan_id} --format plain
... plan lifecycle-apply --yes ${plan_id} --format plain
Should Not Contain ${r_apply.stdout}${r_apply.stderr} INTERNAL
Should Not Contain ${r_apply.stdout}${r_apply.stderr} Traceback
Log Apply output: ${r_apply.stdout}
+1 -1
View File
@@ -101,7 +101,7 @@ Full Flow Apply Step
... Apply may fail if the plan is not in the correct state after
... LLM execution; hard assertions on the success path only.
[Arguments] ${plan_id}
${apply}= Run CleverAgents Command plan lifecycle-apply ${plan_id} --format json expected_rc=None timeout=180s
${apply}= Run CleverAgents Command plan lifecycle-apply --yes ${plan_id} --format json expected_rc=None timeout=180s
Log Apply result rc=${apply.rc} stdout=${apply.stdout} stderr=${apply.stderr}
IF ${apply.rc} == 0
Output Should Contain ${apply} ${plan_id}
+1 -1
View File
@@ -493,7 +493,7 @@ WF05 Database Schema Migration With Safety Nets Review Profile
Log Baseline SHA before apply: ${baseline_sha}
${r_apply}= Run CleverAgents Command
... plan lifecycle-apply ${plan_id} --format json
... plan lifecycle-apply --yes ${plan_id} --format json
... expected_rc=None timeout=180s
Log Apply rc=${r_apply.rc} stdout=${r_apply.stdout}
IF ${r_apply.rc} == 0
+2 -2
View File
@@ -248,7 +248,7 @@ def plan_apply_changeset() -> None:
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service", return_value=svc
):
result = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
result = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
if result.exit_code != 0:
_fail(f"plan apply rc={result.exit_code}\n{result.output}")
@@ -309,7 +309,7 @@ def full_lifecycle() -> None:
assert r2.exit_code == 0, f"plan use: {r2.output}"
r3 = runner.invoke(plan_app, ["execute", _PLAN_ULID])
assert r3.exit_code == 0, f"plan execute: {r3.output}"
r4 = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
r4 = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
assert r4.exit_code == 0, f"plan apply: {r4.output}"
cs = store.get(cid)
+2 -2
View File
@@ -191,7 +191,7 @@ def plan_apply() -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
result = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-apply-ok")
else:
@@ -310,7 +310,7 @@ def full_lifecycle() -> None:
assert r3.exit_code == 0, f"plan execute failed: {r3.output}"
# Step 4: Plan apply
r4 = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
r4 = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
assert r4.exit_code == 0, f"plan apply failed: {r4.output}"
print("cli-lifecycle-full-e2e-ok")
+1 -1
View File
@@ -391,7 +391,7 @@ def plan_full_lifecycle() -> None:
_fail(f"plan diff crashed:\n{combined5}")
# Step 6: Plan lifecycle-apply — similar: plan not ready
r6 = run_cli("plan", "lifecycle-apply", plan_id, workspace=workspace)
r6 = run_cli("plan", "lifecycle-apply", "--yes", plan_id, workspace=workspace)
combined6 = r6.stdout + r6.stderr
if "Traceback" in combined6:
_fail(f"plan apply crashed:\n{combined6}")
+2 -2
View File
@@ -200,7 +200,7 @@ def plan_apply() -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
result = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
result = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
if result.exit_code == 0:
print("m1-plan-apply-ok")
else:
@@ -260,7 +260,7 @@ def full_lifecycle() -> None:
sys.exit(1)
# Step 4: plan apply
r4 = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
r4 = runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
if r4.exit_code != 0:
print(f"FAIL step 4: exit={r4.exit_code} output={r4.output}")
sys.exit(1)
+1 -1
View File
@@ -624,7 +624,7 @@ def plan_apply_lifecycle() -> None:
# lifecycle-apply on a strategize/queued plan — should get
# a controlled rejection, not a crash.
r3 = run_cli("plan", "lifecycle-apply", plan_id, workspace=workspace)
r3 = run_cli("plan", "lifecycle-apply", "--yes", plan_id, workspace=workspace)
combined = r3.stdout + r3.stderr
if "INTERNAL" in combined or "Traceback" in combined:
_fail(f"lifecycle-apply crashed:\n{combined}")
+3 -2
View File
@@ -6,8 +6,9 @@ that the flag is recognised by the CLI framework.
The helper reports the **real** outcome: it exits 0 and prints the sentinel
when the flag is accepted (bug is fixed), and exits 1 when the flag is
rejected (bug still present). The ``tdd_expected_fail_listener`` on the
Robot side handles pass/fail inversion while the bug remains open.
rejected (bug still present). The bug is now fixed and the
``@tdd_expected_fail`` tag has been removed; these tests serve as permanent
regression guards.
"""
from __future__ import annotations
+3 -4
View File
@@ -4,8 +4,7 @@ Documentation TDD Bug #932 — plan lifecycle-apply missing --yes flag
... command accepts the --yes / -y flag required by the
... specification. The spec mandates
... ``agents plan apply [--yes|-y] <PLAN_ID>`` to skip the
... confirmation prompt, but the current implementation does not
... recognise this flag.
... confirmation prompt.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
@@ -16,7 +15,7 @@ ${HELPER} ${CURDIR}/helper_tdd_plan_apply_yes_flag.py
*** Test Cases ***
TDD Plan Apply Yes Long Flag Via CLI
[Documentation] Verify that ``lifecycle-apply --yes`` is recognised
[Tags] tdd_expected_fail tdd_bug tdd_bug_932
[Tags] tdd_bug tdd_bug_932
${result}= Run Process ${PYTHON} ${HELPER} check-yes-long cwd=${WORKSPACE} timeout=30s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
@@ -25,7 +24,7 @@ TDD Plan Apply Yes Long Flag Via CLI
TDD Plan Apply Yes Short Flag Via CLI
[Documentation] Verify that ``lifecycle-apply -y`` is recognised
[Tags] tdd_expected_fail tdd_bug tdd_bug_932
[Tags] tdd_bug tdd_bug_932
${result}= Run Process ${PYTHON} ${HELPER} check-yes-short cwd=${WORKSPACE} timeout=30s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
+33 -7
View File
@@ -50,6 +50,7 @@ from cleveragents.core.exceptions import (
PlanError,
ValidationError,
)
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
# Regex for validating namespaced actor names (namespace/name format)
_NAMESPACED_ACTOR_RE = re.compile(r"^[a-z][a-z0-9-]*/[a-z][a-z0-9._-]*$")
2
@@ -2007,6 +2008,14 @@ def lifecycle_apply_plan(
str | None,
typer.Argument(help="Plan ID to apply (optional if only one plan)"),
] = None,
yes: Annotated[
bool,
typer.Option(
"--yes",
"-y",
help="Skip confirmation prompt",
),
] = False,
fmt: Annotated[
str,
typer.Option(
@@ -2020,6 +2029,8 @@ def lifecycle_apply_plan(
The plan must be in Execute phase with 'complete' state.
This is different from the legacy 'apply' command which applies file changes.
Use ``--yes`` / ``-y`` to skip the confirmation prompt in scripts.
"""
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
@@ -2032,10 +2043,6 @@ def lifecycle_apply_plan(
if not plan_id:
# Try to find plans eligible for apply: Execute/complete or
# Apply/queued (auto_progress may have already advanced).
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
)
exec_plans = service.list_plans(phase=PlanPhase.EXECUTE)
apply_plans = service.list_plans(phase=PlanPhase.APPLY)
@@ -2060,17 +2067,25 @@ def lifecycle_apply_plan(
# Fail-fast: read-only plans must not enter Apply phase
pre_plan = service.get_plan(plan_id)
if pre_plan is not None and pre_plan.read_only is True:
if pre_plan.read_only is True:
console.print(
f"[red]Cannot apply plan '{plan_id}': plan is read-only.[/red]"
)
raise typer.Abort()
from cleveragents.domain.models.core.plan import PlanPhase
# Confirmation prompt — Apply is destructive (merges sandbox into
# real resources). Skip when --yes / -y is provided.
if not yes:
confirm = typer.confirm(
f"\nApply changes for plan {plan_id}?", default=False
)
if not confirm:
Outdated
Review

[M3] Issue #932 acceptance criteria says the prompt should show "the plan ID and a summary of pending changes." Currently only the plan ID is shown. The spec example shows the summary after confirmation, so there is ambiguity. Consider either adding a pre-confirmation summary or clarifying the acceptance criteria.


⏸️ Deferred. The spec example in docs/specification.md shows "Apply changes for plan <ID>? [y/N]: y" without a pre-confirmation summary — the summary appears after confirmation in the spec output. The implementation matches the spec example. This is a ticket-vs-spec ambiguity that should be resolved with the ticket author. Noted in PR description as a known limitation.

**[M3]** Issue #932 acceptance criteria says the prompt should show "the plan ID and a summary of pending changes." Currently only the plan ID is shown. The spec example shows the summary *after* confirmation, so there is ambiguity. Consider either adding a pre-confirmation summary or clarifying the acceptance criteria. --- ⏸️ **Deferred.** The spec example in `docs/specification.md` shows `"Apply changes for plan <ID>? [y/N]: y"` without a pre-confirmation summary — the summary appears *after* confirmation in the spec output. The implementation matches the spec example. This is a ticket-vs-spec ambiguity that should be resolved with the ticket author. Noted in PR description as a known limitation.
console.print("[yellow]Apply cancelled.[/yellow]")
raise typer.Exit(0)
Outdated
Review

[M1] typer.Abort() here produces exit code 1 and appends "Aborted." to output. User declining a confirmation is not an error. correct_decision and legacy apply in this same file use typer.Exit(0) for the decline path, which is the more appropriate pattern.

Consider: raise typer.Exit(0)


Fixed. Changed to raise typer.Exit(0). Also added default=False to the typer.confirm() call for consistency with sibling commands (I2). The decline scenario now exits cleanly with code 0 and no spurious "Aborted." message.

**[M1]** `typer.Abort()` here produces exit code 1 and appends `"Aborted."` to output. User declining a confirmation is not an error. `correct_decision` and legacy `apply` in this same file use `typer.Exit(0)` for the decline path, which is the more appropriate pattern. Consider: `raise typer.Exit(0)` --- ✅ **Fixed.** Changed to `raise typer.Exit(0)`. Also added `default=False` to the `typer.confirm()` call for consistency with sibling commands (I2). The decline scenario now exits cleanly with code 0 and no spurious "Aborted." message.
# If auto_progress already moved the plan to Apply, skip the
# explicit transition and just report the current state.
if pre_plan is not None and pre_plan.phase == PlanPhase.APPLY:
if pre_plan.phase == PlanPhase.APPLY:
plan = pre_plan
else:
# Normal path: plan is in Execute/complete → transition
@@ -2092,9 +2107,20 @@ def lifecycle_apply_plan(
except PlanNotReadyError as e:
console.print(f"[red]Plan not ready:[/red] {e}")
raise typer.Abort() from e
except ValueError as e:
# Provider/config resolution errors can occur during apply when
# required model credentials are unavailable in the environment.
# Surface as a controlled CLI abort instead of an unhandled traceback.
console.print(f"[red]Execution Error:[/red] {e}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
except Exception as e:
if isinstance(e, (typer.Abort, typer.Exit)):
raise
console.print(f"[red]Unexpected error:[/red] {e}")
raise typer.Abort() from e
@app.command("status")