fix(cli): add Name and Updated columns to agents plan list table output #3347

Merged
freemo merged 1 commits from fix/plan-list-rich-output-columns into master 2026-04-05 21:11:40 +00:00
5 changed files with 106 additions and 0 deletions
+20
View File
@@ -141,3 +141,23 @@ Feature: Plan CLI spec alignment
When I run plan cancel with reason "Requirements changed"
Then the plan spec cancel should succeed
And the plan spec cancel output should contain "Requirements changed"
# ---- plan list: required column rendering ----
Scenario: Plan list rich output includes Name column
Given plan spec alignment plans exist
When I run plan list with no filters
Then the plan spec list should succeed
And the plan spec list output should contain "Name"
Scenario: Plan list rich output includes Updated column
Given plan spec alignment plans exist
When I run plan list with no filters
Then the plan spec list should succeed
Outdated
Review

[TEST] This scenario only verifies the column header ("Updat") is present, not that an actual formatted timestamp value appears in the output. Consider adding an assertion for the timestamp value (e.g., the current year-month, or use a fixed timestamp in the fixture and assert the exact formatted string).

**[TEST]** This scenario only verifies the column *header* (`"Updat"`) is present, not that an actual formatted timestamp value appears in the output. Consider adding an assertion for the timestamp value (e.g., the current year-month, or use a fixed timestamp in the fixture and assert the exact formatted string).
And the plan spec list output should contain "Updat"
And the plan spec list output should contain the current year-month timestamp
Scenario: Plan list rich output includes Invariants column
Given plan spec alignment plans exist
When I run plan list with no filters
Then the plan spec list should succeed
And the plan spec list output should contain "Invar"
@@ -350,6 +350,26 @@ def step_plan_list_combined(context: Context, phase: str, project: str) -> None:
)
@when("I run plan list with no filters")
def step_plan_list_no_filters(context: Context) -> None:
"""Run list with no filters to get all plans in rich output.
Forces the module-level Rich console to use a 200-column width so that
all table columns (including ``Updated``) are rendered without truncation
or being dropped. The console's ``_width`` attribute is restored after
the invocation.
"""
import cleveragents.cli.commands.plan as _plan_mod
wide_runner = CliRunner(mix_stderr=False)
original_width = _plan_mod.console._width
_plan_mod.console._width = 200
try:
context.result = wide_runner.invoke(plan_app, ["list"])
finally:
_plan_mod.console._width = original_width
# ---------------------------------------------------------------------------
# When steps -- plan status
# ---------------------------------------------------------------------------
@@ -496,3 +516,17 @@ def step_plan_cancel_contains(context: Context, text: str) -> None:
assert text in context.result.output, (
f"Expected '{text}' in cancel output but got:\n{context.result.output}"
)
@then("the plan spec list output should contain the current year-month timestamp")
def step_plan_list_output_contains_timestamp(context: Context) -> None:
"""Verify the list output contains a formatted timestamp value for the Updated column.
The test fixture creates plans with ``datetime.now()``, so the current
year-month prefix (e.g. ``"2026-04"``) must appear in the output.
"""
year_month = datetime.now().strftime("%Y-%m")
assert year_month in context.result.output, (
f"Expected current year-month '{year_month}' in list output "
f"(Updated column value) but got:\n{context.result.output}"
)
+35
View File
@@ -235,6 +235,40 @@ def list_filters() -> None:
sys.exit(1)
def list_columns() -> None:
"""Verify plan list rich output includes Name, Updated, and Invariants columns."""
mock_service = MagicMock()
mock_service.list_plans.return_value = [
_mock_plan(project_links=[ProjectLink(project_name="proj-a")]),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
# Use a wide terminal so column headers are not truncated
wide_runner = CliRunner(mix_stderr=False)
result = wide_runner.invoke(plan_app, ["list"], env={"COLUMNS": "200"})
if result.exit_code != 0:
print(f"FAIL: list returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
output = result.output
# Check for column headers (rich may truncate in narrow terminals,
# so we check for the first 5 chars which are always rendered)
required_columns = ["Name", "Updat", "Invar"]
missing = [col for col in required_columns if col not in output]
if missing:
print(f"FAIL: missing columns: {missing}", file=sys.stderr)
print(output, file=sys.stderr)
sys.exit(1)
# Verify the plan name appears in the output
if "local/smoke-plan" not in output and "local/smok" not in output:
print("FAIL: plan name not in output", file=sys.stderr)
print(output, file=sys.stderr)
sys.exit(1)
print("plan-cli-list-columns-ok")
def status_fields() -> None:
"""Verify plan status renders required fields."""
mock_service = MagicMock()
@@ -294,6 +328,7 @@ _COMMANDS = {
"use-invariant": use_invariant,
"use-actor-overrides": use_actor_overrides,
"list-filters": list_filters,
"list-columns": list_columns,
"status-fields": status_fields,
"cancel-reason": cancel_reason,
}
+8
View File
@@ -51,3 +51,11 @@ Plan Cancel Accepts Reason Flag
${result}= Run Process ${PYTHON} ${HELPER} cancel-reason cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-cli-cancel-reason-ok
Plan List Rich Output Includes Required Columns
[Documentation] Verify that ``plan list`` rich output includes Name, Updated, and Invariants columns
${result}= Run Process ${PYTHON} ${HELPER} list-columns cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan-cli-list-columns-ok
+9
View File
@@ -2461,6 +2461,7 @@ def lifecycle_list_plans(
table.add_column("Action", style="blue")
table.add_column("Invariants", style="dim")
table.add_column("Project", style="green")
table.add_column("Updated", style="dim")
table.add_column("Elapsed", style="dim")
for plan in plans:
@@ -2487,6 +2488,13 @@ def lifecycle_list_plans(
# Invariant count
invariant_count = str(len(plan.invariants)) if plan.invariants else "0"
# Last-updated timestamp.
Outdated
Review

[CODE-PATTERN] Dead None guard: PlanTimestamps.updated_at is typed as datetime (not Optional) with default_factory=datetime.now. The if updated_at else "" branch is unreachable dead code that misleads maintainers and violates the project's fail-fast principle.

Simplify to:

updated_str = plan.timestamps.updated_at.strftime("%Y-%m-%d %H:%M")

Also consider timezone normalization for consistency with the Elapsed column's timezone-aware handling above.

**[CODE-PATTERN]** Dead None guard: `PlanTimestamps.updated_at` is typed as `datetime` (not Optional) with `default_factory=datetime.now`. The `if updated_at else ""` branch is unreachable dead code that misleads maintainers and violates the project's fail-fast principle. Simplify to: ```python updated_str = plan.timestamps.updated_at.strftime("%Y-%m-%d %H:%M") ``` Also consider timezone normalization for consistency with the `Elapsed` column's timezone-aware handling above.
# PlanTimestamps.updated_at is typed as datetime (never None),
# so we call strftime directly. The value is stored as-is
# (naive local time by default); no timezone conversion is
# applied here, consistent with how created_at is stored.
updated_str = plan.timestamps.updated_at.strftime("%Y-%m-%d %H:%M")
table.add_row(
plan.identity.plan_id[:8],
str(plan.namespaced_name),
@@ -2495,6 +2503,7 @@ def lifecycle_list_plans(
plan.action_name,
invariant_count,
project_display,
updated_str,
elapsed_str,
)