fix(cli): add Name and Updated columns to agents plan list table output #3347
@@ -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
|
||||
|
|
||||
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}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
freemo
commented
[CODE-PATTERN] Dead None guard: Simplify to: Also consider timezone normalization for consistency with the **[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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
[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).