fix(cli): display full session IDs in session list output #10971

Merged
hurui200320 merged 1 commits from bugfix/m3-session-list-truncated-id into master 2026-05-05 10:56:19 +00:00
8 changed files with 203 additions and 32 deletions
+9
View File
@@ -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):
+3 -3
View File
@@ -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,
@@ -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
```
@@ -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?
+8 -8
View File
@@ -1723,8 +1723,8 @@ None.
╭─ Sessions ───────────────────────────────────────────────────────────────────╮
│ <span style="color: cyan; font-weight: 600;">ID</span> <span style="color: cyan; font-weight: 600;">Name</span> <span style="color: cyan; font-weight: 600;">Actor</span> <span style="color: cyan; font-weight: 600;">Messages</span> <span style="color: cyan; font-weight: 600;">Updated</span> │
│ <span style="opacity: 0.7;">──────── ─────────────── ────────────────── ──────── ────────────────</span> │
│ 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
+22
View File
@@ -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
+142 -2
View File
@@ -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
# ---------------------------------------------------------------------------
+3 -3
View File
@@ -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),