fix(cli): display full session IDs in session list output #10971
@@ -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):
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user