Files
cleveragents-core/robot/helper_m4_e2e_cli.py
HAL9000 80bc9c552d
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 33s
CI / helm (pull_request) Successful in 45s
CI / build (pull_request) Successful in 1m4s
CI / lint (pull_request) Successful in 1m13s
CI / typecheck (pull_request) Successful in 1m31s
CI / quality (pull_request) Successful in 1m37s
CI / security (pull_request) Successful in 1m49s
CI / integration_tests (pull_request) Successful in 3m34s
CI / e2e_tests (pull_request) Successful in 5m6s
CI / unit_tests (pull_request) Successful in 6m14s
CI / docker (pull_request) Successful in 1m36s
CI / coverage (pull_request) Successful in 12m27s
CI / status-check (push) Blocked by required conditions
CI / coverage (push) Blocked by required conditions
CI / docker (push) Blocked by required conditions
CI / unit_tests (push) Has started running
CI / status-check (pull_request) Successful in 4s
CI / helm (push) Successful in 31s
CI / build (push) Successful in 53s
CI / lint (push) Successful in 1m3s
CI / quality (push) Successful in 1m13s
CI / typecheck (push) Successful in 1m46s
CI / security (push) Successful in 1m46s
CI / push-validation (push) Successful in 20s
CI / benchmark-publish (push) Failing after 43s
CI / e2e_tests (push) Successful in 4m9s
CI / integration_tests (push) Successful in 6m57s
test: restore and enhance e2e test coverage
Fix malformed imports in helper_m1_e2e_verification.py and
helper_m4_e2e_cli.py where 'from helpers_common import reset_global_state'
was incorrectly inserted inside a parenthesized import block, causing
Python syntax errors and breaking the lint and unit_tests CI gates.

Also fix import ordering in helper_m2_e2e_verification.py,
helper_m5_e2e_context.py, helper_m5_e2e_support.py, and
helper_m5_e2e_verification.py to satisfy ruff I001 import-sort rules
by placing helpers_common imports in the correct group alongside other
local robot/ helper imports.

ISSUES CLOSED: #8459
2026-05-01 00:59:02 +00:00

462 lines
17 KiB
Python

"""M4 E2E CLI integration tests (happy-path).
Tests the ``agents plan`` CLI subcommands (diff, use, execute, tree) by
invoking them via Typer's ``CliRunner`` with mocked services.
Error-path tests live in ``helper_m4_e2e_cli_errors`` to keep both
files under the 500-line CONTRIBUTING.md limit.
Usage (via dispatcher):
python robot/helper_m4_e2e_verification.py plan-diff
python robot/helper_m4_e2e_verification.py cli-plan-use
python robot/helper_m4_e2e_verification.py cli-plan-execute
python robot/helper_m4_e2e_verification.py cli-plan-tree
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure the src directory is on the import path.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.append(_SRC)
from helper_m4_e2e_common import ( # noqa: E402
_CHILD_A_ULID,
_CHILD_B_ULID,
_ROOT_ULID,
_assert_exit_code,
_assert_mock_called_once,
_assert_mock_called_once_with,
_fail,
_make_subplan_statuses,
_mock_parent_plan,
)
from helpers_common import reset_global_state # noqa: E402
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
from cleveragents.domain.models.core.decision import ( # noqa: E402
Decision,
DecisionType,
)
from cleveragents.domain.models.core.plan import ( # noqa: E402
NamespacedName,
PlanPhase,
ProcessingState,
ProjectLink,
)
_runner = CliRunner()
# Decision ULIDs with descriptive names indicating their decision type.
_DECISION_PROMPT_DEF = "01KHDE6WWS2171PWW3GJEBXZ01"
_DECISION_STRATEGY = "01KHDE6WWS2171PWW3GJEBXZ02"
_DECISION_PARALLEL_SPAWN = "01KHDE6WWS2171PWW3GJEBXZ03"
_DECISION_SPAWN_API = "01KHDE6WWS2171PWW3GJEBXZ04"
_DECISION_SPAWN_UI = "01KHDE6WWS2171PWW3GJEBXZ05"
# ---------------------------------------------------------------------------
# plan-diff
# ---------------------------------------------------------------------------
def plan_diff() -> None:
"""Verify merged results via ``agents plan diff`` (mocked service)."""
reset_global_state()
mock_apply_svc = MagicMock()
mock_apply_svc.diff.return_value = (
"--- a/src/api.py\n"
"+++ b/src/api.py\n"
"@@ -1,3 +1,5 @@\n"
" # API module\n"
"+from fastapi import FastAPI\n"
"+app = FastAPI()\n"
" # existing code\n"
"--- a/src/ui.py\n"
"+++ b/src/ui.py\n"
"@@ -1,2 +1,3 @@\n"
" # UI module\n"
"+import react\n"
)
with patch(
"cleveragents.cli.commands.plan._get_apply_service",
return_value=mock_apply_svc,
):
result = _runner.invoke(plan_app, ["diff", _ROOT_ULID])
_assert_exit_code(result, "plan diff")
# Verify diff was called with the plan_id (kwargs + positional
# fallback to avoid IndexError if called as keyword argument).
_assert_mock_called_once(mock_apply_svc.diff, "diff")
call_args = mock_apply_svc.diff.call_args
plan_id_arg = call_args.kwargs.get("plan_id")
if plan_id_arg is None and call_args.args:
plan_id_arg = call_args.args[0]
if plan_id_arg != _ROOT_ULID:
_fail(f"diff called with wrong plan_id: {plan_id_arg}")
# Verify the ``fmt`` kwarg was forwarded. The production CLI
# calls ``service.diff(plan_id, fmt=fmt)``; a regression that
# dropped the ``fmt`` argument must be detected. The default
# value is ``"rich"`` (from the CLI ``--format`` option), so
# both presence and value are asserted strictly.
fmt_arg = call_args.kwargs.get("fmt")
if fmt_arg is None and len(call_args.args) > 1:
fmt_arg = call_args.args[1]
if fmt_arg is None:
_fail(
"diff was not called with 'fmt' argument — "
"expected fmt='rich' (default)"
)
if fmt_arg != "rich":
_fail(f"diff called with unexpected fmt: {fmt_arg!r} — expected 'rich'")
# Verify diff content is rendered in CLI output (M4 criterion:
# "plan diff shows merged results").
if "api.py" not in result.output:
_fail(
f"diff output should contain 'api.py' from merged results\n"
f"output={result.output}"
)
if "ui.py" not in result.output:
_fail(
f"diff output should contain 'ui.py' from merged results\n"
f"output={result.output}"
)
print("m4-plan-diff-ok")
# ---------------------------------------------------------------------------
# cli-plan-use
# ---------------------------------------------------------------------------
def cli_plan_use() -> None:
"""Verify ``agents plan use`` CLI creates a plan with subplan config.
Mocks the lifecycle service and invokes the actual CLI command via
Typer's CliRunner. Asserts the service receives the correct action
name and project, and the CLI exits cleanly.
"""
reset_global_state()
mock_service = MagicMock()
# Mock action lookup -- use_action needs get_action_by_name first
mock_action = MagicMock()
mock_action.namespaced_name = NamespacedName.parse("local/refactor-action")
mock_service.get_action_by_name.return_value = mock_action
# Mock use_action to return a plan in Strategize phase
plan = _mock_parent_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.QUEUED,
)
mock_service.use_action.return_value = plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = _runner.invoke(
plan_app,
["use", "local/refactor-action", "local/monorepo"],
)
_assert_exit_code(result, "plan use")
# Verify the action was looked up
_assert_mock_called_once_with(
mock_service.get_action_by_name,
"get_action_by_name",
"local/refactor-action",
)
# Verify use_action was called with correct action and project
_assert_mock_called_once(mock_service.use_action, "use_action")
call_kwargs = mock_service.use_action.call_args
# action_name: kwargs with positional fallback
action_arg = call_kwargs.kwargs.get("action_name")
if action_arg is None and call_kwargs.args:
action_arg = call_kwargs.args[0]
if action_arg != "local/refactor-action":
_fail(f"use_action called with action={action_arg}")
# project_links: kwargs with positional fallback (symmetric
# with action_name to avoid asymmetric arg extraction).
project_links = call_kwargs.kwargs.get("project_links")
if project_links is None and len(call_kwargs.args) > 1:
project_links = call_kwargs.args[1]
if project_links is None:
_fail("use_action not called with project_links argument")
# Type guard: verify all items are ProjectLink instances
if not all(isinstance(pl, ProjectLink) for pl in project_links):
_fail(
f"project_links should be list of ProjectLink, "
f"got {[type(p).__name__ for p in project_links]}"
)
project_names = [pl.project_name for pl in project_links]
if "local/monorepo" not in project_names:
_fail(f"use_action project_links missing 'local/monorepo': {project_names}")
print("m4-cli-plan-use-ok")
# ---------------------------------------------------------------------------
# cli-plan-execute
# ---------------------------------------------------------------------------
def cli_plan_execute() -> None:
"""Verify ``agents plan execute`` CLI transitions plan to Execute.
Mocks the lifecycle service and invokes the actual CLI command.
Asserts the plan transitions to Execute phase and the CLI renders
the plan details.
"""
reset_global_state()
mock_service = MagicMock()
# get_plan is called for the read-only guard
pre_plan = _mock_parent_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.COMPLETE,
)
mock_service.get_plan.return_value = pre_plan
# execute_plan returns the plan in Execute phase with subplans
statuses = _make_subplan_statuses()
post_plan = _mock_parent_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.PROCESSING,
subplan_statuses=statuses,
)
mock_service.execute_plan.return_value = post_plan
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = _runner.invoke(plan_app, ["execute", _ROOT_ULID])
_assert_exit_code(result, "plan execute")
# Verify execute_plan was called with the plan ID
_assert_mock_called_once_with(
mock_service.execute_plan, "execute_plan", _ROOT_ULID
)
# Verify CLI output references the plan (rich rendering)
if _ROOT_ULID not in result.output:
_fail(
f"CLI output should contain plan ID {_ROOT_ULID}\n"
f"output={result.output}"
)
# Verify CLI output reflects the Execute phase transition.
# Use word-boundary regex to match "execute" as a distinct word,
# avoiding false positives from incidental substrings.
if not re.search(r"\bexecute\b", result.output, re.IGNORECASE):
_fail(f"CLI output should mention 'execute' phase\noutput={result.output}")
# NOTE: Domain-level post_plan assertions (phase, subplan_statuses,
# has_subplans) that were previously here have been removed. They
# verified values the test itself constructed via _mock_parent_plan,
# constituting a tautology that could never fail. The CLI does not
# render subplan_count or has_subplans in its output, so these
# properties cannot be meaningfully verified from CLI output alone.
# Domain-level coverage for these properties is provided by the
# spawn-subplans and parent-tracking domain tests.
print("m4-cli-plan-execute-ok")
# ---------------------------------------------------------------------------
# cli-plan-tree
# ---------------------------------------------------------------------------
def cli_plan_tree() -> None:
"""Verify ``agents plan tree`` CLI displays subplan hierarchy.
Creates real Decision objects forming a subplan tree:
- prompt_definition (root)
- strategy_choice
- subplan_parallel_spawn
- subplan_spawn (child A)
- subplan_spawn (child B)
Mocks the DI container and DecisionService, then invokes the
CLI command with ``--format json`` and verifies the JSON output
contains the expected decision types and hierarchy.
NOTE: This test patches ``get_container`` at its definition site
rather than ``_get_lifecycle_service`` because the production
``tree`` command resolves the ``DecisionService`` directly from
the DI container (not via the lifecycle service helper).
"""
reset_global_state()
# Build a realistic decision tree with subplan decisions
decisions = [
Decision(
decision_id=_DECISION_PROMPT_DEF,
plan_id=_ROOT_ULID,
parent_decision_id=None,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What is the plan prompt?",
chosen_option="Refactor code across monorepo modules",
confidence_score=1.0,
),
Decision(
decision_id=_DECISION_STRATEGY,
plan_id=_ROOT_ULID,
parent_decision_id=_DECISION_PROMPT_DEF,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="How to decompose the refactoring?",
chosen_option="Parallel subplans per module",
confidence_score=0.85,
),
Decision(
decision_id=_DECISION_PARALLEL_SPAWN,
plan_id=_ROOT_ULID,
parent_decision_id=_DECISION_STRATEGY,
sequence_number=2,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
question="Which subplans to run in parallel?",
chosen_option="Spawn API and UI subplans concurrently",
downstream_plan_ids=[_CHILD_A_ULID, _CHILD_B_ULID],
),
Decision(
decision_id=_DECISION_SPAWN_API,
plan_id=_ROOT_ULID,
parent_decision_id=_DECISION_PARALLEL_SPAWN,
sequence_number=3,
decision_type=DecisionType.SUBPLAN_SPAWN,
question="Spawn subplan for API module?",
chosen_option="Spawn API refactor subplan",
downstream_plan_ids=[_CHILD_A_ULID],
),
Decision(
decision_id=_DECISION_SPAWN_UI,
plan_id=_ROOT_ULID,
parent_decision_id=_DECISION_PARALLEL_SPAWN,
sequence_number=4,
decision_type=DecisionType.SUBPLAN_SPAWN,
question="Spawn subplan for UI module?",
chosen_option="Spawn UI refactor subplan",
downstream_plan_ids=[_CHILD_B_ULID],
),
]
# Mock the DI container and DecisionService.
# The production ``tree`` command calls ``container.decision_service()``
# (not ``container.resolve()``), so the mock must match that API.
mock_container = MagicMock()
mock_decision_svc = MagicMock()
mock_decision_svc.list_decisions.return_value = decisions
mock_container.decision_service.return_value = mock_decision_svc
with patch(
"cleveragents.application.container.get_container",
return_value=mock_container,
):
result = _runner.invoke(
plan_app,
["tree", _ROOT_ULID, "--format", "json"],
)
_assert_exit_code(result, "plan tree")
# Verify the decision service was queried
_assert_mock_called_once_with(
mock_decision_svc.list_decisions,
"list_decisions",
_ROOT_ULID,
)
# Parse the JSON output and verify tree structure
try:
tree_data: list[dict[str, object]] = json.loads(result.output)
except json.JSONDecodeError:
_fail(f"plan tree output is not valid JSON:\n{result.output}")
return # unreachable; _fail raises SystemExit
# Verify the tree is a list with exactly 1 root node
if not isinstance(tree_data, list):
_fail(f"tree output should be a list, got {type(tree_data).__name__}")
if len(tree_data) != 1:
_fail(f"tree should have 1 root, got {len(tree_data)}")
# Root must be prompt_definition
root = tree_data[0]
if root.get("type") != "prompt_definition":
_fail(f"root type should be prompt_definition, got {root.get('type')}")
if root.get("decision_id") != _DECISION_PROMPT_DEF:
_fail(f"root decision_id mismatch: {root.get('decision_id')}")
# Root -> strategy_choice
root_children = root.get("children", [])
if not isinstance(root_children, list) or len(root_children) != 1:
_fail(f"root should have 1 child, got {root_children!r}")
strategy_node = root_children[0]
if strategy_node.get("type") != "strategy_choice":
_fail(
f"root child should be strategy_choice, got {strategy_node.get('type')}"
)
# strategy_choice -> subplan_parallel_spawn
strategy_children = strategy_node.get("children", [])
if not isinstance(strategy_children, list) or len(strategy_children) != 1:
_fail(f"strategy_choice should have 1 child, got {strategy_children!r}")
parallel_node = strategy_children[0]
if parallel_node.get("type") != "subplan_parallel_spawn":
_fail(
f"strategy child should be subplan_parallel_spawn, "
f"got {parallel_node.get('type')}"
)
# subplan_parallel_spawn -> 2x subplan_spawn
spawn_children = parallel_node.get("children", [])
if not isinstance(spawn_children, list) or len(spawn_children) != 2:
_fail(
f"subplan_parallel_spawn should have 2 children, got {spawn_children!r}"
)
for sc in spawn_children:
if sc.get("type") != "subplan_spawn":
_fail(
f"parallel spawn child should be subplan_spawn, "
f"got {sc.get('type')}"
)
# Verify total node count: 5 decisions in the tree
def _count_nodes(nodes: list[dict[str, object]]) -> int:
total = 0
for n in nodes:
total += 1
children = n.get("children")
if isinstance(children, list):
total += _count_nodes(children)
return total
node_count = _count_nodes(tree_data)
if node_count != 5:
_fail(f"tree should have 5 nodes, got {node_count}")
print("m4-cli-plan-tree-ok")