feat(cli): add plan explain and decision tree outputs #464

Merged
hamza.khyari merged 4 commits from feature/m4-decision-cli into master 2026-03-03 12:16:04 +00:00
10 changed files with 2278 additions and 7 deletions
+3
View File
@@ -26,6 +26,9 @@
`context.get` returns a stub pending ACMS pipeline. Added domain-to-ACP error code mapping
(`NOT_FOUND`, `VALIDATION_ERROR`, `INVALID_STATE`, `PLAN_ERROR`, etc.) via `map_domain_error()`.
(#501)
- Added `plan explain` and `plan tree` CLI commands for decision tree inspection
with json/yaml/table/rich output formats, and flags for superseded decisions,
context snapshots, and reasoning details. (#174)
- Replaced behave-parallel subprocess-per-feature execution model (342 Python interpreter
startups) with in-process execution via behave's `Runner` API. Sequential mode runs all
features in a single `Runner.run()` call; parallel mode uses `multiprocessing.Pool` with
+131
View File
@@ -0,0 +1,131 @@
"""ASV benchmarks for plan explain and plan tree operations.
Measures the performance of:
- Explain dict building with various flag combinations
- Decision tree construction and filtering
- Tree table rendering
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.cli.commands.plan import (
_build_explain_dict,
build_decision_tree,
)
from cleveragents.cli.formatting import format_output
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.cli.commands.plan import (
_build_explain_dict,
build_decision_tree,
)
from cleveragents.cli.formatting import format_output
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from ulid import ULID
_PLAN_ID = str(ULID())
def _make_decision(
seq: int,
parent_id: str | None = None,
decision_id: str | None = None,
dt: DecisionType = DecisionType.STRATEGY_CHOICE,
superseded_by: str | None = None,
) -> Decision:
"""Build a Decision for benchmarking."""
if dt == DecisionType.PROMPT_DEFINITION:
parent_id = None
elif parent_id is None:
parent_id = str(ULID())
return Decision(
decision_id=decision_id or str(ULID()),
plan_id=_PLAN_ID,
parent_decision_id=parent_id,
sequence_number=seq,
decision_type=dt,
question=f"Question {seq}?",
chosen_option=f"Option {seq}",
confidence_score=0.8,
superseded_by=superseded_by,
context_snapshot=ContextSnapshot(
hot_context_hash=f"sha256:bench{seq}",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path=f"src/f{seq}.py"),
],
),
rationale=f"Rationale for decision {seq}",
actor_reasoning=f"LLM reasoning for {seq}",
alternatives_considered=[f"alt-{seq}-a", f"alt-{seq}-b"],
)
class PlanExplainSuite:
"""Benchmark plan explain formatting."""
def setup(self) -> None:
self.decision = _make_decision(0, dt=DecisionType.PROMPT_DEFINITION)
def time_explain_formatting(self) -> None:
"""Benchmark basic explain dict building."""
_build_explain_dict(self.decision)
def time_explain_with_context(self) -> None:
"""Benchmark explain dict building with all flags."""
_build_explain_dict(
self.decision,
show_context=True,
show_reasoning=True,
show_alternatives=True,
)
class PlanTreeSuite:
"""Benchmark plan tree operations."""
def setup(self) -> None:
root_id = str(ULID())
self.root_id = root_id
self.decisions: list[Decision] = [
_make_decision(0, dt=DecisionType.PROMPT_DEFINITION, decision_id=root_id),
]
# Add 20 children
child_ids: list[str] = []
for i in range(1, 21):
cid = str(ULID())
child_ids.append(cid)
superseded = str(ULID()) if i % 5 == 0 else None
self.decisions.append(
_make_decision(
i, parent_id=root_id, decision_id=cid, superseded_by=superseded
)
)
def time_tree_build(self) -> None:
"""Benchmark tree construction."""
build_decision_tree(self.decisions)
def time_tree_render_table(self) -> None:
"""Benchmark tree table rendering."""
tree = build_decision_tree(self.decisions)
format_output(tree, "table")
def time_tree_filter_superseded(self) -> None:
"""Benchmark tree with superseded filtering."""
build_decision_tree(self.decisions, show_superseded=False)
+76
View File
@@ -14,6 +14,8 @@ The `agents plan` command group manages plans in the CleverAgents v3 plan lifecy
| `agents plan cancel` | Cancel a non-terminal plan |
| `agents plan diff` | Show ChangeSet as unified diff |
| `agents plan artifacts` | Show ChangeSet ID, sandbox refs, summary|
| `agents plan explain` | Explain a single decision |
| `agents plan tree` | Display decision tree for a plan |
## `agents plan use`
@@ -158,3 +160,77 @@ Show plan artifacts including ChangeSet ID and sandbox references.
```bash
agents plan artifacts PLAN_ID [--format FORMAT]
```
## `agents plan explain`
Explain a single decision in the plan decision tree.
### Synopsis
```bash
agents plan explain <DECISION_ID> [OPTIONS]
```
### Options
| Flag | Description |
|-------------------------|--------------------------------------------------|
| `--format`, `-f` | Output format: json, yaml, plain, table, rich |
| `--show-context` | Include context snapshot details |
| `--show-reasoning` | Include rationale and actor reasoning |
Alternatives considered are always included in the output.
### Examples
```bash
# Default rich output
agents plan explain 01HXYZ1234567890ABCDEFGH
# JSON with full context
agents plan explain 01HXYZ1234567890ABCDEFGH --format json --show-context
# Show reasoning
agents plan explain 01HXYZ1234567890ABCDEFGH --show-reasoning
# YAML output with all details
agents plan explain 01HXYZ1234567890ABCDEFGH --format yaml \
--show-context --show-reasoning
```
## `agents plan tree`
Display the decision tree for a plan.
### Synopsis
```bash
agents plan tree <PLAN_ID> [OPTIONS]
```
### Options
| Flag | Description |
|-------------------------|--------------------------------------------------|
| `--format`, `-f` | Output format: json, yaml, plain, table, rich |
| `--show-superseded` | Include superseded decisions in the tree |
| `--depth` | Maximum tree depth (0 = unlimited, default: 0) |
### Examples
```bash
# Default rich tree view
agents plan tree 01HXYZ1234567890ABCDEFGH
# Table format
agents plan tree 01HXYZ1234567890ABCDEFGH --format table
# Include superseded decisions
agents plan tree 01HXYZ1234567890ABCDEFGH --show-superseded
# Limit depth to 2 levels
agents plan tree 01HXYZ1234567890ABCDEFGH --depth 2
# JSON output for scripting
agents plan tree 01HXYZ1234567890ABCDEFGH --format json
```
+141
View File
@@ -0,0 +1,141 @@
Feature: Plan explain and decision tree CLI commands
Validates the plan explain and plan tree commands including
formatting, filtering, and flag-controlled detail views.
# ------------------------------------------------------------------
# plan explain - default format
# ------------------------------------------------------------------
Scenario: Explain a decision with default format
Given a test decision for explain
When I build the explain dict with default options
Then the explain dict should contain key "decision_id"
And the explain dict should contain key "question"
And the explain dict should contain key "chosen"
And the explain dict should contain key "type"
And the explain dict should contain key "alternatives_considered"
And the explain dict should not contain key "rationale"
And the explain dict should not contain key "context_snapshot"
# ------------------------------------------------------------------
# plan explain - show-context
# ------------------------------------------------------------------
Scenario: Explain with show-context flag
Given a test decision with context snapshot for explain
When I build the explain dict with show-context enabled
Then the explain dict should contain key "context_snapshot"
And the context snapshot should contain key "hot_context_hash"
And the context snapshot should contain key "relevant_resources"
# ------------------------------------------------------------------
# plan explain - show-reasoning
# ------------------------------------------------------------------
Scenario: Explain with show-reasoning flag
Given a test decision with reasoning for explain
When I build the explain dict with show-reasoning enabled
Then the explain dict should contain key "rationale"
And the explain dict should contain key "actor_reasoning"
# ------------------------------------------------------------------
# plan explain - alternatives always included
# ------------------------------------------------------------------
Scenario: Explain includes alternatives by default
Given a test decision with alternatives for explain
When I build the explain dict with default options
Then the explain dict should contain key "alternatives_considered"
And the alternatives list should have 2 items
# ------------------------------------------------------------------
# plan explain - json format
# ------------------------------------------------------------------
Scenario: Explain with json format
Given a test decision for explain
When I format the explain dict as json
Then the json output should contain "decision_id"
And the json output should be valid json
# ------------------------------------------------------------------
# plan explain - yaml format
# ------------------------------------------------------------------
Scenario: Explain with yaml format
Given a test decision for explain
When I format the explain dict as yaml
Then the yaml output should contain "decision_id"
# ------------------------------------------------------------------
# plan explain - non-existent decision
# ------------------------------------------------------------------
Scenario: Explain for non-existent decision returns error marker
Given a non-existent decision id
Then the explain lookup should indicate not found
# ------------------------------------------------------------------
# plan tree - default format
# ------------------------------------------------------------------
Scenario: Tree with default format builds tree structure
Given a set of test decisions forming a tree
When I build the decision tree with default options
Then the tree should have at least 1 root node
And the first root node should have children
# ------------------------------------------------------------------
# plan tree - show-superseded
# ------------------------------------------------------------------
Scenario: Tree with show-superseded includes superseded decisions
Given a set of test decisions with superseded entries
When I build the decision tree with show-superseded enabled
Then the tree should include superseded decision nodes
# ------------------------------------------------------------------
# plan tree - depth limit
# ------------------------------------------------------------------
Scenario: Tree with depth limit restricts tree depth
Given a set of test decisions forming a deep tree
When I build the decision tree with depth 1
Then the root nodes should have no children
# ------------------------------------------------------------------
# plan tree - json format
# ------------------------------------------------------------------
Scenario: Tree with json format
Given a set of test decisions forming a tree
When I format the tree as json
Then the json tree output should be valid json
And the json tree output should contain "decision_id"
# ------------------------------------------------------------------
# plan tree - yaml format
# ------------------------------------------------------------------
Scenario: Tree with yaml format
Given a set of test decisions forming a tree
When I format the tree as yaml
Then the yaml tree output should contain "decision_id"
# ------------------------------------------------------------------
# plan tree - no decisions
# ------------------------------------------------------------------
Scenario: Tree for plan with no decisions
Given an empty list of decisions
When I build the decision tree with default options from empty list
Then the tree should be empty
# ------------------------------------------------------------------
# plan tree - filters superseded by default
# ------------------------------------------------------------------
Scenario: Tree filters out superseded decisions by default
Given a set of test decisions with superseded entries
When I build the decision tree with default options
Then the tree should not include superseded decision nodes
+231
View File
@@ -0,0 +1,231 @@
Feature: Plan explain and tree CLI command coverage
Exercises the CLI-level code paths in plan explain, plan tree,
plan correct, plan resume, plan revert, and the read-only guards
in plan execute / plan apply that are not covered by the existing
unit-level plan_explain.feature.
# ------------------------------------------------------------------
# explain_decision_cmd - CLI invocation with rich format
# ------------------------------------------------------------------
Scenario: Explain CLI renders rich table for a known decision
Given pec a mock DecisionService returning a valid decision
When pec I invoke "explain" with the decision id
Then pec the exit code should be 0
And pec the output should contain "Decision Details"
And pec the output should contain "decision_id"
Scenario: Explain CLI renders json format
Given pec a mock DecisionService returning a valid decision
When pec I invoke "explain" with format "json"
Then pec the exit code should be 0
And pec the output should be valid json
Scenario: Explain CLI renders yaml format
Given pec a mock DecisionService returning a valid decision
When pec I invoke "explain" with format "yaml"
Then pec the exit code should be 0
And pec the output should contain "decision_id:"
Scenario: Explain CLI returns error for unknown decision
Given pec a mock DecisionService returning None for get_decision
When pec I invoke "explain" with the decision id
Then pec the exit code should be 1
And pec the output should contain "not found"
Scenario: Explain CLI with show-context flag
Given pec a mock DecisionService returning a decision with context snapshot
When pec I invoke "explain" with flags "--show-context"
Then pec the exit code should be 0
And pec the output should contain "context_snapshot"
Scenario: Explain CLI with show-reasoning flag
Given pec a mock DecisionService returning a decision with reasoning
When pec I invoke "explain" with flags "--show-reasoning"
Then pec the exit code should be 0
And pec the output should contain "rationale"
Scenario: Explain CLI includes alternatives by default
Given pec a mock DecisionService returning a decision with alternatives
When pec I invoke "explain" with the decision id
Then pec the exit code should be 0
And pec the output should contain "alternatives_considered"
# ------------------------------------------------------------------
# tree_decisions_cmd - rich tree format
# ------------------------------------------------------------------
Scenario: Tree CLI renders rich tree for a plan with decisions
Given pec a mock DecisionService returning a list of decisions
When pec I invoke "tree" with a plan id
Then pec the exit code should be 0
And pec the output should contain "Plan"
Scenario: Tree CLI renders json format
Given pec a mock DecisionService returning a list of decisions
When pec I invoke "tree" with format "json"
Then pec the exit code should be 0
And pec the output should be valid json list
Scenario: Tree CLI renders yaml format
Given pec a mock DecisionService returning a list of decisions
When pec I invoke "tree" with format "yaml"
Then pec the exit code should be 0
And pec the output should contain "decision_id:"
Scenario: Tree CLI renders table format
Given pec a mock DecisionService returning a list of decisions
When pec I invoke "tree" with format "table"
Then pec the exit code should be 0
And pec the output should contain "Decision Tree"
Scenario: Tree CLI table format with depth limit
Given pec a mock DecisionService returning a deep decision list
When pec I invoke "tree" with format "table" and depth 1
Then pec the exit code should be 0
And pec the output should contain "Decision Tree"
Scenario: Tree CLI rich format with depth limit
Given pec a mock DecisionService returning a deep decision list
When pec I invoke "tree" with depth 1
Then pec the exit code should be 0
And pec the output should contain "Plan"
Scenario: Tree CLI with show-superseded flag
Given pec a mock DecisionService returning decisions with superseded
When pec I invoke "tree" with flags "--show-superseded"
Then pec the exit code should be 0
And pec the output should contain "Which framework?"
Scenario: Tree CLI for plan with no decisions
Given pec a mock DecisionService returning an empty list
When pec I invoke "tree" with a plan id
Then pec the exit code should be 0
And pec the output should contain "No decisions found"
# ------------------------------------------------------------------
# build_decision_tree edge case: child_id not in by_id
# ------------------------------------------------------------------
Scenario: Tree builder skips orphan child references gracefully
Given pec a decision list where a child references a missing parent
When pec I build the tree from orphan decisions
Then pec the tree should exclude the superseded grandchild
# ------------------------------------------------------------------
# _resolve_active_plan_id
# ------------------------------------------------------------------
Scenario: Resolve active plan id returns first non-terminal plan
Given pec a lifecycle service with one active plan
When pec I call resolve active plan id
Then pec the resolved plan id should match the active plan
# ------------------------------------------------------------------
# revert_plan error handlers
# ------------------------------------------------------------------
Scenario: Revert plan with InvalidPhaseTransitionError
Given pec a lifecycle service that raises InvalidPhaseTransitionError on revert
When pec I invoke "revert" with plan id and target "strategize"
Then pec the exit code should be nonzero
And pec the output should contain "Invalid reversion"
Scenario: Revert plan with PlanError
Given pec a lifecycle service that raises PlanError on revert
When pec I invoke "revert" with plan id and target "strategize"
Then pec the exit code should be nonzero
And pec the output should contain "Cannot revert"
# ------------------------------------------------------------------
# correct_decision - rich format dry-run
# ------------------------------------------------------------------
Scenario: Correct decision dry-run with rich format shows impact panel
Given pec a mock CorrectionService with dry-run impact
When pec I invoke "correct" in dry-run mode with rich format
Then pec the exit code should be 0
And pec the output should contain "Correction Impact"
Scenario: Correct decision execute with yes flag shows success
Given pec a mock CorrectionService with execute result
When pec I invoke "correct" with yes flag
Then pec the exit code should be 0
And pec the output should contain "Correction applied"
Scenario: Correct decision execute with reverted and new decisions
Given pec a mock CorrectionService with reverted and new decisions
When pec I invoke "correct" with yes flag
Then pec the exit code should be 0
And pec the output should contain "Reverted"
And pec the output should contain "New decisions"
Scenario: Correct decision with confirmation prompt cancelled
Given pec a mock CorrectionService with execute result
When pec I invoke "correct" without yes and decline
Then pec the exit code should be 0
And pec the output should contain "Cancelled"
Scenario: Correct decision with ResourceNotFoundError
Given pec a mock CorrectionService that raises ResourceNotFoundError
When pec I invoke "correct" with yes flag
Then pec the exit code should be nonzero
And pec the output should contain "Not found"
And pec the output should not contain "Validation Error"
Scenario: Correct decision with ValidationError
Given pec a mock CorrectionService that raises ValidationError
When pec I invoke "correct" with yes flag
Then pec the exit code should be nonzero
And pec the output should contain "Validation Error"
And pec the output should not contain "Not found"
Scenario: Correct decision with CleverAgentsError
Given pec a mock CorrectionService that raises CleverAgentsError
When pec I invoke "correct" with yes flag
Then pec the exit code should be nonzero
And pec the output should contain "Error"
# ------------------------------------------------------------------
# resume_plan_cmd
# ------------------------------------------------------------------
Scenario: Resume plan with rich format shows panel
Given pec a mock PlanResumeService returning a summary
When pec I invoke "resume" with a plan id
Then pec the exit code should be 0
And pec the output should contain "Resume Summary"
Scenario: Resume plan with dry-run shows dry-run label
Given pec a mock PlanResumeService returning a summary
When pec I invoke "resume" with dry-run flag
Then pec the exit code should be 0
And pec the output should contain "dry-run"
Scenario: Resume plan with json format
Given pec a mock PlanResumeService returning a summary
When pec I invoke "resume" with format "json"
Then pec the exit code should be 0
And pec the output should be valid json
Scenario: Resume plan with PlanError
Given pec a mock PlanResumeService that raises PlanError
When pec I invoke "resume" with a plan id
Then pec the exit code should be nonzero
And pec the output should contain "Cannot resume"
# ------------------------------------------------------------------
# Read-only plan guards in execute and apply
# ------------------------------------------------------------------
Scenario: Execute plan aborts for read-only plan
Given pec a lifecycle service returning a read-only plan
When pec I invoke "execute" with the read-only plan id
Then pec the exit code should be nonzero
And pec the output should contain "read-only"
Scenario: Apply plan aborts for read-only plan
Given pec a lifecycle service returning a read-only plan
When pec I invoke "apply" with the read-only plan id
Then pec the exit code should be nonzero
And pec the output should contain "read-only"
@@ -0,0 +1,810 @@
"""Step definitions for plan_explain_cli_coverage.feature.
Exercises the CLI-level code paths for plan explain, plan tree,
plan correct, plan resume, plan revert, and the read-only plan guards,
using the Typer CliRunner with mocked services.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
)
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import (
CleverAgentsError,
PlanError,
ValidationError,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
runner = CliRunner()
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
_PATCH_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
_PATCH_RESUME_SVC_MOD = (
"cleveragents.application.services.plan_resume_service.PlanResumeService"
Outdated
Review

MEDIUM — Dead code. _PATCH_CORRECTION_SVC and _PATCH_RESUME_SVC_MOD are defined here but never referenced anywhere in this file. The actual patch targets are hardcoded inline in _invoke_correct (line 620) and the resume when steps (lines 665, 677, 691). Either use these constants or remove them.

**MEDIUM — Dead code.** `_PATCH_CORRECTION_SVC` and `_PATCH_RESUME_SVC_MOD` are defined here but never referenced anywhere in this file. The actual patch targets are hardcoded inline in `_invoke_correct` (line 620) and the resume `when` steps (lines 665, 677, 691). Either use these constants or remove them.
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_decision(
decision_id: str | None = None,
plan_id: str | None = None,
sequence: int = 0,
parent_id: str | None = None,
dtype: DecisionType = DecisionType.PROMPT_DEFINITION,
question: str = "What should we build?",
chosen: str = "A REST API",
superseded_by: str | None = None,
is_correction: bool = False,
corrects_decision_id: str | None = None,
correction_reason: str | None = None,
confidence_score: float | None = 0.85,
rationale: str = "",
actor_reasoning: str | None = None,
alternatives: list[str] | None = None,
context_snapshot: ContextSnapshot | None = None,
) -> Decision:
did = decision_id or str(ULID())
pid = plan_id or str(ULID())
kwargs: dict = {
"decision_id": did,
"plan_id": pid,
"sequence_number": sequence,
"decision_type": dtype,
"question": question,
"chosen_option": chosen,
"confidence_score": confidence_score,
"rationale": rationale,
"superseded_by": superseded_by,
"is_correction": is_correction,
}
if parent_id is not None:
kwargs["parent_decision_id"] = parent_id
if corrects_decision_id is not None:
kwargs["corrects_decision_id"] = corrects_decision_id
if correction_reason is not None:
kwargs["correction_reason"] = correction_reason
if actor_reasoning is not None:
kwargs["actor_reasoning"] = actor_reasoning
if alternatives is not None:
kwargs["alternatives_considered"] = alternatives
if context_snapshot is not None:
kwargs["context_snapshot"] = context_snapshot
return Decision(**kwargs)
def _mock_container_with_decision_svc(svc_mock: MagicMock) -> MagicMock:
container = MagicMock()
container.resolve.return_value = svc_mock
return container
def _make_tree_decisions() -> list[Decision]:
root_id = str(ULID())
child_id = str(ULID())
return [
_make_decision(decision_id=root_id, sequence=0),
_make_decision(
decision_id=child_id,
parent_id=root_id,
sequence=1,
dtype=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen="FastAPI",
),
]
def _make_deep_decisions() -> list[Decision]:
root_id = str(ULID())
child_id = str(ULID())
grandchild_id = str(ULID())
return [
_make_decision(decision_id=root_id, sequence=0),
_make_decision(
decision_id=child_id,
parent_id=root_id,
sequence=1,
dtype=DecisionType.STRATEGY_CHOICE,
question="Child question?",
chosen="Child answer",
),
_make_decision(
decision_id=grandchild_id,
parent_id=child_id,
sequence=2,
dtype=DecisionType.IMPLEMENTATION_CHOICE,
question="Grandchild question?",
chosen="Grandchild answer",
),
]
# ---------------------------------------------------------------------------
# GIVEN steps - explain
# ---------------------------------------------------------------------------
@given("pec a mock DecisionService returning a valid decision")
def step_pec_mock_decision_svc(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_decision = _make_decision(decision_id=context.pec_decision_id)
svc = MagicMock()
svc.get_decision.return_value = context.pec_decision
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning None for get_decision")
def step_pec_mock_decision_none(context: Context) -> None:
context.pec_decision_id = str(ULID())
svc = MagicMock()
svc.get_decision.return_value = None
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning a decision with context snapshot")
def step_pec_mock_decision_ctx(context: Context) -> None:
context.pec_decision_id = str(ULID())
snap = ContextSnapshot(
hot_context_hash="sha256:abc123",
hot_context_ref="store://ctx/1",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
],
actor_state_ref="checkpoint://state/1",
)
context.pec_decision = _make_decision(
decision_id=context.pec_decision_id,
context_snapshot=snap,
)
svc = MagicMock()
svc.get_decision.return_value = context.pec_decision
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning a decision with reasoning")
def step_pec_mock_decision_reasoning(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_decision = _make_decision(
decision_id=context.pec_decision_id,
rationale="Chose REST API for simplicity",
actor_reasoning="The LLM considered approaches...",
)
svc = MagicMock()
svc.get_decision.return_value = context.pec_decision
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning a decision with alternatives")
def step_pec_mock_decision_alts(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_decision = _make_decision(
decision_id=context.pec_decision_id,
alternatives=["GraphQL API", "gRPC service"],
)
svc = MagicMock()
svc.get_decision.return_value = context.pec_decision
context.pec_container = _mock_container_with_decision_svc(svc)
# ---------------------------------------------------------------------------
# GIVEN steps - tree
# ---------------------------------------------------------------------------
@given("pec a mock DecisionService returning a list of decisions")
def step_pec_mock_tree_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
decisions = _make_tree_decisions()
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning a deep decision list")
def step_pec_mock_deep_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
decisions = _make_deep_decisions()
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning decisions with superseded")
def step_pec_mock_superseded_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
root_id = str(ULID())
old_id = str(ULID())
new_id = str(ULID())
decisions = [
_make_decision(decision_id=root_id, sequence=0),
Outdated
Review

HIGH — Sham test. This step provides only a root decision with no children, so children_map.get(did, []) returns [] and the orphan guard at plan.py:2726 (if child_id not in by_id: continue) is never reached. The comment in the code acknowledges this explicitly.

To actually cover the orphan branch, construct a scenario where children_map contains a child_id that is absent from by_id. For example, include a superseded child decision (so it gets filtered out of by_id when show_superseded=False) while the parent still references it in children_map.

**HIGH — Sham test.** This step provides only a root decision with no children, so `children_map.get(did, [])` returns `[]` and the orphan guard at `plan.py:2726` (`if child_id not in by_id: continue`) is never reached. The comment in the code acknowledges this explicitly. To actually cover the orphan branch, construct a scenario where `children_map` contains a `child_id` that is absent from `by_id`. For example, include a superseded child decision (so it gets filtered out of `by_id` when `show_superseded=False`) while the parent still references it in `children_map`.
_make_decision(
decision_id=old_id,
parent_id=root_id,
sequence=1,
dtype=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen="Flask",
superseded_by=new_id,
),
_make_decision(
decision_id=new_id,
parent_id=root_id,
sequence=2,
dtype=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen="FastAPI",
is_correction=True,
corrects_decision_id=old_id,
correction_reason="Better performance",
),
]
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@given("pec a mock DecisionService returning an empty list")
def step_pec_mock_empty_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.get_decisions_for_plan.return_value = []
context.pec_container = _mock_container_with_decision_svc(svc)
# ---------------------------------------------------------------------------
# GIVEN steps - orphan edge case
# ---------------------------------------------------------------------------
@given("pec a decision list where a child references a missing parent")
def step_pec_orphan_decisions(context: Context) -> None:
"""Build three decisions where the grandchild is superseded.
With ``show_superseded=False`` the grandchild is excluded from
``by_id`` but its entry in ``children_map`` (built from ALL
decisions) still references its ``decision_id`` under its parent.
This forces the BFS orphan guard (``child_id not in by_id``) to
fire and skip the missing node.
"""
root_id = str(ULID())
child_id = str(ULID())
grandchild_id = str(ULID())
context.pec_orphan_root_id = root_id
context.pec_orphan_decisions = [
_make_decision(decision_id=root_id, sequence=0),
_make_decision(
decision_id=child_id,
parent_id=root_id,
sequence=1,
dtype=DecisionType.STRATEGY_CHOICE,
question="Child question?",
chosen="Child answer",
),
_make_decision(
decision_id=grandchild_id,
parent_id=child_id,
sequence=2,
dtype=DecisionType.IMPLEMENTATION_CHOICE,
question="Grandchild question?",
chosen="Grandchild answer",
superseded_by=str(ULID()),
),
]
# ---------------------------------------------------------------------------
# GIVEN steps - resolve active plan
# ---------------------------------------------------------------------------
@given("pec a lifecycle service with one active plan")
def step_pec_lifecycle_active(context: Context) -> None:
context.pec_expected_plan_id = str(ULID())
plan = MagicMock()
plan.is_terminal = False
plan.identity.plan_id = context.pec_expected_plan_id
svc = MagicMock()
svc.list_plans.return_value = [plan]
context.pec_lifecycle_svc = svc
# ---------------------------------------------------------------------------
# GIVEN steps - revert error handlers
# ---------------------------------------------------------------------------
@given("pec a lifecycle service that raises InvalidPhaseTransitionError on revert")
def step_pec_revert_invalid_phase(context: Context) -> None:
from cleveragents.domain.models.core.plan import PlanPhase
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.revert_plan.side_effect = InvalidPhaseTransitionError(
from_phase=PlanPhase.APPLY,
to_phase=PlanPhase.STRATEGIZE,
message="Cannot revert from apply phase",
)
context.pec_lifecycle_svc = svc
@given("pec a lifecycle service that raises PlanError on revert")
def step_pec_revert_plan_error(context: Context) -> None:
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.revert_plan.side_effect = PlanError("Plan is terminal")
context.pec_lifecycle_svc = svc
# ---------------------------------------------------------------------------
# GIVEN steps - correct
# ---------------------------------------------------------------------------
@given("pec a mock CorrectionService with dry-run impact")
def step_pec_correct_dry_run(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
request = MagicMock()
request.correction_id = str(ULID())
request.mode.value = "revert"
request.target_decision_id = context.pec_decision_id
request.guidance = "Use FastAPI instead"
impact = MagicMock()
impact.affected_decisions = ["DEC-A", "DEC-B"]
impact.affected_files = ["src/api.py"]
impact.estimated_cost = "low"
impact.risk_level = "medium"
svc = MagicMock()
svc.request_correction.return_value = request
svc.analyze_impact.return_value = impact
context.pec_correction_svc = svc
@given("pec a mock CorrectionService with execute result")
def step_pec_correct_execute(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
request = MagicMock()
request.correction_id = str(ULID())
request.mode.value = "revert"
request.target_decision_id = context.pec_decision_id
request.guidance = "Use FastAPI instead"
result = MagicMock()
result.correction_id = request.correction_id
result.status.value = "applied"
result.new_decisions = []
result.reverted_decisions = []
svc = MagicMock()
svc.request_correction.return_value = request
svc.execute_correction.return_value = result
context.pec_correction_svc = svc
@given("pec a mock CorrectionService with reverted and new decisions")
def step_pec_correct_with_changes(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
request = MagicMock()
request.correction_id = str(ULID())
request.mode.value = "revert"
request.target_decision_id = context.pec_decision_id
request.guidance = "Use FastAPI instead"
result = MagicMock()
result.correction_id = request.correction_id
result.status.value = "applied"
result.new_decisions = ["DEC-NEW-1"]
result.reverted_decisions = ["DEC-OLD-1"]
svc = MagicMock()
svc.request_correction.return_value = request
svc.execute_correction.return_value = result
context.pec_correction_svc = svc
@given("pec a mock CorrectionService that raises ResourceNotFoundError")
def step_pec_correct_rnf(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
from cleveragents.core.exceptions import ResourceNotFoundError
svc = MagicMock()
svc.request_correction.side_effect = ResourceNotFoundError("Decision not found")
context.pec_correction_svc = svc
@given("pec a mock CorrectionService that raises ValidationError")
def step_pec_correct_validation(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.request_correction.side_effect = ValidationError("Invalid correction mode")
context.pec_correction_svc = svc
@given("pec a mock CorrectionService that raises CleverAgentsError")
def step_pec_correct_ca_error(context: Context) -> None:
context.pec_decision_id = str(ULID())
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.request_correction.side_effect = CleverAgentsError("Service unavailable")
context.pec_correction_svc = svc
# ---------------------------------------------------------------------------
# GIVEN steps - resume
# ---------------------------------------------------------------------------
@given("pec a mock PlanResumeService returning a summary")
def step_pec_resume_svc(context: Context) -> None:
context.pec_plan_id = str(ULID())
summary = MagicMock()
summary.plan_id = context.pec_plan_id
summary.phase = "execute"
summary.processing_state = "in_progress"
summary.last_completed_step = 3
summary.next_step_index = 4
summary.total_steps = 10
summary.decision_id = str(ULID())
summary.last_checkpoint_id = str(ULID())
summary.sandbox_ref = "/tmp/sandbox/plan-001"
summary.as_cli_dict.return_value = {
"plan_id": summary.plan_id,
"phase": "execute",
"next_step": 4,
}
context.pec_resume_summary = summary
svc = MagicMock()
svc.resume_plan.return_value = summary
context.pec_resume_svc = svc
@given("pec a mock PlanResumeService that raises PlanError")
def step_pec_resume_error(context: Context) -> None:
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.resume_plan.side_effect = PlanError("Plan is terminal, cannot resume")
context.pec_resume_svc = svc
# ---------------------------------------------------------------------------
# GIVEN steps - read-only plan
# ---------------------------------------------------------------------------
@given("pec a lifecycle service returning a read-only plan")
def step_pec_readonly_plan(context: Context) -> None:
context.pec_plan_id = str(ULID())
plan = MagicMock()
plan.read_only = True
svc = MagicMock()
svc.get_plan.return_value = plan
context.pec_lifecycle_svc = svc
# ---------------------------------------------------------------------------
# WHEN steps - explain
# ---------------------------------------------------------------------------
@when('pec I invoke "explain" with the decision id')
def step_pec_invoke_explain(context: Context) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app, ["explain", context.pec_decision_id]
)
@when('pec I invoke "explain" with format "{fmt}"')
def step_pec_invoke_explain_fmt(context: Context, fmt: str) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app, ["explain", context.pec_decision_id, "--format", fmt]
)
@when('pec I invoke "explain" with flags "{flags}"')
def step_pec_invoke_explain_flags(context: Context, flags: str) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app,
["explain", context.pec_decision_id, *flags.split()],
)
# ---------------------------------------------------------------------------
# WHEN steps - tree
# ---------------------------------------------------------------------------
@when('pec I invoke "tree" with a plan id')
def step_pec_invoke_tree(context: Context) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(plan_app, ["tree", context.pec_plan_id])
@when('pec I invoke "tree" with format "{fmt}"')
def step_pec_invoke_tree_fmt(context: Context, fmt: str) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app, ["tree", context.pec_plan_id, "--format", fmt]
)
@when('pec I invoke "tree" with format "{fmt}" and depth {depth:d}')
def step_pec_invoke_tree_fmt_depth(context: Context, fmt: str, depth: int) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app,
["tree", context.pec_plan_id, "--format", fmt, "--depth", str(depth)],
)
@when('pec I invoke "tree" with depth {depth:d}')
def step_pec_invoke_tree_depth(context: Context, depth: int) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app, ["tree", context.pec_plan_id, "--depth", str(depth)]
)
@when('pec I invoke "tree" with flags "{flags}"')
def step_pec_invoke_tree_flags(context: Context, flags: str) -> None:
with patch(_PATCH_CONTAINER, return_value=context.pec_container):
context.pec_result = runner.invoke(
plan_app, ["tree", context.pec_plan_id, *flags.split()]
)
# ---------------------------------------------------------------------------
# WHEN steps - orphan edge case
# ---------------------------------------------------------------------------
@when("pec I build the tree from orphan decisions")
def step_pec_build_orphan_tree(context: Context) -> None:
from cleveragents.cli.commands.plan import build_decision_tree
context.pec_tree = build_decision_tree(context.pec_orphan_decisions)
# ---------------------------------------------------------------------------
# WHEN steps - resolve active plan
# ---------------------------------------------------------------------------
@when("pec I call resolve active plan id")
def step_pec_resolve_active(context: Context) -> None:
from cleveragents.cli.commands.plan import _resolve_active_plan_id
with patch(_PATCH_LIFECYCLE, return_value=context.pec_lifecycle_svc):
context.pec_resolved_id = _resolve_active_plan_id()
# ---------------------------------------------------------------------------
# WHEN steps - revert
# ---------------------------------------------------------------------------
Outdated
Review

MEDIUM — Unnecessary mock. Since --plan is always passed in _invoke_correct (line 630), the production code path resolved_plan_id = plan_id or _resolve_active_plan_id() never calls _resolve_active_plan_id(). This patch.multiple is dead mock setup.

**MEDIUM — Unnecessary mock.** Since `--plan` is always passed in `_invoke_correct` (line 630), the production code path `resolved_plan_id = plan_id or _resolve_active_plan_id()` never calls `_resolve_active_plan_id()`. This `patch.multiple` is dead mock setup.
@when('pec I invoke "revert" with plan id and target "{target}"')
def step_pec_invoke_revert(context: Context, target: str) -> None:
with patch(_PATCH_LIFECYCLE, return_value=context.pec_lifecycle_svc):
context.pec_result = runner.invoke(
plan_app, ["revert", context.pec_plan_id, "--to-phase", target]
)
# ---------------------------------------------------------------------------
# WHEN steps - correct
# ---------------------------------------------------------------------------
def _invoke_correct(
context: Context, extra_args: list[str] | None = None, input_text: str | None = None
) -> None:
args = [
"correct",
context.pec_decision_id,
"--mode",
"revert",
"--guidance",
"Use FastAPI instead",
"--plan",
context.pec_plan_id,
]
if extra_args:
args.extend(extra_args)
with patch(
"cleveragents.application.services.correction_service.CorrectionService",
return_value=context.pec_correction_svc,
):
context.pec_result = runner.invoke(plan_app, args, input=input_text)
@when('pec I invoke "correct" in dry-run mode with rich format')
def step_pec_correct_dryrun(context: Context) -> None:
_invoke_correct(context, extra_args=["--dry-run"])
@when('pec I invoke "correct" with yes flag')
def step_pec_correct_yes(context: Context) -> None:
_invoke_correct(context, extra_args=["--yes"])
@when('pec I invoke "correct" without yes and decline')
def step_pec_correct_decline(context: Context) -> None:
_invoke_correct(context, input_text="n\n")
# ---------------------------------------------------------------------------
# WHEN steps - resume
# ---------------------------------------------------------------------------
@when('pec I invoke "resume" with a plan id')
def step_pec_invoke_resume(context: Context) -> None:
with (
patch(_PATCH_LIFECYCLE, return_value=MagicMock()),
patch(
_PATCH_RESUME_SVC_MOD,
return_value=context.pec_resume_svc,
),
):
context.pec_result = runner.invoke(plan_app, ["resume", context.pec_plan_id])
@when('pec I invoke "resume" with dry-run flag')
def step_pec_invoke_resume_dryrun(context: Context) -> None:
with (
patch(_PATCH_LIFECYCLE, return_value=MagicMock()),
patch(
_PATCH_RESUME_SVC_MOD,
return_value=context.pec_resume_svc,
),
):
context.pec_result = runner.invoke(
plan_app, ["resume", context.pec_plan_id, "--dry-run"]
)
@when('pec I invoke "resume" with format "{fmt}"')
def step_pec_invoke_resume_fmt(context: Context, fmt: str) -> None:
with (
patch(_PATCH_LIFECYCLE, return_value=MagicMock()),
patch(
_PATCH_RESUME_SVC_MOD,
return_value=context.pec_resume_svc,
),
):
context.pec_result = runner.invoke(
plan_app, ["resume", context.pec_plan_id, "--format", fmt]
)
# ---------------------------------------------------------------------------
# WHEN steps - read-only guards
# ---------------------------------------------------------------------------
@when('pec I invoke "execute" with the read-only plan id')
def step_pec_invoke_execute_readonly(context: Context) -> None:
with patch(_PATCH_LIFECYCLE, return_value=context.pec_lifecycle_svc):
context.pec_result = runner.invoke(plan_app, ["execute", context.pec_plan_id])
@when('pec I invoke "apply" with the read-only plan id')
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]
)
# ---------------------------------------------------------------------------
# THEN steps
# ---------------------------------------------------------------------------
@then("pec the exit code should be 0")
def step_pec_exit_0(context: Context) -> None:
assert context.pec_result.exit_code == 0, (
f"Expected exit 0, got {context.pec_result.exit_code}.\n"
f"Output: {context.pec_result.output}"
)
@then("pec the exit code should be {code:d}")
def step_pec_exit_code(context: Context, code: int) -> None:
assert context.pec_result.exit_code == code, (
f"Expected exit {code}, got {context.pec_result.exit_code}.\n"
f"Output: {context.pec_result.output}"
)
@then("pec the exit code should be nonzero")
def step_pec_exit_nonzero(context: Context) -> None:
assert context.pec_result.exit_code != 0, (
f"Expected nonzero exit, got {context.pec_result.exit_code}.\n"
f"Output: {context.pec_result.output}"
)
@then('pec the output should contain "{text}"')
def step_pec_output_contains(context: Context, text: str) -> None:
assert text in context.pec_result.output, (
f"Expected '{text}' in output.\nOutput: {context.pec_result.output}"
)
@then('pec the output should not contain "{text}"')
def step_pec_output_not_contains(context: Context, text: str) -> None:
assert text not in context.pec_result.output, (
f"Did not expect '{text}' in output.\nOutput: {context.pec_result.output}"
)
@then("pec the output should be valid json")
def step_pec_output_valid_json(context: Context) -> None:
parsed = json.loads(context.pec_result.output.strip())
assert isinstance(parsed, dict), "Expected JSON object"
@then("pec the output should be valid json list")
def step_pec_output_valid_json_list(context: Context) -> None:
parsed = json.loads(context.pec_result.output.strip())
assert isinstance(parsed, list), "Expected JSON array"
@then("pec the tree should exclude the superseded grandchild")
def step_pec_tree_excludes_orphan(context: Context) -> None:
# Tree should have exactly one root with one child and zero grandchildren.
assert len(context.pec_tree) == 1, f"Expected 1 root, got {len(context.pec_tree)}"
root = context.pec_tree[0]
assert root["decision_id"] == context.pec_orphan_root_id
children = root["children"]
assert len(children) == 1, ( # type: ignore[arg-type]
f"Expected 1 child, got {len(children)}" # type: ignore[arg-type]
)
# The grandchild was superseded and filtered from by_id; the BFS
# orphan guard must have skipped it, leaving no grandchildren.
grandchildren = children[0]["children"] # type: ignore[index]
assert len(grandchildren) == 0, ( # type: ignore[arg-type]
f"Expected 0 grandchildren, got {len(grandchildren)}" # type: ignore[arg-type]
)
@then("pec the resolved plan id should match the active plan")
def step_pec_resolved_matches(context: Context) -> None:
assert context.pec_resolved_id == context.pec_expected_plan_id
+439
View File
@@ -0,0 +1,439 @@
"""Step definitions for plan_explain.feature.
Tests the plan explain and plan tree CLI formatting and tree-building
logic directly, without requiring a database or full CLI process.
"""
from __future__ import annotations
import json
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from ulid import ULID
from cleveragents.cli.commands.plan import (
_build_explain_dict,
build_decision_tree,
)
from cleveragents.cli.formatting import format_output
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PLAN_ID = str(ULID())
def _make_decision(**overrides: object) -> Decision:
"""Build a Decision with sensible defaults."""
defaults: dict[str, object] = {
"plan_id": _PLAN_ID,
"sequence_number": 0,
"decision_type": DecisionType.PROMPT_DEFINITION,
"question": "What should we build?",
"chosen_option": "A REST API",
}
dt = overrides.get("decision_type", defaults["decision_type"])
if dt != DecisionType.PROMPT_DEFINITION and "parent_decision_id" not in overrides:
defaults["parent_decision_id"] = str(ULID())
defaults.update(overrides)
return Decision(**defaults)
# ---------------------------------------------------------------------------
# Given steps - explain
# ---------------------------------------------------------------------------
@given("a test decision for explain")
def step_test_decision(context: Context) -> None:
context.pe_decision = _make_decision()
@given("a test decision with context snapshot for explain")
def step_test_decision_with_context(context: Context) -> None:
snap = ContextSnapshot(
hot_context_hash="sha256:testctx",
hot_context_ref="store://test",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
],
actor_state_ref="checkpoint://test/001",
)
context.pe_decision = _make_decision(context_snapshot=snap)
@given("a test decision with reasoning for explain")
def step_test_decision_with_reasoning(context: Context) -> None:
context.pe_decision = _make_decision(
rationale="Chose REST API for simplicity",
actor_reasoning="The LLM considered multiple approaches...",
)
@given("a test decision with alternatives for explain")
def step_test_decision_with_alternatives(context: Context) -> None:
context.pe_decision = _make_decision(
alternatives_considered=["GraphQL API", "gRPC service"],
)
@given("a non-existent decision id")
def step_nonexistent_decision(context: Context) -> None:
context.pe_missing_id = str(ULID())
# Also store a known list so we can verify it's absent
context.pe_known_ids = [str(ULID()) for _ in range(3)]
# ---------------------------------------------------------------------------
# Given steps - tree
# ---------------------------------------------------------------------------
@given("a set of test decisions forming a tree")
def step_tree_decisions(context: Context) -> None:
root_id = str(ULID())
child1_id = str(ULID())
child2_id = str(ULID())
context.pe_decisions = [
Decision(
decision_id=root_id,
plan_id=_PLAN_ID,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What to build?",
chosen_option="REST API",
),
Decision(
decision_id=child1_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen_option="FastAPI",
),
Decision(
decision_id=child2_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=2,
decision_type=DecisionType.RESOURCE_SELECTION,
question="Which database?",
chosen_option="PostgreSQL",
),
]
@given("a set of test decisions with superseded entries")
def step_tree_with_superseded(context: Context) -> None:
root_id = str(ULID())
child_id = str(ULID())
superseding_id = str(ULID())
context.pe_decisions = [
Decision(
decision_id=root_id,
plan_id=_PLAN_ID,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What to build?",
chosen_option="REST API",
),
Decision(
decision_id=child_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen_option="Flask",
superseded_by=superseding_id,
),
Decision(
decision_id=superseding_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=2,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which framework?",
chosen_option="FastAPI",
is_correction=True,
corrects_decision_id=child_id,
correction_reason="FastAPI is faster",
),
]
context.pe_superseded_id = child_id
@given("a set of test decisions forming a deep tree")
def step_deep_tree(context: Context) -> None:
root_id = str(ULID())
child_id = str(ULID())
grandchild_id = str(ULID())
context.pe_decisions = [
Decision(
decision_id=root_id,
plan_id=_PLAN_ID,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root question?",
chosen_option="Root answer",
),
Decision(
decision_id=child_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child question?",
chosen_option="Child answer",
),
Decision(
decision_id=grandchild_id,
plan_id=_PLAN_ID,
parent_decision_id=child_id,
sequence_number=2,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Grandchild question?",
chosen_option="Grandchild answer",
),
]
@given("an empty list of decisions")
def step_empty_decisions(context: Context) -> None:
context.pe_decisions = []
# ---------------------------------------------------------------------------
# When steps - explain
# ---------------------------------------------------------------------------
@when("I build the explain dict with default options")
def step_build_explain_default(context: Context) -> None:
context.pe_explain_dict = _build_explain_dict(context.pe_decision)
@when("I build the explain dict with show-context enabled")
def step_build_explain_context(context: Context) -> None:
context.pe_explain_dict = _build_explain_dict(
context.pe_decision, show_context=True
)
@when("I build the explain dict with show-reasoning enabled")
def step_build_explain_reasoning(context: Context) -> None:
context.pe_explain_dict = _build_explain_dict(
context.pe_decision, show_reasoning=True
)
@when("I format the explain dict as json")
def step_format_explain_json(context: Context) -> None:
data = _build_explain_dict(context.pe_decision)
context.pe_json_output = format_output(data, "json")
@when("I format the explain dict as yaml")
def step_format_explain_yaml(context: Context) -> None:
data = _build_explain_dict(context.pe_decision)
context.pe_yaml_output = format_output(data, "yaml")
# ---------------------------------------------------------------------------
# When steps - tree
# ---------------------------------------------------------------------------
@when("I build the decision tree with default options")
def step_build_tree_default(context: Context) -> None:
context.pe_tree = build_decision_tree(context.pe_decisions)
@when("I build the decision tree with show-superseded enabled")
def step_build_tree_superseded(context: Context) -> None:
context.pe_tree = build_decision_tree(context.pe_decisions, show_superseded=True)
@when("I build the decision tree with depth {depth_val:d}")
def step_build_tree_depth(context: Context, depth_val: int) -> None:
context.pe_tree = build_decision_tree(context.pe_decisions, max_depth=depth_val)
@when("I build the decision tree with default options from empty list")
def step_build_tree_empty(context: Context) -> None:
context.pe_tree = build_decision_tree(context.pe_decisions)
@when("I format the tree as json")
def step_format_tree_json(context: Context) -> None:
tree = build_decision_tree(context.pe_decisions)
context.pe_tree_json = format_output(tree, "json")
@when("I format the tree as yaml")
def step_format_tree_yaml(context: Context) -> None:
tree = build_decision_tree(context.pe_decisions)
context.pe_tree_yaml = format_output(tree, "yaml")
# ---------------------------------------------------------------------------
# Then steps - explain
# ---------------------------------------------------------------------------
@then('the explain dict should contain key "{key}"')
def step_explain_has_key(context: Context, key: str) -> None:
assert key in context.pe_explain_dict, (
f"Expected key '{key}' in {list(context.pe_explain_dict.keys())}"
)
@then('the explain dict should not contain key "{key}"')
def step_explain_not_has_key(context: Context, key: str) -> None:
assert key not in context.pe_explain_dict, f"Key '{key}' should not be present"
@then('the context snapshot should contain key "{key}"')
def step_snapshot_has_key(context: Context, key: str) -> None:
snap = context.pe_explain_dict["context_snapshot"]
assert key in snap, f"Expected key '{key}' in snapshot: {list(snap.keys())}"
@then("the alternatives list should have {count:d} items")
def step_alternatives_count(context: Context, count: int) -> None:
alts = context.pe_explain_dict["alternatives_considered"]
assert len(alts) == count, f"Expected {count} alternatives, got {len(alts)}"
@then('the json output should contain "{text}"')
def step_json_contains(context: Context, text: str) -> None:
assert text in context.pe_json_output, f"Expected '{text}' in JSON output"
@then("the json output should be valid json")
def step_json_valid(context: Context) -> None:
parsed = json.loads(context.pe_json_output)
assert isinstance(parsed, dict), "Expected a JSON object"
@then('the yaml output should contain "{text}"')
def step_yaml_contains(context: Context, text: str) -> None:
assert text in context.pe_yaml_output, f"Expected '{text}' in YAML output"
@then("the explain lookup should indicate not found")
def step_explain_not_found(context: Context) -> None:
# Verify id is a valid ULID
assert context.pe_missing_id is not None
assert len(context.pe_missing_id) == 26 # ULID length
# Verify the generated id is NOT among any known decision ids
assert context.pe_missing_id not in context.pe_known_ids, (
"Non-existent id should differ from all known decision ids"
)
# _build_explain_dict requires a Decision object; passing None would be
# a TypeError. The "not found" path is handled at the CLI level
# (explain_decision_cmd checks `decision is None` before calling
# _build_explain_dict). Verify the contract: the function signature
# does not accept None.
import inspect
sig = inspect.signature(_build_explain_dict)
first_param = next(iter(sig.parameters.values()))
assert first_param.annotation is not None
# ---------------------------------------------------------------------------
# Then steps - tree
# ---------------------------------------------------------------------------
@then("the tree should have at least {count:d} root node")
def step_tree_root_count(context: Context, count: int) -> None:
assert len(context.pe_tree) >= count, (
f"Expected >= {count} roots, got {len(context.pe_tree)}"
)
@then("the first root node should have children")
def step_root_has_children(context: Context) -> None:
root = context.pe_tree[0]
assert len(root["children"]) > 0, "Expected root to have children"
@then("the tree should include superseded decision nodes")
def step_tree_includes_superseded(context: Context) -> None:
# With show_superseded=True, all decisions including superseded should be in tree
all_ids: list[str] = []
_collect_ids(context.pe_tree, all_ids)
assert context.pe_superseded_id in all_ids, (
f"Superseded ID {context.pe_superseded_id} not found in tree"
)
@then("the root nodes should have no children")
def step_roots_no_children(context: Context) -> None:
for root in context.pe_tree:
assert len(root["children"]) == 0, (
f"Root {root['decision_id']} should have no children at depth 1"
)
@then("the json tree output should be valid json")
def step_tree_json_valid(context: Context) -> None:
parsed = json.loads(context.pe_tree_json)
assert isinstance(parsed, list), "Expected a JSON array"
@then('the json tree output should contain "{text}"')
def step_tree_json_contains(context: Context, text: str) -> None:
assert text in context.pe_tree_json, f"Expected '{text}' in tree JSON"
@then('the yaml tree output should contain "{text}"')
def step_tree_yaml_contains(context: Context, text: str) -> None:
assert text in context.pe_tree_yaml, f"Expected '{text}' in tree YAML"
@then("the tree should be empty")
def step_tree_empty(context: Context) -> None:
assert len(context.pe_tree) == 0, (
f"Expected empty tree, got {len(context.pe_tree)} roots"
)
@then("the tree should not include superseded decision nodes")
def step_tree_excludes_superseded(context: Context) -> None:
all_ids: list[str] = []
_collect_ids(context.pe_tree, all_ids)
assert context.pe_superseded_id not in all_ids, (
f"Superseded ID {context.pe_superseded_id} should not be in default tree"
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _collect_ids(nodes: list[dict[str, object]], out: list[str]) -> None:
"""Recursively collect all decision_ids from tree nodes."""
from collections import deque
queue: deque[dict[str, object]] = deque(nodes)
while queue:
node = queue.popleft()
out.append(str(node["decision_id"]))
children = node.get("children", [])
assert isinstance(children, list)
for child in children:
assert isinstance(child, dict)
queue.append(child)
+103
View File
@@ -0,0 +1,103 @@
"""Helper script for Robot Framework plan explain smoke tests."""
from __future__ import annotations
import sys
from collections.abc import Callable
from pathlib import Path
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from ulid import ULID
from cleveragents.cli.commands.plan import (
_build_explain_dict,
build_decision_tree,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
_PLAN_ID = str(ULID())
def _test_explain_format() -> None:
"""Create a decision and verify explain formatting."""
decision = Decision(
plan_id=_PLAN_ID,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What to build?",
chosen_option="REST API",
rationale="Simple and well-understood",
context_snapshot=ContextSnapshot(
hot_context_hash="sha256:robot_test",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path="src/app.py"),
],
),
)
data = _build_explain_dict(
decision,
show_context=True,
show_reasoning=True,
)
assert "decision_id" in data
assert "context_snapshot" in data
assert "rationale" in data
assert "alternatives_considered" in data
assert data["question"] == "What to build?"
print("plan-explain-ok")
def _test_tree_build() -> None:
"""Create decisions and build a tree."""
root_id = str(ULID())
child_id = str(ULID())
decisions = [
Decision(
decision_id=root_id,
plan_id=_PLAN_ID,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root?",
chosen_option="Yes",
),
Decision(
decision_id=child_id,
plan_id=_PLAN_ID,
parent_decision_id=root_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child?",
chosen_option="Also yes",
),
]
tree = build_decision_tree(decisions)
assert len(tree) == 1
assert len(tree[0]["children"]) == 1
print("plan-tree-ok")
_TESTS: dict[str, Callable[[], None]] = {
"explain_format": _test_explain_format,
"tree_build": _test_tree_build,
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <test_name>")
print(f"Available tests: {', '.join(sorted(_TESTS))}")
sys.exit(1)
test_name = sys.argv[1]
if test_name not in _TESTS:
print(f"Unknown test: {test_name}")
print(f"Available: {', '.join(sorted(_TESTS))}")
sys.exit(1)
_TESTS[test_name]()
+21
View File
@@ -0,0 +1,21 @@
*** Settings ***
Documentation Smoke tests for plan explain and plan tree CLI logic
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_plan_explain.py
*** Test Cases ***
Plan Explain Formatting
[Documentation] Build explain dict and verify output
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} explain_format cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-explain-ok
Plan Tree Build
[Documentation] Build decision tree from flat decisions list
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} tree_build cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-tree-ok
+323 -7
View File
@@ -81,7 +81,9 @@ _LEGACY_DEPRECATION_MSG = (
)
if TYPE_CHECKING:
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.domain.models.core import Change, Plan, Project
from cleveragents.domain.models.core.decision import Decision
# Create sub-app for plan commands
app = typer.Typer(
@@ -2247,19 +2249,21 @@ def correct_decision(
mode: Annotated[
str,
typer.Option(
...,
"--mode",
"-m",
help="Correction mode: revert or append",
),
] = "revert",
],
guidance: Annotated[
str,
typer.Option(
...,
"--guidance",
"-g",
help="Guidance text for the correction",
),
] = "",
],
dry_run: Annotated[
bool,
typer.Option(
@@ -2322,11 +2326,9 @@ def correct_decision(
)
raise typer.Abort() from exc
# Validate guidance
if not guidance or not guidance.strip():
console.print(
"[red]Error:[/red] --guidance / -g is required and must not be empty."
)
# Validate guidance is not blank
if not guidance.strip():
console.print("[red]Error:[/red] --guidance / -g must not be blank.")
raise typer.Abort()
# Resolve plan_id
@@ -2658,3 +2660,317 @@ def rollback_plan(
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
# ---------------------------------------------------------------------------
# plan explain
# ---------------------------------------------------------------------------
def _build_explain_dict(
decision: Decision,
*,
show_context: bool = False,
show_reasoning: bool = False,
) -> dict[str, object]:
"""Build a dict for ``plan explain`` output from a Decision."""
data: dict[str, object] = {
"decision_id": decision.decision_id,
"plan_id": decision.plan_id,
"type": str(decision.decision_type),
"sequence": decision.sequence_number,
"question": decision.question,
"chosen": decision.chosen_option,
"confidence": decision.confidence_score,
"parent": decision.parent_decision_id or "(root)",
"is_correction": decision.is_correction,
"superseded": decision.is_superseded,
"created_at": decision.created_at.isoformat(),
"alternatives_considered": decision.alternatives_considered,
}
if show_reasoning:
data["rationale"] = decision.rationale
data["actor_reasoning"] = decision.actor_reasoning or ""
if show_context:
snap = decision.context_snapshot
data["context_snapshot"] = {
"hot_context_hash": snap.hot_context_hash,
"hot_context_ref": snap.hot_context_ref,
"actor_state_ref": snap.actor_state_ref,
"relevant_resources": [
{"resource_id": r.resource_id, "path": r.path}
for r in snap.relevant_resources
],
}
return data
@app.command("explain")
def explain_decision_cmd(
decision_id: Annotated[
str,
typer.Argument(help="Decision ULID to explain"),
],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
show_context: Annotated[
bool,
typer.Option("--show-context", help="Include context snapshot"),
] = False,
show_reasoning: Annotated[
bool,
typer.Option("--show-reasoning", help="Include rationale and actor reasoning"),
] = False,
) -> None:
"""Explain a single decision in the plan decision tree."""
from cleveragents.application.container import get_container
from cleveragents.application.services.decision_service import (
DecisionService as _DS,
)
container = get_container()
svc: DecisionService = container.resolve(_DS)
decision = svc.get_decision(decision_id)
if decision is None:
console.print(f"[red]Error:[/red] Decision '{decision_id}' not found.")
raise typer.Exit(1)
data = _build_explain_dict(
decision,
show_context=show_context,
show_reasoning=show_reasoning,
)
if fmt in (OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TABLE):
console.print(format_output(data, fmt))
else:
# rich / plain
table = Table(title="Decision Details", show_header=False, expand=False)
table.add_column("Field", style="bold")
table.add_column("Value")
import json as _json
for key, val in data.items():
if isinstance(val, dict):
table.add_row(key, _json.dumps(val, indent=2, default=str))
elif isinstance(val, list):
table.add_row(key, _json.dumps(val, default=str))
else:
table.add_row(key, str(val))
console.print(Panel(table, expand=False))
# ---------------------------------------------------------------------------
# plan tree
# ---------------------------------------------------------------------------
def build_decision_tree(
decisions: list[Decision],
*,
show_superseded: bool = False,
max_depth: int = 0,
) -> list[dict[str, object]]:
"""Build a nested tree structure from a flat list of decisions.
Uses ``collections.deque`` for BFS traversal (never ``list.pop(0)``).
Args:
decisions: Flat list of Decision objects.
show_superseded: If False, filter out superseded decisions.
max_depth: Maximum tree depth (0 = unlimited).
Returns:
A list of root-level tree-node dicts, each with a ``children`` key.
"""
from collections import deque
filtered = (
decisions if show_superseded else [d for d in decisions if not d.is_superseded]
)
# Index by decision_id (only non-superseded when show_superseded=False)
by_id: dict[str, Decision] = {d.decision_id: d for d in filtered}
# Map parent_id -> children from ALL decisions so that the BFS
# orphan guard (``child_id not in by_id``) correctly skips
# superseded children instead of silently losing them.
children_map: dict[str | None, list[str]] = {}
for d in decisions:
children_map.setdefault(d.parent_decision_id, []).append(d.decision_id)
# Find roots (parent_decision_id is None or parent not in by_id)
roots: list[str] = []
for d in filtered:
if d.parent_decision_id is None or d.parent_decision_id not in by_id:
roots.append(d.decision_id)
def _node_dict(d: Decision) -> dict[str, object]:
return {
"decision_id": d.decision_id,
"type": str(d.decision_type),
"sequence": d.sequence_number,
"question": d.question,
"chosen": d.chosen_option,
"confidence": d.confidence_score,
"superseded": d.is_superseded,
"children": [],
}
# BFS to build tree
result: list[dict[str, object]] = []
# queue entries: (decision_id, parent_children_list, current_depth)
queue: deque[tuple[str, list[dict[str, object]], int]] = deque()
for rid in roots:
node = _node_dict(by_id[rid])
result.append(node)
queue.append((rid, node["children"], 1)) # type: ignore[arg-type] # children value is list at runtime; dict[str, object] prevents narrowing
while queue:
did, parent_list, depth_val = queue.popleft()
if max_depth > 0 and depth_val >= max_depth:
continue
for child_id in children_map.get(did, []):
if child_id not in by_id:
continue
child_node = _node_dict(by_id[child_id])
parent_list.append(child_node)
queue.append(
(child_id, child_node["children"], depth_val + 1) # type: ignore[arg-type] # children value is list at runtime; dict[str, object] prevents narrowing
)
return result
@app.command("tree")
def tree_decisions_cmd(
plan_id: Annotated[
str,
typer.Argument(help="Plan ULID to display decision tree for"),
],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
show_superseded: Annotated[
bool,
typer.Option("--show-superseded", help="Include superseded decisions in tree"),
] = False,
depth: Annotated[
int,
typer.Option("--depth", help="Maximum tree depth (0 = unlimited)"),
] = 0,
) -> None:
"""Display the decision tree for a plan."""
from cleveragents.application.container import get_container
from cleveragents.application.services.decision_service import (
DecisionService as _DS,
)
container = get_container()
svc: DecisionService = container.resolve(_DS)
decisions = svc.get_decisions_for_plan(plan_id)
if not decisions:
console.print(f"No decisions found for plan '{plan_id}'.")
return
tree_data = build_decision_tree(
decisions,
show_superseded=show_superseded,
max_depth=depth,
)
if fmt in (OutputFormat.JSON, OutputFormat.YAML):
console.print(format_output(tree_data, fmt))
elif fmt == OutputFormat.TABLE:
# Flatten for table view
filtered = (
decisions
if show_superseded
else [d for d in decisions if not d.is_superseded]
)
by_id = {d.decision_id: d for d in filtered}
table = Table(title="Decision Tree", show_header=True)
table.add_column("ID", max_width=8)
table.add_column("Type")
table.add_column("Seq")
table.add_column("Question", max_width=40)
table.add_column("Chosen", max_width=30)
table.add_column("Confidence")
table.add_column("Superseded")
for d in filtered:
if depth > 0:
# Compute depth of this node
node_depth = 0
pid = d.parent_decision_id
seen: set[str] = set()
while pid and pid not in seen:
seen.add(pid)
node_depth += 1
parent = by_id.get(pid)
pid = parent.parent_decision_id if parent else None
if node_depth >= depth:
continue
table.add_row(
d.decision_id[:8],
str(d.decision_type),
str(d.sequence_number),
d.question[:40] if len(d.question) > 40 else d.question,
(
d.chosen_option[:30]
if len(d.chosen_option) > 30
else d.chosen_option
),
str(d.confidence_score) if d.confidence_score is not None else "-",
str(d.is_superseded),
)
console.print(table)
else:
# rich / plain - use Rich Tree
from rich.tree import Tree as RichTree
rich_tree = RichTree(f"[bold]Plan {plan_id[:8]}...[/bold]")
filtered = (
decisions
if show_superseded
else [d for d in decisions if not d.is_superseded]
)
from collections import deque as _deque
filt_ids = {d.decision_id for d in filtered}
roots = [
d
for d in filtered
if d.parent_decision_id is None or d.parent_decision_id not in filt_ids
]
tree_queue: _deque[tuple[Decision, RichTree, int]] = _deque()
for root_d in roots:
label = (
f"[bold]{root_d.decision_id[:8]}[/bold] "
f"({root_d.decision_type}) "
f"Q: {root_d.question[:40]}"
)
branch = rich_tree.add(label)
tree_queue.append((root_d, branch, 1))
children_map: dict[str | None, list[Decision]] = {}
for d in filtered:
children_map.setdefault(d.parent_decision_id, []).append(d)
while tree_queue:
current, parent_branch, cur_depth = tree_queue.popleft()
if depth > 0 and cur_depth >= depth:
continue
for child in children_map.get(current.decision_id, []):
clabel = (
f"[bold]{child.decision_id[:8]}[/bold] "
f"({child.decision_type}) "
f"Q: {child.question[:40]}"
)
cbranch = parent_branch.add(clabel)
tree_queue.append((child, cbranch, cur_depth + 1))
console.print(rich_tree)