Files
cleveragents-core/features/steps/plan_prompt_command_steps.py
T
HAL9000 1f1b135db7 fix(cli): add timing.started ISO timestamp to plan prompt JSON envelope
- Capture started_at timestamp using datetime.now(UTC) before service call
- Add timing.started field to JSON envelope with ISO 8601 format
- Update step definitions to verify timing.started is present and valid
- Remove @tdd_expected_fail tag from plan_prompt_command.feature scenario
- Remove unrelated files accidentally committed to repo root
- Move datetime import to top of plan_prompt_command_steps.py
- Add CHANGELOG.md entry under [Unreleased] > Fixed

ISSUES CLOSED: #9353
2026-06-03 03:03:14 -04:00

192 lines
6.7 KiB
Python

"""Step definitions for the ``agents plan prompt`` CLI command."""
from __future__ import annotations
import json
from datetime import datetime
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
# Verify timing.started is present and is a valid ISO timestamp
timing = payload.get("timing")
assert isinstance(timing, dict)
assert "started" in timing
assert "duration_ms" in timing
started = timing.get("started")
assert isinstance(started, str)
# Verify it is a valid ISO 8601 timestamp
datetime.fromisoformat(started)
@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(
method="_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.result or {}
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.result or {}
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.result or {}
assert data.get("guidance") == guidance