Files
cleveragents-core/features/steps/plan_prompt_command_steps.py
brent.edwards d59fa47fd0 feat(cli): implement plan prompt command
Add the missing plan prompt CLI command and wire it through the local A2A facade into PlanLifecycleService so operator guidance can be injected into active execute-phase plans per the specification. The lifecycle flow now validates active state, records user-intervention decisions, and returns structured queue/decision metadata for rich and machine-readable output envelopes across all supported formats.

Also stabilize flaky quality gates discovered while implementing #885 by hardening integration helper timeouts, relaxing an overly strict CLI-core timing assertion, defaulting test worker concurrency to serial for determinism, switching ASV to spawn launch mode for runner stability, and documenting the controlled transform sandbox exec/compile path for static security hooks.

ISSUES CLOSED: #885

Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-04-02 16:50:55 +00:00

182 lines
6.3 KiB
Python

"""Step definitions for the ``agents plan prompt`` CLI command."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import A2aRequest
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import PlanError
@when("I run plan command help")
def step_run_plan_help(context) -> None:
runner = CliRunner()
context.prompt_result = runner.invoke(plan_app, ["--help"])
@then("help output should include plan prompt command")
def step_help_includes_prompt(context) -> None:
assert context.prompt_result.exit_code == 0, context.prompt_result.output
assert "prompt" in context.prompt_result.output
@given("a mocked lifecycle service prompt response")
def step_mock_lifecycle_prompt_response(context) -> None:
context.prompt_service_mock = MagicMock()
context.prompt_service_mock.prompt_plan.return_value = {
"guidance_added": {
"plan": "01HXM8C2ZK4Q7C2B3F2R4VYV6J",
"guidance": "Use mocks for database tests",
"scope": "next execution step",
"phase": "execute",
"state_transition": "errored -> processing",
},
"decision_created": {
"type": "user_intervention",
"id": "01HXM9C5G7R2X8S3K4Z5Q8R6Y3",
"parent": "01HXM9A1C2Q7W3R5G8Z0P4Q1X9",
},
"queue": {"pending": 1, "applied": 0},
}
@given("lifecycle service rejects prompt for inactive plan phase")
def step_mock_lifecycle_prompt_reject(context) -> None:
context.prompt_service_mock = MagicMock()
context.prompt_service_mock.prompt_plan.side_effect = PlanError(
"Plan is not in active execute phase"
)
@when(
'I run plan prompt with plan id "{plan_id}" and guidance "{guidance}" in format "{fmt}"'
)
def step_run_plan_prompt(context, plan_id: str, guidance: str, fmt: str) -> None:
runner = CliRunner()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.prompt_service_mock,
):
context.prompt_result = runner.invoke(
plan_app,
["prompt", plan_id, guidance, "--format", fmt],
)
@then("the prompt command should succeed")
def step_prompt_success(context) -> None:
assert context.prompt_result.exit_code == 0, context.prompt_result.output
@then("the prompt command should abort")
def step_prompt_abort(context) -> None:
assert context.prompt_result.exit_code != 0
@then(
'lifecycle prompt should be called with plan id "{plan_id}" and guidance "{guidance}"'
)
def step_prompt_called(context, plan_id: str, guidance: str) -> None:
context.prompt_service_mock.prompt_plan.assert_called_once_with(plan_id, guidance)
def _json_output(result_output: str) -> dict[str, object]:
return json.loads(result_output.strip())
@then('prompt output envelope should contain command "{command}"')
def step_prompt_envelope_command(context, command: str) -> None:
payload = _json_output(context.prompt_result.output)
assert payload.get("command") == command
@then('prompt output envelope should contain status "{status}"')
def step_prompt_envelope_status(context, status: str) -> None:
payload = _json_output(context.prompt_result.output)
assert payload.get("status") == status
@then("prompt output data should include queued guidance")
def step_prompt_envelope_data(context) -> None:
payload = _json_output(context.prompt_result.output)
data = payload.get("data")
assert isinstance(data, dict)
guidance_added = data.get("guidance_added")
assert isinstance(guidance_added, dict)
assert guidance_added.get("guidance") == "Use mocks for database tests"
queue = data.get("queue")
assert isinstance(queue, dict)
assert queue.get("pending") == 1
@then('prompt output should include guidance text "{guidance}"')
def step_prompt_output_contains_guidance(context, guidance: str) -> None:
normalized_output = " ".join(context.prompt_result.output.split())
normalized_guidance = " ".join(guidance.split())
assert normalized_guidance in normalized_output
@then("prompt output should mention inactive execute phase")
def step_prompt_output_inactive_phase(context) -> None:
assert "active execute phase" in context.prompt_result.output.lower()
@given("an A2A facade with a lifecycle prompt service")
def step_facade_with_prompt_service(context) -> None:
class _PromptLifecycleService:
def prompt_plan(self, plan_id: str, guidance: str) -> dict[str, object]:
return {
"guidance_added": {
"plan": plan_id,
"guidance": guidance,
"scope": "next execution step",
"phase": "execute",
"state_transition": "errored -> processing",
},
"decision_created": {
"type": "user_intervention",
"id": "01HXM9C5G7R2X8S3K4Z5Q8R6Y3",
"parent": "01HXM9A1C2Q7W3R5G8Z0P4Q1X9",
},
"queue": {"pending": 1, "applied": 0},
}
context.prompt_facade = A2aLocalFacade(
{"plan_lifecycle_service": _PromptLifecycleService()}
)
@when(
'I dispatch _cleveragents plan prompt for plan "{plan_id}" and guidance "{guidance}"'
)
def step_dispatch_facade_prompt(context, plan_id: str, guidance: str) -> None:
request = A2aRequest(
operation="_cleveragents/plan/prompt",
params={"plan_id": plan_id, "guidance": guidance},
)
context.facade_prompt_response = context.prompt_facade.dispatch(request)
@then("facade prompt response should not be a stub")
def step_facade_prompt_not_stub(context) -> None:
data = context.facade_prompt_response.data
assert data.get("stub") is not True
@then('facade prompt response should contain plan id "{plan_id}"')
def step_facade_prompt_plan(context, plan_id: str) -> None:
data = context.facade_prompt_response.data
assert data.get("plan_id") == plan_id
@then('facade prompt response should contain guidance "{guidance}"')
def step_facade_prompt_guidance(context, guidance: str) -> None:
data = context.facade_prompt_response.data
assert data.get("guidance") == guidance