fix(cli): implement spec-required structured panels in agents plan explain rich output

Replace the single flat 'Decision Details' table in explain_decision_cmd()
with six structured Rich panels matching the spec exactly:

- Decision panel: ID, Type, Question, Chosen, Confidence, Plan, Sequence, Created
- Alternatives Considered panel: numbered list with '(chosen)' marker
- Impact panel: Downstream Decisions, Downstream Child Plans, Artifacts Produced,
  Correction Impact (derived from downstream_decision_ids count)
- Context Snapshot panel (--show-context): resource paths + hot context hash
- Rationale panel (--show-reasoning): rationale text
- Correction panel: 'agents plan correct <ID> --mode revert --guidance "..."' hint

Also adds the missing success message '✓ OK Decision explained'.

Updates _build_explain_dict() to:
- Accept total_decisions parameter for 'X of Y' sequence format
- Return structured alternatives list [{index, description, chosen}] instead
  of flat string list
- Include impact dict with downstream_decisions, downstream_child_plans,
  artifacts_produced, correction_impact
- Include correction_hint field

Updates feature files and step definitions to test the new output structure.

ISSUES CLOSED: #2815
This commit is contained in:
2026-04-05 09:02:44 +00:00
parent 1411adfed3
commit 8cdb05bb1f
5 changed files with 283 additions and 21 deletions
+36 -2
View File
@@ -13,7 +13,9 @@ Feature: Plan explain and decision tree CLI commands
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 contain key "alternatives"
And the explain dict should contain key "impact"
And the explain dict should contain key "correction_hint"
And the explain dict should not contain key "rationale"
And the explain dict should not contain key "context_snapshot"
@@ -45,9 +47,41 @@ Feature: Plan explain and decision tree CLI commands
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"
Then the explain dict should contain key "alternatives"
And the alternatives list should have 2 items
# ------------------------------------------------------------------
# plan explain - impact dict always included
# ------------------------------------------------------------------
Scenario: Explain includes impact dict by default
Given a test decision for explain
When I build the explain dict with default options
Then the explain dict should contain key "impact"
And the impact dict should contain key "downstream_decisions"
And the impact dict should contain key "downstream_child_plans"
And the impact dict should contain key "artifacts_produced"
And the impact dict should contain key "correction_impact"
# ------------------------------------------------------------------
# plan explain - correction_hint always included
# ------------------------------------------------------------------
Scenario: Explain includes correction_hint by default
Given a test decision for explain
When I build the explain dict with default options
Then the explain dict should contain key "correction_hint"
And the correction hint should reference the decision id
# ------------------------------------------------------------------
# plan explain - sequence format
# ------------------------------------------------------------------
Scenario: Explain sequence shows X of Y when total provided
Given a test decision for explain
When I build the explain dict with total decisions 5
Then the sequence should be formatted as "X of Y"
# ------------------------------------------------------------------
# plan explain - json format
# ------------------------------------------------------------------
+2 -2
View File
@@ -12,7 +12,7 @@ Feature: Plan explain and tree CLI command coverage
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"
And pec the output should contain "decision_id"
Scenario: Explain CLI renders json format
@@ -49,7 +49,7 @@ Feature: Plan explain and tree CLI command coverage
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"
And pec the output should contain "Alternatives Considered"
# ------------------------------------------------------------------
# tree_decisions_cmd - rich tree format
@@ -94,6 +94,8 @@ def _make_decision(
def _mock_container_with_decision_svc(svc_mock: MagicMock) -> MagicMock:
# Ensure count_decisions returns an int for sequence formatting.
svc_mock.count_decisions.return_value = 3
container = MagicMock()
container.decision_service.return_value = svc_mock
return container
+32 -1
View File
@@ -236,6 +236,13 @@ def step_build_explain_reasoning(context: Context) -> None:
)
@when("I build the explain dict with total decisions {total:d}")
def step_build_explain_with_total(context: Context, total: int) -> None:
context.pe_explain_dict = _build_explain_dict(
context.pe_decision, total_decisions=total
)
def _capture_format_output(data, fmt):
"""Call format_output capturing stdout (machine-readable formats write there)."""
from contextlib import redirect_stdout
@@ -319,12 +326,36 @@ def step_snapshot_has_key(context: Context, key: str) -> None:
assert key in snap, f"Expected key '{key}' in snapshot: {list(snap.keys())}"
@then('the impact dict should contain key "{key}"')
def step_impact_has_key(context: Context, key: str) -> None:
impact = context.pe_explain_dict["impact"]
assert isinstance(impact, dict), (
f"Expected 'impact' to be a dict, got {type(impact)}"
)
assert key in impact, f"Expected key '{key}' in impact: {list(impact.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"]
alts = context.pe_explain_dict["alternatives"]
assert len(alts) == count, f"Expected {count} alternatives, got {len(alts)}"
@then("the correction hint should reference the decision id")
def step_correction_hint_has_decision_id(context: Context) -> None:
hint = context.pe_explain_dict["correction_hint"]
decision_id = context.pe_explain_dict["decision_id"]
assert decision_id in str(hint), (
f"Expected decision_id '{decision_id}' in correction_hint '{hint}'"
)
@then('the sequence should be formatted as "X of Y"')
def step_sequence_x_of_y(context: Context) -> None:
seq = context.pe_explain_dict["sequence"]
assert " of " in str(seq), f"Expected sequence in 'X of Y' format, got '{seq}'"
@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"
+211 -16
View File
@@ -3457,13 +3457,68 @@ def _build_explain_dict(
*,
show_context: bool = False,
show_reasoning: bool = False,
total_decisions: int | None = None,
) -> dict[str, object]:
"""Build a dict for ``plan explain`` output from a Decision."""
"""Build a dict for ``plan explain`` output from a Decision.
Args:
decision: The decision to explain.
show_context: Include the context snapshot in the output.
show_reasoning: Include rationale and actor reasoning.
total_decisions: Total number of decisions in the plan, used to
format ``sequence`` as "X of Y". When ``None`` the raw
sequence number is used.
"""
# Format sequence as "X of Y" when total is known.
if total_decisions is not None:
sequence_str = f"{decision.sequence_number + 1} of {total_decisions}"
else:
sequence_str = str(decision.sequence_number)
# Build structured alternatives list: [{index, description, chosen}].
chosen_lower = decision.chosen_option.strip().lower()
alternatives: list[dict[str, object]] = []
for idx, alt in enumerate(decision.alternatives_considered, start=1):
alternatives.append(
{
"index": idx,
"description": alt,
"chosen": alt.strip().lower() == chosen_lower,
}
)
# Always include the chosen option itself if alternatives list is empty.
if not alternatives:
alternatives = [
{"index": 1, "description": decision.chosen_option, "chosen": True}
]
# Derive a simple correction_impact level from downstream count.
downstream_count = len(decision.downstream_decision_ids)
if downstream_count == 0:
correction_impact = "low"
elif downstream_count <= 3:
correction_impact = "medium"
else:
correction_impact = "high"
# Build impact sub-dict.
impact: dict[str, object] = {
"downstream_decisions": len(decision.downstream_decision_ids),
"downstream_child_plans": len(decision.downstream_plan_ids),
"artifacts_produced": len(decision.artifacts_produced),
"correction_impact": correction_impact,
}
# Build correction hint.
correction_hint = (
f'agents plan correct {decision.decision_id} --mode revert --guidance "..."'
)
data: dict[str, object] = {
"decision_id": decision.decision_id,
"plan_id": decision.plan_id,
"type": str(decision.decision_type),
"sequence": decision.sequence_number,
"sequence": sequence_str,
"question": decision.question,
"chosen": decision.chosen_option,
"confidence": decision.confidence_score,
@@ -3471,7 +3526,9 @@ def _build_explain_dict(
"is_correction": decision.is_correction,
"superseded": decision.is_superseded,
"created_at": decision.created_at.isoformat(),
"alternatives_considered": decision.alternatives_considered,
"alternatives": alternatives,
"impact": impact,
"correction_hint": correction_hint,
}
if show_reasoning:
data["rationale"] = decision.rationale
@@ -3537,29 +3594,167 @@ def explain_decision_cmd(
)
raise typer.Exit(1)
# Fetch total decision count for "X of Y" sequence format.
total_decisions: int | None = None
with suppress(Exception):
total_decisions = svc.count_decisions(decision.plan_id)
data = _build_explain_dict(
decision,
show_context=show_context,
show_reasoning=show_reasoning,
total_decisions=total_decisions,
)
if fmt in (OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TABLE):
console.print(format_output(data, fmt))
elif fmt == OutputFormat.PLAIN.value or fmt == "plain":
# Plain text: section headers + indented key-value lines.
created_str = decision.created_at.strftime("%Y-%m-%d %H:%M")
lines = [
"Decision",
f" ID: {data['decision_id']}",
f" Type: {data['type']}",
f" Question: {data['question']}",
f" Chosen: {data['chosen']}",
f" Confidence: {data['confidence']}",
f" Plan: {data['plan_id']}",
f" Sequence: {data['sequence']}",
f" Created: {created_str}",
"",
"Alternatives Considered",
]
plain_alts = data.get("alternatives", [])
assert isinstance(plain_alts, list)
for alt in plain_alts:
assert isinstance(alt, dict)
suffix = " (chosen)" if alt.get("chosen") else ""
lines.append(f" {alt['index']}. {alt['description']}{suffix}")
impact = data.get("impact", {})
assert isinstance(impact, dict)
lines += [
"",
"Impact",
f" Downstream Decisions: {impact.get('downstream_decisions', 0)}",
f" Downstream Child Plans: {impact.get('downstream_child_plans', 0)}",
f" Artifacts Produced: {impact.get('artifacts_produced', 0)}",
f" Correction Impact: {impact.get('correction_impact', 'low')}",
]
if show_context and "context_snapshot" in data:
snap = data["context_snapshot"]
assert isinstance(snap, dict)
lines += ["", "Context Snapshot"]
for res in snap.get("relevant_resources", []):
assert isinstance(res, dict)
lines.append(f" - {res.get('path', '')}")
lines.append(f" Hot Context Hash: {snap.get('hot_context_hash', '')}")
if show_reasoning:
lines += ["", "Rationale", f" {data.get('rationale', '')}"]
lines += [
"",
"Correction",
f" {data['correction_hint']}",
"",
"[OK] Decision explained",
]
console.print("\n".join(lines))
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
# Rich output: structured panels per spec.
created_str = decision.created_at.strftime("%Y-%m-%d %H:%M")
confidence_val = data.get("confidence")
confidence_str = str(confidence_val) if confidence_val is not None else "N/A"
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))
# --- Decision panel ---
console.print(
Panel(
f"[bold]ID:[/bold] {data['decision_id']}\n"
f"[bold]Type:[/bold] {data['type']}\n"
f"[bold]Question:[/bold] {data['question']}\n"
f"[bold]Chosen:[/bold] {data['chosen']}\n"
f"[bold]Confidence:[/bold] {confidence_str}\n"
f"[bold]Plan:[/bold] {data['plan_id']}\n"
f"[bold]Sequence:[/bold] {data['sequence']}\n"
f"[bold]Created:[/bold] {created_str}",
title="Decision",
expand=False,
)
)
# --- Alternatives Considered panel ---
alt_lines: list[str] = []
rich_alts = data.get("alternatives", [])
assert isinstance(rich_alts, list)
for alt in rich_alts:
assert isinstance(alt, dict)
suffix = " [bold](chosen)[/bold]" if alt.get("chosen") else ""
alt_lines.append(f"{alt['index']}. {alt['description']}{suffix}")
console.print(
Panel(
"\n".join(alt_lines) if alt_lines else "(none)",
title="Alternatives Considered",
expand=False,
)
)
# --- Impact panel ---
impact = data.get("impact", {})
assert isinstance(impact, dict)
console.print(
Panel(
f"[bold]Downstream Decisions:[/bold] "
f"{impact.get('downstream_decisions', 0)}\n"
f"[bold]Downstream Child Plans:[/bold] "
f"{impact.get('downstream_child_plans', 0)}\n"
f"[bold]Artifacts Produced:[/bold] "
f"{impact.get('artifacts_produced', 0)}\n"
f"[bold]Correction Impact:[/bold] "
f"{impact.get('correction_impact', 'low')}",
title="Impact",
expand=False,
)
)
# --- Context Snapshot panel (optional) ---
if show_context and "context_snapshot" in data:
snap = data["context_snapshot"]
assert isinstance(snap, dict)
ctx_lines: list[str] = []
for res in snap.get("relevant_resources", []):
assert isinstance(res, dict)
ctx_lines.append(f"- {res.get('path', '')}")
ctx_lines.append(
f"[bold]Hot Context Hash:[/bold] {snap.get('hot_context_hash', '')}"
)
console.print(
Panel(
"\n".join(ctx_lines),
title="Context Snapshot",
expand=False,
)
)
# --- Rationale panel (optional) ---
if show_reasoning:
rationale = str(data.get("rationale", ""))
console.print(
Panel(
rationale or "(no rationale recorded)",
title="Rationale",
expand=False,
)
)
# --- Correction panel ---
console.print(
Panel(
str(data["correction_hint"]),
title="Correction",
expand=False,
)
)
# --- Success message ---
console.print("[green bold]✓ OK[/green bold] Decision explained")
# ---------------------------------------------------------------------------