feat(cli): add plan explain and decision tree outputs #464
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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),
|
||||
|
CoreRasurae
commented
HIGH — Sham test. This step provides only a root decision with no children, so To actually cover the orphan branch, construct a scenario where **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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
CoreRasurae
commented
MEDIUM — Unnecessary mock. Since **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
|
||||
@@ -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)
|
||||
@@ -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]()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
MEDIUM — Dead code.
_PATCH_CORRECTION_SVCand_PATCH_RESUME_SVC_MODare defined here but never referenced anywhere in this file. The actual patch targets are hardcoded inline in_invoke_correct(line 620) and the resumewhensteps (lines 665, 677, 691). Either use these constants or remove them.