diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bdbdcf9c..dd3b8e7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table + and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of + each session ULID. This made the output unusable for copy-paste into `session tell`, + `session show`, `session delete`, and `session export`, all of which require the full + 26-character identifier. The full ULID is now displayed in all output formats (Rich, plain, + JSON, YAML, table). + ### Security - **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544): diff --git a/docs/reference/session_cli.md b/docs/reference/session_cli.md index 12d2214b3..7b31c85be 100644 --- a/docs/reference/session_cli.md +++ b/docs/reference/session_cli.md @@ -72,8 +72,8 @@ The `rich` format renders a sessions table with columns: **ID**, **Name**, **Act | Field | Description | |-------|-------------| | Total | Number of sessions | -| Most Recent | Name or truncated ID of the most recently updated session | -| Oldest | Name or truncated ID of the oldest session | +| Most Recent | Name or full ULID of the most recently updated session | +| Oldest | Name or full ULID of the oldest session | | Total Messages | Sum of messages across all sessions | | Storage | Estimated storage used | @@ -85,7 +85,7 @@ Followed by a `✓ OK N sessions listed` success message. { "sessions": [ { - "id": "01HXYZ...", + "id": "01HXYZ4M1Q3F0R0E5HR8K5T8A", "name": "my-session", "actor": "openai/gpt-4", "messages": 5, diff --git a/docs/showcase/cli-tools/project-init-and-context-management.md b/docs/showcase/cli-tools/project-init-and-context-management.md index f96175812..d426b9712 100644 --- a/docs/showcase/cli-tools/project-init-and-context-management.md +++ b/docs/showcase/cli-tools/project-init-and-context-management.md @@ -296,13 +296,13 @@ $ python -m cleveragents session list ┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ ┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃ ┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ -│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │ +│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │ └──────────┴───────────┴────────┴──────────┴──────────────────┘ ╭────────────────────────────────── Summary ───────────────────────────────────╮ │ Total: 1 │ -│ Most Recent: 01KNKK4Q │ -│ Oldest: 01KNKK4Q │ +│ Most Recent: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ +│ Oldest: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ │ Total Messages: 0 │ │ Storage: 0 KB │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -311,7 +311,7 @@ $ python -m cleveragents session list ``` **What's Happening:** -The session list shows all sessions with their truncated ID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions. +The session list shows all sessions with their full ULID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions. --- @@ -339,8 +339,8 @@ $ python -m cleveragents session list --format json ], "summary": { "total": 1, - "most_recent": "01KNKK4Q", - "oldest": "01KNKK4Q", + "most_recent": "01KNKK4Q9GZ0TRR5B0NEJYGMWH", + "oldest": "01KNKK4Q9GZ0TRR5B0NEJYGMWH", "total_messages": 0, "storage": "0 KB" } @@ -469,7 +469,7 @@ $ python -m cleveragents session list ┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ ┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃ ┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ -│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │ +│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │ └──────────┴───────────┴────────┴──────────┴──────────────────┘ ✓ OK 1 sessions listed ``` diff --git a/docs/showcase/cli-tools/session-management-workflows.md b/docs/showcase/cli-tools/session-management-workflows.md index 2cf15cb66..d9236ad73 100644 --- a/docs/showcase/cli-tools/session-management-workflows.md +++ b/docs/showcase/cli-tools/session-management-workflows.md @@ -180,14 +180,14 @@ $ python -m cleveragents session list ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ 01HXYZ4M │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │ -│ 01HXYZ3K │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │ +│ 01HXYZ4M1Q3F0R0E5HR8K5T8A │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │ +│ 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │ └──────────┴────────────┴────────────────┴──────────┴──────────────────┘ ╭──────────────────────────────────────────── Summary ─────────────────────────────────────────────╮ │ Total: 2 │ -│ Most Recent: 01HXYZ4M │ -│ Oldest: 01HXYZ3K │ +│ Most Recent: 01HXYZ4M1Q3F0R0E5HR8K5T8A │ +│ Oldest: 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │ │ Total Messages: 3 │ │ Storage: 0 KB │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -196,8 +196,8 @@ $ python -m cleveragents session list ``` **What's Happening:** -The list command renders a Rich table with five columns: truncated ID (first -8 characters for readability), optional name, bound actor, message count, and +The list command renders a Rich table with five columns: the full session +ULID (26 characters), optional name, bound actor, message count, and last update time. The **Summary** panel below shows aggregate statistics including total sessions, most recent, oldest, total message count, and storage used. @@ -229,8 +229,8 @@ $ python -m cleveragents session list --format json ], "summary": { "total": 2, - "most_recent": "01HXYZ4M", - "oldest": "01HXYZ3K", + "most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A", + "oldest": "01HXYZ3K9P2E9Q9D4GQ7J4S7Z", "total_messages": 3, "storage": "0 KB" } @@ -773,7 +773,7 @@ $ python -m cleveragents session list ✓ OK 2 sessions listed $ python -m cleveragents session list --format json -{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M", ...}} +{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A", ...}} $ python -m cleveragents session tell --session 01HXYZ4M1Q3F0R0E5HR8K5T8A "What is the capital of France?" user: What is the capital of France? diff --git a/docs/specification.md b/docs/specification.md index e8c02523f..b75c2daaa 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -1723,8 +1723,8 @@ None. ╭─ Sessions ───────────────────────────────────────────────────────────────────╮ │ ID Name Actor Messages Updated │ │ ──────── ─────────────── ────────────────── ──────── ──────────────── │ - │ 01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44 │ - │ 01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11 │ + │ 01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44 │ + │ 01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Summary ────────────────────╮ @@ -1746,8 +1746,8 @@ None. Sessions ID Name Actor Messages Updated -------- --------------- ------------------ -------- ---------------- - 01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44 - 01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11 + 01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44 + 01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11 Summary Total: 2 @@ -1769,14 +1769,14 @@ None. "data": { "sessions": [ { - "id": "01HXM2A6", + "id": "01HXM2A61MQHZ4MRBAY3MPNJTN", "name": "weekly-planning", "actor": "local/orchestrator", "messages": 6, "updated": "2026-02-08T12:44:00Z" }, { - "id": "01HXM1F2", + "id": "01HXM1F21MQHZ4MRBAY3MPNJTN", "name": "refactor-sprint", "actor": "local/orchestrator", "messages": 14, @@ -1804,12 +1804,12 @@ None. exit_code: 0 data: sessions: - - id: 01HXM2A6 + - id: 01HXM2A61MQHZ4MRBAY3MPNJTN name: weekly-planning actor: local/orchestrator messages: 6 updated: "2026-02-08T12:44:00Z" - - id: 01HXM1F2 + - id: 01HXM1F21MQHZ4MRBAY3MPNJTN name: refactor-sprint actor: local/orchestrator messages: 14 diff --git a/features/session_cli.feature b/features/session_cli.feature index b363e03ea..215771676 100644 --- a/features/session_cli.feature +++ b/features/session_cli.feature @@ -44,6 +44,28 @@ Feature: Session CLI commands When I run session CLI list with --format json Then the session CLI JSON list entries should match the documented contract + Scenario: List sessions displays full 26-character ULIDs in Rich table + Given there are mocked existing sessions + When I run session CLI list + Then the session CLI rich table should display full session ULIDs + + Scenario: List sessions summary panel shows full ULIDs for unnamed sessions + Given there are mocked existing sessions + When I run session CLI list + Then the session CLI summary panel should contain full session ULIDs + + Scenario: List sessions summary panel shows session names for named sessions + Given there are mocked existing named sessions + When I run session CLI list + Then the session CLI summary panel should show session names + + Scenario: Full session ID from list output works with session tell + Given there are mocked existing sessions + When I run session CLI list + And I capture the first session full ULID from the output + And I run session CLI tell with the full session ID and prompt "Hello from list" + Then the session CLI tell should succeed + # Show command tests Scenario: Show session with valid ID Given there is a mocked session with messages diff --git a/features/steps/session_cli_steps.py b/features/steps/session_cli_steps.py index 779643ffb..4c33d7e9f 100644 --- a/features/steps/session_cli_steps.py +++ b/features/steps/session_cli_steps.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import re import tempfile from datetime import datetime from typing import Any @@ -160,14 +161,34 @@ def step_existing_sessions(context: Context) -> None: context.mock_service.list.return_value = sessions +@given("there are mocked existing named sessions") +def step_existing_named_sessions(context: Context) -> None: + """Set up mocked sessions with names for Summary panel name-display test.""" + sessions = [ + _make_session( + session_id=_SESSION_ID, + actor_name="openai/gpt-4", + messages=[_make_message(sequence=0)], + ), + _make_session(session_id=_SESSION_ID_2), + ] + sessions[0].name = "weekly-planning" + sessions[1].name = "refactor-sprint" + context.mock_service.list.return_value = sessions + + @when("I run session CLI list") def step_list(context: Context) -> None: - context.result = context.runner.invoke(session_app, ["list"]) + context.result = context.runner.invoke( + session_app, ["list"], env={"COLUMNS": "200"} + ) @when("I run session CLI list with --format json") def step_list_json(context: Context) -> None: - context.result = context.runner.invoke(session_app, ["list", "--format", "json"]) + context.result = context.runner.invoke( + session_app, ["list", "--format", "json"], env={"COLUMNS": "200"} + ) @then("the session CLI should show all sessions in a table") @@ -176,6 +197,125 @@ def step_list_shows_table(context: Context) -> None: assert "Sessions" in context.result.output +@then("the session CLI rich table should display full session ULIDs") +def step_list_rich_table_full_ulids(context: Context) -> None: + """Verify the Rich table displays the full 26-character session ULIDs.""" + assert context.result.exit_code == 0 + output = context.result.output + # Restrict assertion to the table region (before the Summary panel) so + # that a regression back to 8-char truncation is not masked by the full + # ULID appearing elsewhere (e.g. in the Summary panel). + summary_idx = output.find("Summary") + table_output = output[:summary_idx] if summary_idx != -1 else output + assert _SESSION_ID in table_output, ( + f"Full ULID {_SESSION_ID} not found in Rich table output:\n" + f"{context.result.output}" + ) + assert _SESSION_ID_2 in table_output, ( + f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n" + f"{context.result.output}" + ) + # Negative guard: ensure the table contains full 26-character ULIDs, + # not the old 8-character truncated form. A standalone 8-char prefix + # (not as part of the full ULID) would indicate the [:8] slice was not + # removed. + table_without_full_ids = table_output.replace(_SESSION_ID, "").replace( + _SESSION_ID_2, "" + ) + assert _SESSION_ID[:8] not in table_without_full_ids, ( + f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n" + f"{context.result.output}" + ) + assert _SESSION_ID_2[:8] not in table_without_full_ids, ( + f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n" + f"{context.result.output}" + ) + + +@then("the session CLI summary panel should contain full session ULIDs") +def step_list_summary_full_ulids(context: Context) -> None: + """Verify the Summary panel shows full ULIDs for unnamed sessions.""" + assert context.result.exit_code == 0 + output = context.result.output + assert "Summary" in output, f"Summary panel not found in output:\n{output}" + # Extract the Summary panel region to avoid a false pass from the Rich + # table also containing the same full ULIDs. + summary_idx = output.find("Summary") + summary_output = output[summary_idx:] + # Both Most Recent and Oldest entries should display full ULIDs when + # sessions are unnamed. Asserting only one ID would allow a regression + # that re-introduced [:8] on one fallback path while keeping the other + # intact to pass the test undetected. + assert _SESSION_ID in summary_output, ( + f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}" + ) + assert _SESSION_ID_2 in summary_output, ( + f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}" + ) + + +@then("the session CLI summary panel should show session names") +def step_list_summary_shows_names(context: Context) -> None: + """Verify the Summary panel shows session names (not ULIDs) for named sessions.""" + assert context.result.exit_code == 0 + output = context.result.output + summary_idx = output.find("Summary") + assert summary_idx != -1, f"Summary panel not found in output:\n{output}" + summary_output = output[summary_idx:] + assert "weekly-planning" in summary_output, ( + f"'weekly-planning' not found in Summary panel:\n{output}" + ) + assert "refactor-sprint" in summary_output, ( + f"'refactor-sprint' not found in Summary panel:\n{output}" + ) + # The Summary should NOT show the raw ULID when session names are present + assert _SESSION_ID not in summary_output, ( + f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}" + ) + assert _SESSION_ID_2 not in summary_output, ( + f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}" + ) + + +@when("I capture the first session full ULID from the output") +def step_capture_first_ulid(context: Context) -> None: + """Parse the first session's full ULID from the output and store it + for subsequent steps (round-trip tell test).""" + assert context.result.exit_code == 0 + output = context.result.output + # Restrict the search to the table region (before the Summary panel) + # so that a ULID appearing in the Summary panel is not accidentally + # captured as the "first" session ID. + summary_idx = output.find("Summary") + search_region = output[:summary_idx] if summary_idx != -1 else output + match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region) + assert match is not None, ( + f"No 26-character ULID found in table output:\n{search_region}" + ) + context.full_session_id = match.group() + assert len(context.full_session_id) == 26, ( + f"Parsed ULID '{context.full_session_id}' is not 26 characters" + ) + # Sanity check: the captured ID should match one of the known fixture IDs + assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), ( + f"Captured ULID '{context.full_session_id}' does not match any fixture ID" + ) + + +@when('I run session CLI tell with the full session ID and prompt "{prompt}"') +def step_tell_with_stored_ulid(context: Context, prompt: str) -> None: + """Run session tell using the full ULID stored from session list.""" + session_id = context.full_session_id + context.mock_service.append_message.side_effect = [ + _make_message(MessageRole.USER, prompt, 0), + _make_message(MessageRole.ASSISTANT, f"Acknowledged: {prompt}", 1), + ] + context.result = context.runner.invoke( + session_app, + ["tell", "--session", session_id, prompt], + ) + + # --------------------------------------------------------------------------- # Show # --------------------------------------------------------------------------- diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 60e4e591d..30202e6da 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -151,8 +151,8 @@ def _session_list_dict(sessions: list[Session]) -> dict[str, Any]: # Find most recent and oldest sessions if sessions: sorted_sessions = sorted(sessions, key=lambda x: x.updated_at, reverse=True) - most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id[:8] - oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id[:8] + most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id + oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id else: most_recent = None oldest = None @@ -347,7 +347,7 @@ def list_sessions( for s in sessions: table.add_row( - s.session_id[:8], # Truncate ID for readability + s.session_id, # Full ULID for copy-paste compatibility with session tell s.name or "(unnamed)", s.actor_name or "(none)", str(s.message_count),