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),