Files
placeholder/features/steps/plan_use_env_priority_steps.py
freemo a0df5a4cd0 fix(cli): wrap format_output() in spec-required JSON/YAML envelope across all CLI commands
Implements the spec-required JSON/YAML output envelope for all CLI commands
that use format_output(). The envelope structure is:

  {
    "command": "<command that was run>",
    "status": "ok" | "warn" | "error",
    "exit_code": 0,
    "data": { ... command-specific payload ... },
    "timing": { "duration_ms": 123 },
    "messages": [{ "level": "ok", "text": "..." }]
  }

Changes:
- Add _build_envelope() helper to construct the spec-required envelope
- Add optional command, status, exit_code, messages parameters to format_output()
- Wrap json/yaml output in the envelope; plain/table/rich/color unchanged
- Add timing measurement (duration_ms) to all json/yaml outputs
- Add new BDD feature file (cli_json_envelope.feature) with 14 scenarios
  testing envelope field presence, values, and data payload
- Update 14 existing step files to unwrap the envelope when checking
  specific data keys (backward-compatible via _unwrap_envelope() helper)

Closes #3431
2026-04-05 19:48:40 +00:00

525 lines
20 KiB
Python

"""Step definitions for plan use --execution-env-priority feature."""
from __future__ import annotations
import json
from datetime import datetime
from enum import StrEnum
from typing import Any
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.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
ExecutionEnvPriority,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
def _unwrap_envelope(parsed: Any) -> Any:
"""Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
return parsed["data"]
return parsed
def _make_plan() -> Plan:
"""Create a Plan instance for env priority tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/env-priority-plan"),
description="Test plan for env priority",
definition_of_done="All tests pass",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-a")],
arguments={},
arguments_order=[],
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _make_action() -> Action:
"""Create an Action for plan use tests."""
return Action(
namespaced_name=NamespacedName.parse("local/test-action"),
description="Test action",
long_description=None,
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a plan env priority CLI runner")
def step_env_priority_runner(context: Context) -> None:
"""Set up the CLI runner."""
context.runner = CliRunner()
@given("a plan env priority mocked lifecycle service")
def step_env_priority_service(context: Context) -> None:
"""Set up a mock PlanLifecycleService."""
context.mock_service = MagicMock()
context.service_patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
context.service_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context.service_patcher.stop)
@given("a plan env priority action exists")
def step_env_priority_action(context: Context) -> None:
"""Set up an action and configure mock service for use_action."""
context.mock_action = _make_action()
context.mock_service.get_action_by_name.return_value = context.mock_action
context.mock_plan = _make_plan()
context.mock_service.use_action.return_value = context.mock_plan
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I run plan use with execution environment "{env}" and priority "{priority}"')
def step_run_use_with_env_and_priority(
context: Context, env: str, priority: str
) -> None:
"""Run plan use with both --execution-environment and --execution-env-priority."""
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"proj-a",
"--execution-environment",
env,
"--execution-env-priority",
priority,
],
)
@when('I run plan use with priority "{priority}" but no execution environment')
def step_run_use_with_priority_no_env(context: Context, priority: str) -> None:
"""Run plan use with --execution-env-priority but no --execution-environment."""
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"proj-a",
"--execution-env-priority",
priority,
],
)
@when('I run plan use with execution environment "{env}" but no priority')
def step_run_use_with_env_no_priority(context: Context, env: str) -> None:
"""Run plan use with --execution-environment but no --execution-env-priority."""
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"proj-a",
"--execution-environment",
env,
],
)
@when(
'I run plan use with execution environment "{env}" and priority "{priority}" in json format'
)
def step_run_use_with_env_priority_json(
context: Context, env: str, priority: str
) -> None:
"""Run plan use with env, priority, and JSON format."""
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"proj-a",
"--execution-environment",
env,
"--execution-env-priority",
priority,
"--format",
"json",
],
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the plan env priority command should succeed")
def step_env_priority_succeed(context: Context) -> None:
"""Assert the CLI command succeeded."""
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then("the plan env priority command should fail")
def step_env_priority_fail(context: Context) -> None:
"""Assert the CLI command failed."""
assert context.result.exit_code != 0, (
f"Expected non-zero exit code, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('the plan should have execution_env_priority set to "{value}"')
def step_env_priority_value(context: Context, value: str) -> None:
"""Assert the plan has the expected execution_env_priority."""
plan = context.mock_service.use_action.return_value
assert plan.execution_env_priority == value, (
f"Expected execution_env_priority={value!r}, "
f"got {plan.execution_env_priority!r}"
)
@then('the plan should have execution_environment set to "{value}"')
def step_env_value(context: Context, value: str) -> None:
"""Assert the plan has the expected execution_environment."""
plan = context.mock_service.use_action.return_value
assert plan.execution_environment == value, (
f"Expected execution_environment={value!r}, got {plan.execution_environment!r}"
)
@then('the plan env priority output should contain "{text}"')
def step_env_priority_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the expected text."""
assert text in context.result.output, (
f"Expected output to contain {text!r}. Output: {context.result.output}"
)
@then('the plan env priority json output should include "{key}" as "{value}"')
def step_env_priority_json_output(context: Context, key: str, value: str) -> None:
"""Assert the JSON output includes the expected key/value."""
parsed = json.loads(context.result.output)
data = _unwrap_envelope(parsed)
assert key in data, (
f"Expected JSON to contain key {key!r}. Keys: {list(data.keys())}"
)
assert data[key] == value, f"Expected {key}={value!r}, got {data[key]!r}"
@then("the plan env priority service use_action should have been called")
def step_verify_use_action_called(context: Context) -> None:
"""Verify that use_action was called on the lifecycle service."""
context.mock_service.use_action.assert_called_once()
@then("the plan env priority service save_plan should have been called")
def step_verify_save_plan_called(context: Context) -> None:
"""Verify that save_plan was called with the correct plan to persist CLI overrides."""
context.mock_service.save_plan.assert_called_once()
saved_plan = context.mock_service.save_plan.call_args[0][0]
assert saved_plan is context.mock_plan, (
"save_plan should be called with the same plan object returned by use_action"
)
# ---------------------------------------------------------------------------
# Domain model validator steps
# ---------------------------------------------------------------------------
@when(
'I construct a Plan with execution_env_priority "{priority}" '
"and no execution_environment"
)
def step_construct_plan_priority_no_env(context: Context, priority: str) -> None:
"""Try to construct a Plan with priority but no environment."""
context.construction_error = None
context.constructed_plan = None
try:
context.constructed_plan = _make_plan()
context.constructed_plan.execution_env_priority = ExecutionEnvPriority(priority)
context.constructed_plan.execution_environment = None
except (ValueError, Exception) as exc:
context.construction_error = exc
# Also try direct construction (the validator fires in __init__)
if context.construction_error is None:
try:
now = datetime.now()
Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/validator-test"),
description="Validator test",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-a")],
arguments={},
arguments_order=[],
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
execution_env_priority=ExecutionEnvPriority(priority),
execution_environment=None,
)
except (ValueError, Exception) as exc:
context.construction_error = exc
@when(
'I construct a Plan with execution_environment "{env}" '
'and execution_env_priority "{priority}"'
)
def step_construct_plan_both_fields(context: Context, env: str, priority: str) -> None:
"""Construct a Plan with both execution_environment and execution_env_priority."""
context.construction_error = None
context.constructed_plan = None
try:
now = datetime.now()
context.constructed_plan = Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/validator-test"),
description="Validator test",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-a")],
arguments={},
arguments_order=[],
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
execution_environment=env,
execution_env_priority=ExecutionEnvPriority(priority),
)
except (ValueError, Exception) as exc:
context.construction_error = exc
@then("the plan construction should raise ValueError")
def step_construction_raises_value_error(context: Context) -> None:
"""Assert that plan construction raised a ValueError."""
assert context.construction_error is not None, (
"Expected ValueError during plan construction, but no error was raised"
)
@then('the plan construction error should mention "{text}"')
def step_construction_error_message(context: Context, text: str) -> None:
"""Assert the construction error message contains expected text."""
assert context.construction_error is not None
assert text in str(context.construction_error), (
f"Expected error to mention {text!r}, got: {context.construction_error}"
)
@then("the plan construction should succeed")
def step_construction_succeeds(context: Context) -> None:
"""Assert that plan construction succeeded."""
assert context.construction_error is None, (
f"Expected plan construction to succeed, "
f"but got error: {context.construction_error}"
)
assert context.constructed_plan is not None
@then('the constructed plan should have execution_env_priority "{value}"')
def step_constructed_plan_priority(context: Context, value: str) -> None:
"""Assert the constructed plan has the expected execution_env_priority."""
assert context.constructed_plan is not None
actual = context.constructed_plan.execution_env_priority
assert actual is not None and actual.value == value, (
f"Expected execution_env_priority={value!r}, got {actual!r}"
)
@then('the constructed plan should have execution_environment "{value}"')
def step_constructed_plan_env(context: Context, value: str) -> None:
"""Assert the constructed plan has the expected execution_environment."""
assert context.constructed_plan is not None
assert context.constructed_plan.execution_environment == value, (
f"Expected execution_environment={value!r}, "
f"got {context.constructed_plan.execution_environment!r}"
)
# ---------------------------------------------------------------------------
# ExecutionEnvPriority enum steps
# ---------------------------------------------------------------------------
@then('the ExecutionEnvPriority enum should have value "{value}"')
def step_enum_has_value(context: Context, value: str) -> None:
"""Assert that ExecutionEnvPriority has the given value."""
values = [e.value for e in ExecutionEnvPriority]
assert value in values, (
f"Expected {value!r} in ExecutionEnvPriority values, got {values}"
)
@then("ExecutionEnvPriority should be a StrEnum subclass")
def step_enum_is_strenum(context: Context) -> None:
"""Assert that ExecutionEnvPriority is a StrEnum subclass."""
assert issubclass(ExecutionEnvPriority, StrEnum), (
f"ExecutionEnvPriority should be a StrEnum subclass, "
f"got bases: {ExecutionEnvPriority.__bases__}"
)
# ---------------------------------------------------------------------------
# Plan.as_cli_dict execution environment steps
# ---------------------------------------------------------------------------
@given(
'a plan with execution_environment "{env}" and execution_env_priority "{priority}"'
)
def step_plan_with_env_and_priority(context: Context, env: str, priority: str) -> None:
"""Create a plan with both execution environment fields set."""
context.env_plan = _make_plan()
context.env_plan.execution_environment = env
context.env_plan.execution_env_priority = ExecutionEnvPriority(priority)
@given("a plan with no execution_environment")
def step_plan_no_env(context: Context) -> None:
"""Create a plan with no execution environment."""
context.env_plan = _make_plan()
# execution_environment and execution_env_priority default to None
@given('a plan with execution_environment "{env}" but no execution_env_priority')
def step_plan_env_no_priority(context: Context, env: str) -> None:
"""Create a plan with execution_environment but no priority (pre-migration).
Simulates pre-migration data where execution_environment was set but
execution_env_priority was not persisted. Uses ``__dict__`` update
to bypass the model validator that normally pairs the two fields.
"""
context.env_plan = _make_plan()
context.env_plan.__dict__["execution_environment"] = env
context.env_plan.__dict__["execution_env_priority"] = None
@when("I call as_cli_dict on the env priority plan")
def step_call_as_cli_dict_env(context: Context) -> None:
"""Call as_cli_dict() on the env priority plan and store the result."""
context.env_cli_dict = context.env_plan.as_cli_dict()
@then('the env priority CLI dict should contain key "{key}" with value "{value}"')
def step_env_cli_dict_has_key_value(context: Context, key: str, value: str) -> None:
"""Assert the CLI dict contains the specified key with expected value."""
assert key in context.env_cli_dict, (
f"Expected key '{key}' in CLI dict, "
f"got keys: {list(context.env_cli_dict.keys())}"
)
assert str(context.env_cli_dict[key]) == value, (
f"Expected CLI dict['{key}'] == '{value}', got '{context.env_cli_dict[key]}'"
)
@then('the env priority CLI dict should not contain key "{key}"')
def step_env_cli_dict_missing_key(context: Context, key: str) -> None:
"""Assert the CLI dict does not contain the specified key."""
assert key not in context.env_cli_dict, (
f"Key '{key}' should not be in CLI dict but it is: {context.env_cli_dict[key]}"
)
# ---------------------------------------------------------------------------
# DB round-trip serialization steps
# ---------------------------------------------------------------------------
@when("I serialize the plan through LifecyclePlanModel from_domain and to_domain")
def step_round_trip_serialization(context: Context) -> None:
"""Perform a from_domain -> to_domain round-trip on the env plan."""
from cleveragents.infrastructure.database.models import LifecyclePlanModel
db_model = LifecyclePlanModel.from_domain(context.env_plan)
context.round_trip_plan = db_model.to_domain()
@then('the round-trip plan should have execution_environment "{value}"')
def step_round_trip_env(context: Context, value: str) -> None:
"""Assert the round-trip plan has the expected execution_environment."""
assert context.round_trip_plan.execution_environment == value, (
f"Expected execution_environment={value!r}, "
f"got {context.round_trip_plan.execution_environment!r}"
)
@then("the round-trip plan should have execution_environment None")
def step_round_trip_env_none(context: Context) -> None:
"""Assert the round-trip plan has execution_environment=None."""
assert context.round_trip_plan.execution_environment is None, (
f"Expected execution_environment=None, "
f"got {context.round_trip_plan.execution_environment!r}"
)
@then('the round-trip plan should have execution_env_priority "{value}"')
def step_round_trip_priority(context: Context, value: str) -> None:
"""Assert the round-trip plan has the expected execution_env_priority."""
actual = context.round_trip_plan.execution_env_priority
assert actual is not None and actual.value == value, (
f"Expected execution_env_priority={value!r}, got {actual!r}"
)
@then("the round-trip plan should have execution_env_priority None")
def step_round_trip_priority_none(context: Context) -> None:
"""Assert the round-trip plan has execution_env_priority=None."""
assert context.round_trip_plan.execution_env_priority is None, (
f"Expected execution_env_priority=None, "
f"got {context.round_trip_plan.execution_env_priority!r}"
)