fix(cli): add Progress, Timing, Execution Detail, and Cost panels to agents plan status output

Restore all five required Rich output panels to _print_lifecycle_plan():
- Plan Status panel: Processing State, Projects, Arguments, Automation Profile,
  actors (Strategy/Execution/Estimation/Invariant), Execution Environment,
  Created/Updated timestamps, Description, Definition of Done, DoD evaluation,
  Invariants, resume metadata, multi-project scopes, error message
- Progress panel: Strategize/Execute/Apply step indicators
- Timing panel: Started, Elapsed, ETA (using estimation_result when available),
  and all phase timestamps (Strategize Started/Completed, Execute Started/Completed,
  Applied At)
- Execution Detail panel: Sandbox, Tool Calls (N/A), Files Modified (N/A),
  Child Plans, Checkpoints
- Cost panel: Tokens Used, Cost So Far, Estimated Total Cost
- Footer: ✓ OK Status refreshed

Also fixes:
- tool_calls semantic bug: display N/A instead of total_tokens
- files_modified: display N/A (not available in cost_metadata)
- ETA calculation: use estimation_result.estimated_time_seconds or N/A
- In-function import: moved Plan as LifecyclePlan to top of file
- Import sorting: split aliased import per ruff isort rules

Adds BDD scenarios for all five panels in plan_lifecycle_cli_coverage.feature
with step definitions in plan_lifecycle_cli_coverage_steps.py.

Updates CHANGELOG.md with user-facing output changes.

ISSUES CLOSED: #9341
This commit is contained in:
2026-05-05 17:15:15 +00:00
committed by drew
parent 159ae7b149
commit 2165e82d72
3 changed files with 413 additions and 43 deletions
@@ -162,3 +162,87 @@ Feature: Plan lifecycle CLI coverage
Scenario: Plan execute shares lifecycle service instance with executor
When I run plan execute verifying lifecycle service sharing
Then the plan executor should receive the same lifecycle service instance
# ---- _print_lifecycle_plan five-panel output ----
Scenario: Plan status renders Plan Status panel with required fields
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Plan Status"
And the plan lifecycle output should contain "Processing State"
And the plan lifecycle output should contain "Projects"
And the plan lifecycle output should contain "Action"
And the plan lifecycle output should contain "Phase"
And the plan lifecycle output should contain "Attempt"
Scenario: Plan status renders Progress panel
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Progress"
And the plan lifecycle output should contain "Strategize"
And the plan lifecycle output should contain "Execute"
And the plan lifecycle output should contain "Apply"
Scenario: Plan status renders Timing panel
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Timing"
And the plan lifecycle output should contain "Started"
And the plan lifecycle output should contain "Elapsed"
And the plan lifecycle output should contain "ETA"
Scenario: Plan status renders Execution Detail panel
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Execution Detail"
And the plan lifecycle output should contain "Sandbox"
And the plan lifecycle output should contain "Tool Calls"
And the plan lifecycle output should contain "Files Modified"
And the plan lifecycle output should contain "Child Plans"
And the plan lifecycle output should contain "Checkpoints"
Scenario: Plan status renders Cost panel
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Cost"
And the plan lifecycle output should contain "Tokens Used"
And the plan lifecycle output should contain "Cost So Far"
And the plan lifecycle output should contain "Estimated"
Scenario: Plan status renders status refresh footer
When I run plan status for a plan with all five panels
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Status refreshed"
Scenario: Plan status renders Arguments when plan has arguments
When I run plan status for a plan with arguments
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Arguments"
And the plan lifecycle output should contain "coverage"
Scenario: Plan status renders Automation Profile when set
When I run plan status for a plan with automation profile
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Automation Profile"
And the plan lifecycle output should contain "trusted"
Scenario: Plan status renders Estimation Actor when set
When I run plan status for a plan with estimation actor
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Estimation Actor"
And the plan lifecycle output should contain "openai/gpt-4"
Scenario: Plan status renders Invariant Actor when set
When I run plan status for a plan with invariant actor
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Invariant Actor"
And the plan lifecycle output should contain "openai/gpt-4"
Scenario: Plan status renders phase timestamps when populated
When I run plan status for a plan with all timestamps
Then the plan lifecycle command should succeed
And the plan lifecycle output should contain "Strategize Started"
And the plan lifecycle output should contain "Strategize Completed"
And the plan lifecycle output should contain "Execute Started"
And the plan lifecycle output should contain "Execute Completed"
And the plan lifecycle output should contain "Applied At"
@@ -0,0 +1,140 @@
"""Step definitions for plan_lifecycle_cli_coverage.feature.
Covers the five-panel output of _print_lifecycle_plan():
- Plan Status panel (Processing State, Projects, Action, Phase, Attempt)
- Progress panel (Strategize, Execute, Apply)
- Timing panel (Started, Elapsed, ETA, phase timestamps)
- Execution Detail panel (Sandbox, Tool Calls, Files Modified, Child Plans,
Checkpoints)
- Cost panel (Tokens Used, Cost So Far, Estimated)
- Status refresh footer
- Optional fields: Arguments, Automation Profile, Estimation Actor,
Invariant Actor
"""
from __future__ import annotations
from datetime import datetime, timedelta
from behave import when
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FBF"
def _make_five_panel_plan(
*,
arguments: dict[str, object] | None = None,
arguments_order: list[str] | None = None,
automation_profile: AutomationProfileRef | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
) -> Plan:
"""Build a Plan with all fields needed for five-panel output testing."""
now = datetime.now()
if timestamps is None:
timestamps = PlanTimestamps(
created_at=now - timedelta(hours=1),
updated_at=now,
)
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/five-panel-plan"),
action_name="local/test-action",
description="Five panel test plan",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
project_links=[
ProjectLink(project_name="proj-a"),
ProjectLink(project_name="proj-b"),
],
arguments=dict(arguments) if arguments else {},
arguments_order=arguments_order or [],
automation_profile=automation_profile,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
timestamps=timestamps,
)
@when("I run plan status for a plan with all five panels")
def step_plan_status_five_panels(context) -> None:
"""Run plan status for a plan that exercises all five output panels."""
plan = _make_five_panel_plan()
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
@when("I run plan status for a plan with arguments")
def step_plan_status_with_arguments(context) -> None:
"""Run plan status for a plan that has arguments."""
plan = _make_five_panel_plan(
arguments={"coverage": 80, "framework": "behave"},
arguments_order=["coverage", "framework"],
)
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
@when("I run plan status for a plan with automation profile")
def step_plan_status_with_automation_profile(context) -> None:
"""Run plan status for a plan that has an automation profile."""
plan = _make_five_panel_plan(
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
)
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
@when("I run plan status for a plan with estimation actor")
def step_plan_status_with_estimation_actor(context) -> None:
"""Run plan status for a plan that has an estimation actor."""
plan = _make_five_panel_plan(estimation_actor="openai/gpt-4")
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
@when("I run plan status for a plan with invariant actor")
def step_plan_status_with_invariant_actor(context) -> None:
"""Run plan status for a plan that has an invariant actor."""
plan = _make_five_panel_plan(invariant_actor="openai/gpt-4")
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
@when("I run plan status for a plan with all timestamps")
def step_plan_status_with_all_timestamps(context) -> None:
"""Run plan status for a plan that has all phase timestamps populated."""
now = datetime.now()
timestamps = PlanTimestamps(
created_at=now - timedelta(hours=2),
updated_at=now,
strategize_started_at=now - timedelta(hours=1, minutes=50),
strategize_completed_at=now - timedelta(hours=1, minutes=40),
execute_started_at=now - timedelta(hours=1, minutes=30),
execute_completed_at=now - timedelta(hours=1),
applied_at=now - timedelta(minutes=30),
)
plan = _make_five_panel_plan(
timestamps=timestamps,
)
context.lifecycle_service.get_plan.return_value = plan
context.result = context.runner.invoke(plan_app, ["status", _PLAN_ULID])
+189 -43
View File
@@ -53,7 +53,12 @@ from cleveragents.domain.models.core.error_recovery import (
ErrorCategory,
classify_error,
)
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
from cleveragents.domain.models.core.plan import (
ExecutionEnvPriority,
PlanPhase,
ProcessingState,
)
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
from cleveragents.infrastructure.sandbox.git_worktree import (
GitWorktreeSandbox,
)
@@ -346,8 +351,6 @@ def _execute_output_dict(
Returns:
A JSON-serialisable dict matching the spec-required envelope.
"""
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
if not isinstance(plan, LifecyclePlan):
# Legacy plan fallback — return minimal envelope
return {
@@ -1420,20 +1423,18 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
"""Print v3 lifecycle plan details in multiple panels.
Renders five panels per specification:
1. Plan Status - basic plan info
1. Plan Status - basic plan info (ID, phase, processing state, action,
projects, arguments, automation profile, actors, created/updated)
2. Progress - Strategize/Execute/Apply step progress
3. Timing - Started, Elapsed, ETA
4. Execution Detail - Sandbox, Tool Calls, Files Modified, Child Plans, Checkpoints
3. Timing - Started, Elapsed, ETA, and all phase timestamps
4. Execution Detail - Sandbox, Tool Calls, Files Modified, Child Plans,
Checkpoints
5. Cost - Tokens Used, Cost So Far, Estimated
Args:
plan: A v3 Plan object from plan.py (not plan_legacy.py)
title: Panel title (used for Plan Status panel)
"""
from cleveragents.domain.models.core.plan import (
Plan as LifecyclePlan,
)
if not isinstance(plan, LifecyclePlan):
# Fall back to legacy plan display - this shouldn't happen
console.print(Panel(f"Plan: {plan}", title=title, expand=False))
@@ -1452,24 +1453,149 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
project_parts.append(label)
projects = ", ".join(project_parts) if project_parts else "(none)"
# Get automation profile name
automation_name = (
plan.automation_profile.profile_name
if plan.automation_profile
else "(none)"
)
# ===== PANEL 1: Plan Status =====
plan_status_details = (
f"[cyan bold]Plan:[/cyan bold] {plan.identity.plan_id}\n"
f"[yellow bold]Phase:[/yellow bold] {plan.phase.value}\n"
f"[magenta bold]State:[/magenta bold] {state_display}\n"
f"[magenta bold]Processing State:[/magenta bold] {state_display}\n"
f"[#5599ff bold]Action:[/#5599ff bold] {plan.action_name}\n"
f"[#66cc66 bold]Project:[/#66cc66 bold] {projects}\n"
f"[#5599ff bold]Automation:[/#5599ff bold] {automation_name}\n"
f"[#66cc66 bold]Projects:[/#66cc66 bold] {projects}\n"
f"[#5599ff bold]Attempt:[/#5599ff bold] {plan.identity.attempt}"
)
console.print(Panel(plan_status_details, title="Plan Status", expand=False))
# Arguments (ordered)
if plan.arguments:
ordered_keys = (
plan.arguments_order
if plan.arguments_order
else sorted(plan.arguments.keys())
)
args_lines = "\n".join(
f" {key} = {plan.arguments[key]}"
for key in ordered_keys
if key in plan.arguments
)
plan_status_details += f"\n[bold]Arguments:[/bold]\n{args_lines}"
# Automation profile info
if plan.automation_profile:
plan_status_details += (
f"\n[#5599ff bold]Automation Profile:[/#5599ff bold] "
f"{plan.automation_profile.profile_name} "
f"(source: {plan.automation_profile.provenance.value})"
)
# Optional actors
if plan.strategy_actor:
plan_status_details += f"\n[bold]Strategy Actor:[/bold] {plan.strategy_actor}"
if plan.execution_actor:
plan_status_details += f"\n[bold]Execution Actor:[/bold] {plan.execution_actor}"
if plan.estimation_actor:
plan_status_details += (
f"\n[bold]Estimation Actor:[/bold] {plan.estimation_actor}"
)
if plan.invariant_actor:
plan_status_details += f"\n[bold]Invariant Actor:[/bold] {plan.invariant_actor}"
# Execution environment
if plan.execution_environment:
plan_status_details += (
f"\n[bold]Execution Environment:[/bold] {plan.execution_environment}"
)
priority_display = (
plan.execution_env_priority.value
if plan.execution_env_priority is not None
else ExecutionEnvPriority.FALLBACK.value
)
plan_status_details += (
f"\n[bold]Execution Env Priority:[/bold] {priority_display}"
)
# Timestamps: Created and Updated always shown
plan_status_details += (
f"\n[bold]Created:[/bold] {plan.timestamps.created_at}"
f"\n[bold]Updated:[/bold] {plan.timestamps.updated_at}"
)
# Description (truncated to 200 chars)
if plan.description:
description_preview = plan.description[:200]
if len(plan.description) > 200:
description_preview = f"{description_preview}..."
plan_status_details += f"\n[bold]Description:[/bold]\n {description_preview}"
# Definition-of-Done
if plan.definition_of_done:
dod_preview = plan.definition_of_done[:200]
if len(plan.definition_of_done) > 200:
dod_preview += "..."
plan_status_details += f"\n[bold]Definition of Done:[/bold]\n {dod_preview}"
# DoD evaluation summary
if plan.validation_summary and plan.validation_summary.get("dod_evaluated"):
vs = plan.validation_summary
dod_passed = vs.get("dod_all_passed", False)
req_pass = vs.get("required_passed", 0)
req_fail = vs.get("required_failed", 0)
dod_total = req_pass + req_fail
status_color = "green" if dod_passed else "red"
status_label = "PASSED" if dod_passed else "FAILED"
plan_status_details += (
f"\n[bold]DoD Evaluation:[/bold] "
f"[{status_color}]{status_label}[/{status_color}]"
f" ({req_pass}/{dod_total} passed"
f"{f', {req_fail} failed' if req_fail else ''})"
)
# Invariants
if plan.invariants:
plan_status_details += "\n[bold]Invariants:[/bold]"
for inv in plan.invariants:
plan_status_details += f"\n [{inv.source.value}] {inv.text}"
# Resume metadata
if plan.last_completed_step >= 0:
plan_status_details += (
f"\n[bold]Last Completed Step:[/bold] {plan.last_completed_step}"
)
if plan.last_checkpoint_id:
plan_status_details += (
f"\n[bold]Last Checkpoint:[/bold] {plan.last_checkpoint_id}"
)
# Multi-project changeset summaries
if (
plan.multi_project_metadata is not None
and plan.multi_project_metadata.project_scopes
):
plan_status_details += "\n[bold]Multi-Project Scopes:[/bold]"
for scope in plan.multi_project_metadata.project_scopes:
label = scope.project_name
if scope.alias:
label += f" (alias: {scope.alias})"
if scope.read_only:
label += " [read-only]"
plan_status_details += f"\n {label}"
if scope.changeset_summary is not None:
cs = scope.changeset_summary
plan_status_details += (
f"\n Changed: {cs.files_changed} "
f"Added: {cs.files_added} "
f"Deleted: {cs.files_deleted} "
f"Lines: {cs.total_lines_changed}"
)
if cs.validation_passed is not None:
v_color = "green" if cs.validation_passed else "red"
v_label = "PASSED" if cs.validation_passed else "FAILED"
plan_status_details += (
f" Validation: [{v_color}]{v_label}[/{v_color}]"
)
# Error message
if plan.error_message:
plan_status_details += f"\n[bold red]Error:[/bold red] {plan.error_message}"
console.print(Panel(plan_status_details, title=title, expand=False))
# ===== PANEL 2: Progress =====
# Determine status of each phase based on current phase and state
@@ -1551,26 +1677,48 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
seconds = int(elapsed_seconds % 60)
elapsed_time = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
# Calculate ETA (rough estimate based on phase)
# For demo purposes, estimate remaining time
if plan.phase == PlanPhase.STRATEGIZE:
estimated_total_seconds = elapsed_seconds * 2 # Assume 2x current time
elif plan.phase == PlanPhase.EXECUTE:
estimated_total_seconds = elapsed_seconds * 1.5
# Calculate ETA using estimation result when available
if (
plan.estimation_result is not None
and plan.estimation_result.estimated_time_seconds is not None
):
estimated_total_seconds = plan.estimation_result.estimated_time_seconds
remaining_seconds = max(0, estimated_total_seconds - elapsed_seconds)
eta_hours = int(remaining_seconds // 3600)
eta_minutes = int((remaining_seconds % 3600) // 60)
eta_secs = int(remaining_seconds % 60)
eta_time = f"{eta_hours:02d}:{eta_minutes:02d}:{eta_secs:02d}"
else:
estimated_total_seconds = elapsed_seconds
remaining_seconds = max(0, estimated_total_seconds - elapsed_seconds)
eta_hours = int(remaining_seconds // 3600)
eta_minutes = int((remaining_seconds % 3600) // 60)
eta_secs = int(remaining_seconds % 60)
eta_time = f"{eta_hours:02d}:{eta_minutes:02d}:{eta_secs:02d}"
eta_time = "N/A"
timing_details = (
f"[#66cc66 bold]Started:[/#66cc66 bold] {started_time}\n"
f"[yellow bold]Elapsed:[/yellow bold] {elapsed_time}\n"
f"[#66cc66 bold]ETA:[/#66cc66 bold] {eta_time}"
)
# Optional phase timestamps
if plan.timestamps.strategize_started_at:
timing_details += (
f"\n[bold]Strategize Started:[/bold] "
f"{plan.timestamps.strategize_started_at}"
)
if plan.timestamps.strategize_completed_at:
timing_details += (
f"\n[bold]Strategize Completed:[/bold] "
f"{plan.timestamps.strategize_completed_at}"
)
if plan.timestamps.execute_started_at:
timing_details += (
f"\n[bold]Execute Started:[/bold] {plan.timestamps.execute_started_at}"
)
if plan.timestamps.execute_completed_at:
timing_details += (
f"\n[bold]Execute Completed:[/bold] {plan.timestamps.execute_completed_at}"
)
if plan.timestamps.applied_at:
timing_details += f"\n[bold]Applied At:[/bold] {plan.timestamps.applied_at}"
console.print(Panel(timing_details, title="Timing", expand=False))
# ===== PANEL 4: Execution Detail =====
@@ -1581,17 +1729,15 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
if len(plan.sandbox_refs) > 3:
sandbox_strategy += f" (+{len(plan.sandbox_refs) - 3} more)"
tool_calls = 0
files_modified = 0
if plan.cost_metadata:
tool_calls = plan.cost_metadata.total_tokens or 0
files_modified = 0 # Not directly available in cost_metadata
# tool_calls is not tracked in CostMetadata; display N/A
tool_calls_display = "N/A"
# files_modified is not directly available; display N/A
files_modified_display = "N/A"
child_plans = "0/0 complete"
if plan.subplan_statuses:
completed = sum(
1 for sp in plan.subplan_statuses
if sp.completed_at is not None
1 for sp in plan.subplan_statuses if sp.completed_at is not None
)
child_plans = f"{completed}/{len(plan.subplan_statuses)} complete"
@@ -1601,8 +1747,8 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
execution_details = (
f"[#5599ff bold]Sandbox:[/#5599ff bold] {sandbox_strategy}\n"
f"[#5599ff bold]Tool Calls:[/#5599ff bold] {tool_calls}\n"
f"[#5599ff bold]Files Modified:[/#5599ff bold] {files_modified}\n"
f"[#5599ff bold]Tool Calls:[/#5599ff bold] {tool_calls_display}\n"
f"[#5599ff bold]Files Modified:[/#5599ff bold] {files_modified_display}\n"
f"[#5599ff bold]Child Plans:[/#5599ff bold] {child_plans}\n"
f"[#5599ff bold]Checkpoints:[/#5599ff bold] {checkpoints} created"
)