feat(cli): add session commands

This commit is contained in:
2026-02-19 22:04:58 +00:00
parent 4243ac8cff
commit 04c8a886db
9 changed files with 1848 additions and 19 deletions
+156
View File
@@ -0,0 +1,156 @@
"""ASV benchmarks for Session CLI command parsing overhead.
Measures the performance of:
- Session create command parsing
- Session list command rendering
- Session show command rendering
- Session tell command parsing
"""
from __future__ import annotations
import importlib
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from typer.testing import CliRunner # noqa: E402
from ulid import ULID # noqa: E402
from cleveragents.cli.commands import session as session_mod # noqa: E402
from cleveragents.cli.commands.session import app as session_app # noqa: E402
from cleveragents.domain.models.core.session import ( # noqa: E402
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
_runner = CliRunner()
def _mock_session(
session_id: str | None = None,
actor_name: str | None = None,
messages: list[SessionMessage] | None = None,
) -> Session:
return Session(
session_id=session_id or str(ULID()),
actor_name=actor_name,
namespace="local",
messages=messages or [],
token_usage=SessionTokenUsage(),
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _mock_message(
role: MessageRole = MessageRole.USER,
content: str = "Hello",
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=str(ULID()),
role=role,
content=content,
sequence=sequence,
timestamp=datetime.now(),
)
class SessionCLICreateSuite:
"""Benchmark session create command throughput."""
def setup(self) -> None:
self._svc = MagicMock()
self._svc.create.return_value = _mock_session()
session_mod._service = self._svc
def teardown(self) -> None:
session_mod._service = None
def time_create_default(self) -> None:
"""Benchmark create with defaults."""
_runner.invoke(session_app, ["create"])
def time_create_with_actor(self) -> None:
"""Benchmark create with actor flag."""
_runner.invoke(session_app, ["create", "--actor", "openai/gpt-4"])
class SessionCLIListSuite:
"""Benchmark session list command throughput."""
def setup(self) -> None:
self._svc = MagicMock()
self._svc.list.return_value = [
_mock_session(actor_name=f"openai/gpt-{i}") for i in range(50)
]
session_mod._service = self._svc
def teardown(self) -> None:
session_mod._service = None
def time_list_all(self) -> None:
"""Benchmark listing all sessions."""
_runner.invoke(session_app, ["list"])
def time_list_json(self) -> None:
"""Benchmark listing with JSON format."""
_runner.invoke(session_app, ["list", "--format", "json"])
class SessionCLIShowSuite:
"""Benchmark session show command throughput."""
def setup(self) -> None:
self._sid = str(ULID())
self._svc = MagicMock()
self._svc.get.return_value = _mock_session(
session_id=self._sid,
messages=[
_mock_message(MessageRole.USER, "Hello", 0),
_mock_message(MessageRole.ASSISTANT, "Hi", 1),
],
)
session_mod._service = self._svc
def teardown(self) -> None:
session_mod._service = None
def time_show(self) -> None:
"""Benchmark show command."""
_runner.invoke(session_app, ["show", self._sid])
class SessionCLITellSuite:
"""Benchmark session tell command throughput."""
def setup(self) -> None:
self._sid = str(ULID())
self._svc = MagicMock()
self._svc.append_message.side_effect = lambda **kw: _mock_message(
role=kw.get("role", MessageRole.USER),
content=kw.get("content", "msg"),
sequence=0,
)
session_mod._service = self._svc
def teardown(self) -> None:
session_mod._service = None
def time_tell(self) -> None:
"""Benchmark tell command."""
_runner.invoke(session_app, ["tell", "--session", self._sid, "Hello world"])
+204
View File
@@ -0,0 +1,204 @@
# Session CLI Reference
The `agents session` command group manages **sessions** — persistent conversation threads tied to orchestrator actors.
## Commands
| Command | Description |
|---------|-------------|
| `agents session create` | Create a new interactive session |
| `agents session list` | List all sessions |
| `agents session show` | Show session details and recent messages |
| `agents session delete` | Delete a session permanently |
| `agents session export` | Export a session as JSON |
| `agents session import` | Import a session from JSON |
| `agents session tell` | Send a message to a session |
---
## `agents session create`
Create a new interactive session, optionally bound to an orchestrator actor.
```bash
agents session create [--actor <ACTOR>] [--format <FORMAT>]
```
### Options
| Flag | Description |
|------|-------------|
| `--actor` | Orchestrator actor name in `namespace/name` format |
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` (default: `rich`) |
### Output
Returns session info including `session_id`, `actor`, `created`, and `namespace`.
### Example
```bash
agents session create --actor openai/gpt-4
agents session create --format json
```
---
## `agents session list`
List all sessions with message counts and timestamps.
```bash
agents session list [--format <FORMAT>]
```
### Options
| Flag | Description |
|------|-------------|
| `--format`, `-f` | Output format (default: `rich`) |
### JSON Output Shape
```json
{
"sessions": [
{"id": "...", "actor": "...", "messages": 5, "updated": "..."}
],
"total": 1
}
```
---
## `agents session show`
Show session details including recent messages, linked plans, and token usage.
```bash
agents session show <SESSION_ID> [--format <FORMAT>]
```
### Arguments
| Argument | Description |
|----------|-------------|
| `SESSION_ID` | The ULID of the session to display |
### Options
| Flag | Description |
|------|-------------|
| `--format`, `-f` | Output format (default: `rich`) |
---
## `agents session delete`
Delete a session permanently. A confirmation prompt is shown unless `--yes` is provided.
```bash
agents session delete <SESSION_ID> [--yes]
```
### Arguments
| Argument | Description |
|----------|-------------|
| `SESSION_ID` | The ULID of the session to delete |
### Options
| Flag | Short | Description |
|------|-------|-------------|
| `--yes` | `-y` | Skip confirmation prompt |
---
## `agents session export`
Export a session as JSON to a file or stdout.
```bash
agents session export <SESSION_ID> [--output <FILE>] [--force]
```
### Arguments
| Argument | Description |
|----------|-------------|
| `SESSION_ID` | The ULID of the session to export |
### Options
| Flag | Short | Description |
|------|-------|-------------|
| `--output` | `-o` | Output file path (default: stdout) |
| `--force` | | Overwrite existing output file |
### Notes
- Parent directories are created automatically when writing to a file.
- Without `--force`, the command refuses to overwrite an existing file.
- The exported JSON includes a SHA-256 checksum for integrity validation.
---
## `agents session import`
Import a session from a JSON file previously exported with `session export`.
```bash
agents session import --input <FILE>
```
### Options
| Flag | Short | Description |
|------|-------|-------------|
| `--input` | `-i` | Input JSON file path (required) |
### Notes
- Validates schema version and SHA-256 checksum.
- Imported sessions receive fresh ULIDs to avoid ID collisions.
---
## `agents session tell`
Send a message to a session and receive an assistant response.
```bash
agents session tell --session <SESSION_ID> [--actor <ACTOR>] [--stream] <PROMPT>
```
### Arguments
| Argument | Description |
|----------|-------------|
| `PROMPT` | The message text to send |
### Options
| Flag | Description |
|------|-------------|
| `--session` | Target session ULID (required) |
| `--actor` | Override actor for this message |
| `--stream` | Stream the response in real-time |
### Notes
- In M3, actor execution is stubbed — the assistant echoes an acknowledgement.
- Both user and assistant messages are persisted with sequence ordering.
- The session's `updated_at` timestamp is refreshed after each message.
---
## Error Handling
| Error | Cause |
|-------|-------|
| `SessionNotFoundError` | The specified session ID does not exist |
| `SessionExportError` | Export operation failed |
| `SessionImportError` | Import failed due to bad schema or corrupt data |
+142
View File
@@ -0,0 +1,142 @@
Feature: Session CLI commands
As a developer
I want to manage sessions via CLI commands
So that I can create and use persistent conversation threads
Background:
Given a session CLI runner with mocked service
# Create command tests
Scenario: Create session with defaults
When I run session CLI create with no arguments
Then the session CLI create should succeed
And the session CLI output should contain "Session Created"
Scenario: Create session with custom actor
When I run session CLI create with --actor "openai/gpt-4"
Then the session CLI create should succeed
And the session CLI output should contain "openai/gpt-4"
Scenario: Create session with JSON format
When I run session CLI create with --format json
Then the session CLI create should succeed
And the session CLI output should be valid JSON
# List command tests
Scenario: List sessions when empty
Given there are no mocked sessions
When I run session CLI list
Then the session CLI output should contain "No sessions found"
Scenario: List sessions with populated data
Given there are mocked existing sessions
When I run session CLI list
Then the session CLI should show all sessions in a table
Scenario: List sessions with JSON format
Given there are mocked existing sessions
When I run session CLI list with --format json
Then the session CLI output should be valid JSON
And the session CLI JSON should contain "sessions"
# Show command tests
Scenario: Show session with valid ID
Given there is a mocked session with messages
When I run session CLI show with a valid session ID
Then the session CLI show should succeed
And the session CLI output should contain "Session Details"
Scenario: Show session with JSON format
Given there is a mocked session with messages
When I run session CLI show with --format json
Then the session CLI output should be valid JSON
Scenario: Show session with invalid ID
When I run session CLI show with an invalid session ID
Then the session CLI should exit with error
And the session CLI output should contain "Session not found"
# Delete command tests
Scenario: Delete session with --yes flag
Given there is a mocked session to delete
When I run session CLI delete with --yes
Then the session CLI delete should succeed
And the session CLI output should contain "deleted"
Scenario: Delete non-existent session
When I run session CLI delete with a non-existent ID
Then the session CLI should exit with error
And the session CLI output should contain "Session not found"
# Export command tests
Scenario: Export session to stdout
Given there is a mocked session for export
When I run session CLI export with no output file
Then the session CLI export should succeed
And the session CLI output should be valid JSON
Scenario: Export session to file
Given there is a mocked session for export
When I run session CLI export with --output to a temp file
Then the session CLI export should succeed
And the exported file should exist
Scenario: Export refuses overwrite without --force
Given there is a mocked session for export
And there is an existing export file
When I run session CLI export to an existing file without --force
Then the session CLI should exit with error
And the session CLI output should contain "File already exists"
Scenario: Export with --force overwrites file
Given there is a mocked session for export
And there is an existing export file
When I run session CLI export to an existing file with --force
Then the session CLI export should succeed
Scenario: Export non-existent session
When I run session CLI export with a non-existent session ID
Then the session CLI should exit with error
And the session CLI output should contain "Session not found"
# Import command tests
Scenario: Import session from valid file
Given there is a valid session export file
When I run session CLI import with the export file
Then the session CLI import should succeed
And the session CLI output should contain "Session Imported"
Scenario: Import from non-existent file
When I run session CLI import with a non-existent file
Then the session CLI should exit with error
And the session CLI output should contain "File not found"
Scenario: Import from corrupt file
Given there is a corrupt session export file
When I run session CLI import with the corrupt file
Then the session CLI should exit with error
And the session CLI output should contain "Import error"
Scenario: Import from invalid JSON file
Given there is an invalid JSON file
When I run session CLI import with the invalid JSON file
Then the session CLI should exit with error
And the session CLI output should contain "Invalid JSON"
# Tell command tests
Scenario: Tell appends message to session
Given there is a mocked session for tell
When I run session CLI tell with a prompt "Hello, world"
Then the session CLI tell should succeed
And the session CLI output should contain "Acknowledged"
Scenario: Tell with custom actor
Given there is a mocked session for tell
When I run session CLI tell with --actor "openai/gpt-4" and prompt "Plan a feature"
Then the session CLI tell should succeed
And the session CLI output should contain "openai/gpt-4"
Scenario: Tell to non-existent session
When I run session CLI tell to a non-existent session
Then the session CLI should exit with error
And the session CLI output should contain "Session not found"
+504
View File
@@ -0,0 +1,504 @@
"""Step definitions for the Session CLI feature."""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionImportError,
SessionMessage,
SessionNotFoundError,
SessionTokenUsage,
)
_SESSION_ID = str(ULID())
_SESSION_ID_2 = str(ULID())
def _make_session(
*,
session_id: str | None = None,
actor_name: str | None = None,
messages: list[SessionMessage] | None = None,
) -> Session:
"""Create a test Session instance."""
return Session(
session_id=session_id or str(ULID()),
actor_name=actor_name,
namespace="local",
messages=messages or [],
token_usage=SessionTokenUsage(
input_tokens=100,
output_tokens=50,
estimated_cost=0.005,
),
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _make_message(
role: MessageRole = MessageRole.USER,
content: str = "Hello",
sequence: int = 0,
) -> SessionMessage:
"""Create a test SessionMessage."""
return SessionMessage(
message_id=str(ULID()),
role=role,
content=content,
sequence=sequence,
timestamp=datetime.now(),
)
def _cleanup(context: Context) -> None:
"""Clean up temporary files."""
for path in getattr(context, "_cleanup_paths", []):
if os.path.exists(path):
os.unlink(path)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a session CLI runner with mocked service")
def step_session_cli_runner(context: Context) -> None:
"""Set up CLI runner with a mocked session service."""
context.runner = CliRunner()
context.mock_service = MagicMock()
context._cleanup_paths: list[str] = []
# Default: create returns a session
default_session = _make_session(session_id=_SESSION_ID)
context.mock_service.create.return_value = default_session
# Patch the module-level service
session_mod._service = context.mock_service
def cleanup() -> None:
session_mod._service = None
_cleanup(context)
context.add_cleanup(cleanup)
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
@when("I run session CLI create with no arguments")
def step_create_no_args(context: Context) -> None:
context.result = context.runner.invoke(session_app, ["create"])
@when('I run session CLI create with --actor "{actor}"')
def step_create_with_actor(context: Context, actor: str) -> None:
session = _make_session(session_id=_SESSION_ID, actor_name=actor)
context.mock_service.create.return_value = session
context.result = context.runner.invoke(session_app, ["create", "--actor", actor])
@when("I run session CLI create with --format json")
def step_create_json(context: Context) -> None:
context.result = context.runner.invoke(session_app, ["create", "--format", "json"])
@then("the session CLI create should succeed")
def step_create_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
@given("there are no mocked sessions")
def step_no_sessions(context: Context) -> None:
context.mock_service.list.return_value = []
@given("there are mocked existing sessions")
def step_existing_sessions(context: Context) -> None:
sessions = [
_make_session(
session_id=_SESSION_ID,
actor_name="openai/gpt-4",
messages=[_make_message(sequence=0)],
),
_make_session(session_id=_SESSION_ID_2),
]
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"])
@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"])
@then("the session CLI should show all sessions in a table")
def step_list_shows_table(context: Context) -> None:
assert context.result.exit_code == 0
assert "Sessions" in context.result.output
# ---------------------------------------------------------------------------
# Show
# ---------------------------------------------------------------------------
@given("there is a mocked session with messages")
def step_session_with_messages(context: Context) -> None:
messages = [
_make_message(MessageRole.USER, "Hello", 0),
_make_message(MessageRole.ASSISTANT, "Hi there", 1),
]
session = _make_session(
session_id=_SESSION_ID,
actor_name="openai/gpt-4",
messages=messages,
)
context.mock_service.get.return_value = session
context.session_id = _SESSION_ID
@when("I run session CLI show with a valid session ID")
def step_show_valid(context: Context) -> None:
context.result = context.runner.invoke(session_app, ["show", context.session_id])
@when("I run session CLI show with --format json")
def step_show_json(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["show", context.session_id, "--format", "json"]
)
@when("I run session CLI show with an invalid session ID")
def step_show_invalid(context: Context) -> None:
context.mock_service.get.side_effect = SessionNotFoundError(
"Session 'INVALID' not found"
)
context.result = context.runner.invoke(session_app, ["show", "INVALID"])
@then("the session CLI show should succeed")
def step_show_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@given("there is a mocked session to delete")
def step_session_to_delete(context: Context) -> None:
session = _make_session(session_id=_SESSION_ID)
context.mock_service.get.return_value = session
context.mock_service.delete.return_value = None
context.session_id = _SESSION_ID
@when("I run session CLI delete with --yes")
def step_delete_yes(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["delete", context.session_id, "--yes"]
)
@when("I run session CLI delete with a non-existent ID")
def step_delete_nonexistent(context: Context) -> None:
context.mock_service.get.side_effect = SessionNotFoundError("Session not found")
context.result = context.runner.invoke(
session_app, ["delete", "NONEXISTENT", "--yes"]
)
@then("the session CLI delete should succeed")
def step_delete_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
@given("there is a mocked session for export")
def step_session_for_export(context: Context) -> None:
session = _make_session(session_id=_SESSION_ID)
context.mock_service.get.return_value = session
context.mock_service.export_session.return_value = session.as_export_dict()
context.session_id = _SESSION_ID
@given("there is an existing export file")
def step_existing_export_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
fh.write("{}")
context.existing_export_path = path
context._cleanup_paths.append(path)
@when("I run session CLI export with no output file")
def step_export_stdout(context: Context) -> None:
context.result = context.runner.invoke(session_app, ["export", context.session_id])
@when("I run session CLI export with --output to a temp file")
def step_export_to_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd)
os.unlink(path) # Remove so export can create it
context.export_path = path
context._cleanup_paths.append(path)
context.result = context.runner.invoke(
session_app, ["export", context.session_id, "--output", path]
)
@when("I run session CLI export to an existing file without --force")
def step_export_no_force(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
["export", context.session_id, "--output", context.existing_export_path],
)
@when("I run session CLI export to an existing file with --force")
def step_export_with_force(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
[
"export",
context.session_id,
"--output",
context.existing_export_path,
"--force",
],
)
@when("I run session CLI export with a non-existent session ID")
def step_export_nonexistent(context: Context) -> None:
context.mock_service.export_session.side_effect = SessionNotFoundError(
"Session not found"
)
context.result = context.runner.invoke(session_app, ["export", "NONEXISTENT"])
@then("the session CLI export should succeed")
def step_export_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
@then("the exported file should exist")
def step_exported_file_exists(context: Context) -> None:
assert os.path.exists(context.export_path), (
f"Export file not found: {context.export_path}"
)
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
@given("there is a valid session export file")
def step_valid_export_file(context: Context) -> None:
session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
export_data = session.as_export_dict()
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(export_data, fh, default=str)
context.import_path = path
context._cleanup_paths.append(path)
imported = _make_session(session_id=str(ULID()), actor_name="openai/gpt-4")
context.mock_service.import_session.return_value = imported
@given("there is a corrupt session export file")
def step_corrupt_export_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump({"schema_version": "1.0", "bad": "data"}, fh)
context.corrupt_path = path
context._cleanup_paths.append(path)
context.mock_service.import_session.side_effect = SessionImportError(
"Checksum verification failed"
)
@given("there is an invalid JSON file")
def step_invalid_json_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
fh.write("{not valid json")
context.invalid_json_path = path
context._cleanup_paths.append(path)
@when("I run session CLI import with the export file")
def step_import_valid(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["import", "--input", context.import_path]
)
@when("I run session CLI import with a non-existent file")
def step_import_nonexistent(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["import", "--input", "/tmp/nonexistent_session_file.json"]
)
@when("I run session CLI import with the corrupt file")
def step_import_corrupt(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["import", "--input", context.corrupt_path]
)
@when("I run session CLI import with the invalid JSON file")
def step_import_invalid_json(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["import", "--input", context.invalid_json_path]
)
@then("the session CLI import should succeed")
def step_import_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
# ---------------------------------------------------------------------------
# Tell
# ---------------------------------------------------------------------------
@given("there is a mocked session for tell")
def step_session_for_tell(context: Context) -> None:
session = _make_session(session_id=_SESSION_ID)
context.mock_service.get.return_value = session
context.mock_service.append_message.side_effect = [
_make_message(MessageRole.USER, "Hello, world", 0),
_make_message(MessageRole.ASSISTANT, "Acknowledged: Hello, world", 1),
]
context.session_id = _SESSION_ID
@when('I run session CLI tell with a prompt "{prompt}"')
def step_tell_prompt(context: Context, prompt: str) -> None:
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", context.session_id, prompt],
)
@when('I run session CLI tell with --actor "{actor}" and prompt "{prompt}"')
def step_tell_with_actor(context: Context, actor: str, prompt: str) -> None:
context.mock_service.append_message.side_effect = [
_make_message(MessageRole.USER, prompt, 0),
_make_message(
MessageRole.ASSISTANT,
f"[{actor}] Acknowledged: {prompt}",
1,
),
]
context.result = context.runner.invoke(
session_app,
["tell", "--session", context.session_id, "--actor", actor, prompt],
)
@when("I run session CLI tell to a non-existent session")
def step_tell_nonexistent(context: Context) -> None:
context.mock_service.append_message.side_effect = SessionNotFoundError(
"Session not found"
)
context.result = context.runner.invoke(
session_app,
["tell", "--session", "NONEXISTENT", "Hello"],
)
@then("the session CLI tell should succeed")
def step_tell_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
)
# ---------------------------------------------------------------------------
# Generic assertions
# ---------------------------------------------------------------------------
@then('the session CLI output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
assert text in context.result.output, (
f"Expected '{text}' in output:\n{context.result.output}"
)
@then("the session CLI output should be valid JSON")
def step_output_valid_json(context: Context) -> None:
try:
json.loads(context.result.output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON:\n{context.result.output}"
) from exc
@then('the session CLI JSON should contain "{key}"')
def step_json_contains_key(context: Context, key: str) -> None:
data = json.loads(context.result.output)
assert key in data, f"Key '{key}' not found in JSON: {data}"
@then("the session CLI should exit with error")
def step_exit_with_error(context: Context) -> None:
assert context.result.exit_code != 0, (
f"Expected non-zero exit code, got {context.result.exit_code}: "
f"{context.result.output}"
)
+19 -19
View File
@@ -2373,25 +2373,25 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- `update_token_usage()` uses cumulative addition (+=) for `input_tokens`, `output_tokens`, `estimated_cost` rather than replacement.
- Second commit `fix(bench)` (2026-02-15 16:49:20 +0000) fixes `benchmarks/resource_registry_migration_bench.py` unique name constraints under ASV multi-iteration runs.
- 12 files changed, +1861/-1 lines (main commit); 1 file changed, +12/-4 lines (bench fix).
- [ ] **COMMIT (Owner: Brent | Group: A7.cli | Branch: feature/m3-session-cli | Planned: Day 15 | Expected: Day 19) - Commit message: "feat(cli): add session commands"**
- [ ] Git [Brent]: `git checkout master`
- [ ] Git [Brent]: `git pull origin master`
- [ ] Git [Brent]: `git checkout -b feature/m3-session-cli`
- [ ] Git [Brent]: `git fetch origin && git merge origin/master`
- [ ] Code [Brent]: Implement `agents session create/list/show/delete/export/import/tell` with `--actor`, `--stream`, and format outputs.
- [ ] Code [Brent]: Ensure `session tell` uses SessionService append + actor execution, updates last_active, and persists message order.
- [ ] Code [Brent]: Validate `session export/import` paths (create dirs, refuse overwrite unless `--force`) and guard against missing session IDs.
- [ ] Code [Brent]: Add `--format json/yaml` support for list/show and include message counts + last_active timestamps.
- [ ] Docs [Brent]: Update CLI reference with session command examples, streaming notes, and JSON output shape.
- [ ] Tests (Behave) [Brent]: Add `features/session_cli.feature` covering create/list/show/delete/export/import/tell flows + error cases.
- [ ] Tests (Robot) [Brent]: Add `robot/session_cli.robot` end-to-end session flows with export/import round-trip.
- [ ] Tests (ASV) [Brent]: Add `benchmarks/session_cli_bench.py` for command parsing overhead.
- [ ] Quality [Brent]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Brent]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Brent]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Brent]: `git commit -m "feat(cli): add session commands"`
- [ ] Git [Brent]: `git push -u origin feature/m3-session-cli`
- [ ] Forgejo PR [Brent]: Open PR from `feature/m3-session-cli` to `master` with description "Add session CLI commands with Behave/Robot coverage and docs updates."
- [X] **COMMIT (Owner: Brent | Group: A7.cli | Branch: feature/m3-session-cli | Planned: Day 15 | Expected: Day 19 | Done: 2026-02-19) - Commit message: "feat(cli): add session commands"**
- [X] Git [Brent]: `git checkout master`
- [X] Git [Brent]: `git pull origin master`
- [X] Git [Brent]: `git checkout -b feature/m3-session-cli`
- [X] Git [Brent]: `git fetch origin && git merge origin/master`
- [X] Code [Brent]: Implement `agents session create/list/show/delete/export/import/tell` with `--actor`, `--stream`, and format outputs.
- [X] Code [Brent]: Ensure `session tell` uses SessionService append + actor execution, updates last_active, and persists message order.
- [X] Code [Brent]: Validate `session export/import` paths (create dirs, refuse overwrite unless `--force`) and guard against missing session IDs.
- [X] Code [Brent]: Add `--format json/yaml` support for list/show and include message counts + last_active timestamps.
- [X] Docs [Brent]: Update CLI reference with session command examples, streaming notes, and JSON output shape.
- [X] Tests (Behave) [Brent]: Add `features/session_cli.feature` covering create/list/show/delete/export/import/tell flows + error cases.
- [X] Tests (Robot) [Brent]: Add `robot/session_cli.robot` end-to-end session flows with export/import round-trip.
- [X] Tests (ASV) [Brent]: Add `benchmarks/session_cli_bench.py` for command parsing overhead.
- [X] Quality [Brent]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] Quality [Brent]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [X] Git [Brent]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [X] Git [Brent]: `git commit -m "feat(cli): add session commands"`
- [X] Git [Brent]: `git push -u origin feature/m3-session-cli`
- [X] Forgejo PR [Brent]: Open PR from `feature/m3-session-cli` to `master` with description "Add session CLI commands with Behave/Robot coverage and docs updates."
**Parallel Group A8: Config CLI [Brent]** (M3; post-M1; depends on Settings)
- [ ] **COMMIT (Owner: Brent | Group: A8.cli | Branch: feature/m3-config-cli | Planned: Day 16 | Expected: Day 20) - Commit message: "feat(cli): add config get/set/list commands"**
+257
View File
@@ -0,0 +1,257 @@
"""Helper script for session_cli.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import os
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from ulid import ULID # noqa: E402
from cleveragents.cli.commands import session as session_mod # noqa: E402
from cleveragents.cli.commands.session import app as session_app # noqa: E402
from cleveragents.domain.models.core.session import ( # noqa: E402
MessageRole,
Session,
SessionMessage,
SessionNotFoundError,
SessionTokenUsage,
)
runner = CliRunner()
def _mock_session(
session_id: str | None = None,
actor_name: str | None = None,
messages: list[SessionMessage] | None = None,
) -> Session:
"""Create a test Session instance."""
return Session(
session_id=session_id or str(ULID()),
actor_name=actor_name,
namespace="local",
messages=messages or [],
token_usage=SessionTokenUsage(
input_tokens=100,
output_tokens=50,
estimated_cost=0.005,
),
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _mock_message(
role: MessageRole = MessageRole.USER,
content: str = "Hello",
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=str(ULID()),
role=role,
content=content,
sequence=sequence,
timestamp=datetime.now(),
)
def _setup_service() -> MagicMock:
"""Create and install a mock service."""
svc = MagicMock()
session_mod._service = svc
return svc
def _teardown() -> None:
session_mod._service = None
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def create_default() -> None:
svc = _setup_service()
svc.create.return_value = _mock_session()
try:
result = runner.invoke(session_app, ["create"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
print("session-cli-create-default-ok")
finally:
_teardown()
def create_actor() -> None:
svc = _setup_service()
svc.create.return_value = _mock_session(actor_name="openai/gpt-4")
try:
result = runner.invoke(session_app, ["create", "--actor", "openai/gpt-4"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "openai/gpt-4" in result.output
print("session-cli-create-actor-ok")
finally:
_teardown()
def list_empty() -> None:
svc = _setup_service()
svc.list.return_value = []
try:
result = runner.invoke(session_app, ["list"])
assert result.exit_code == 0
assert "No sessions found" in result.output
print("session-cli-list-empty-ok")
finally:
_teardown()
def list_populated() -> None:
svc = _setup_service()
svc.list.return_value = [
_mock_session(actor_name="openai/gpt-4"),
_mock_session(),
]
try:
result = runner.invoke(session_app, ["list"])
assert result.exit_code == 0
assert "Sessions" in result.output
print("session-cli-list-populated-ok")
finally:
_teardown()
def show_valid() -> None:
sid = str(ULID())
svc = _setup_service()
session = _mock_session(
session_id=sid,
messages=[
_mock_message(MessageRole.USER, "Hello", 0),
_mock_message(MessageRole.ASSISTANT, "Hi there", 1),
],
)
svc.get.return_value = session
try:
result = runner.invoke(session_app, ["show", sid])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "Session Details" in result.output
print("session-cli-show-valid-ok")
finally:
_teardown()
def show_not_found() -> None:
svc = _setup_service()
svc.get.side_effect = SessionNotFoundError("not found")
try:
result = runner.invoke(session_app, ["show", "INVALID"])
assert result.exit_code != 0
assert "Session not found" in result.output
print("session-cli-show-not-found-ok")
finally:
_teardown()
def delete_yes() -> None:
sid = str(ULID())
svc = _setup_service()
svc.get.return_value = _mock_session(session_id=sid)
svc.delete.return_value = None
try:
result = runner.invoke(session_app, ["delete", sid, "--yes"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "deleted" in result.output
print("session-cli-delete-yes-ok")
finally:
_teardown()
def export_import_roundtrip() -> None:
"""Test export to file then import back."""
sid = str(ULID())
svc = _setup_service()
session = _mock_session(session_id=sid, actor_name="openai/gpt-4")
svc.get.return_value = session
export_data = session.as_export_dict()
svc.export_session.return_value = export_data
fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd)
os.unlink(path)
try:
# Export
result = runner.invoke(session_app, ["export", sid, "--output", path])
assert result.exit_code == 0, f"export exit={result.exit_code}: {result.output}"
assert os.path.exists(path)
# Set up import mock
imported = _mock_session(actor_name="openai/gpt-4")
svc.import_session.return_value = imported
# Import
result = runner.invoke(session_app, ["import", "--input", path])
assert result.exit_code == 0, f"import exit={result.exit_code}: {result.output}"
assert "Session Imported" in result.output
print("session-cli-export-import-roundtrip-ok")
finally:
if os.path.exists(path):
os.unlink(path)
_teardown()
def tell_message() -> None:
sid = str(ULID())
svc = _setup_service()
svc.get.return_value = _mock_session(session_id=sid)
svc.append_message.side_effect = [
_mock_message(MessageRole.USER, "Hello", 0),
_mock_message(MessageRole.ASSISTANT, "Acknowledged: Hello", 1),
]
try:
result = runner.invoke(session_app, ["tell", "--session", sid, "Hello"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "Acknowledged" in result.output
print("session-cli-tell-message-ok")
finally:
_teardown()
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, object] = {
"create-default": create_default,
"create-actor": create_actor,
"list-empty": list_empty,
"list-populated": list_populated,
"show-valid": show_valid,
"show-not-found": show_not_found,
"delete-yes": delete_yes,
"export-import-roundtrip": export_import_roundtrip,
"tell-message": tell_message,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
cmd = _COMMANDS[sys.argv[1]]
cmd() # type: ignore[operator]
+65
View File
@@ -0,0 +1,65 @@
*** Settings ***
Documentation End-to-end smoke tests for Session CLI commands
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_session_cli.py
*** Test Cases ***
Session Create Default
[Documentation] Verify that ``session create`` succeeds with defaults
${result}= Run Process ${PYTHON} ${HELPER} create-default cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-create-default-ok
Session Create With Actor
[Documentation] Verify that ``session create --actor`` binds an actor
${result}= Run Process ${PYTHON} ${HELPER} create-actor cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-create-actor-ok
Session List Empty
[Documentation] Verify that ``session list`` handles empty state
${result}= Run Process ${PYTHON} ${HELPER} list-empty cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-list-empty-ok
Session List Populated
[Documentation] Verify that ``session list`` shows sessions
${result}= Run Process ${PYTHON} ${HELPER} list-populated cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-list-populated-ok
Session Show Valid
[Documentation] Verify that ``session show`` displays details
${result}= Run Process ${PYTHON} ${HELPER} show-valid cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-show-valid-ok
Session Show Not Found
[Documentation] Verify that ``session show`` reports missing session
${result}= Run Process ${PYTHON} ${HELPER} show-not-found cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-show-not-found-ok
Session Delete With Yes
[Documentation] Verify that ``session delete --yes`` works
${result}= Run Process ${PYTHON} ${HELPER} delete-yes cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-delete-yes-ok
Session Export Import Roundtrip
[Documentation] Verify export/import round-trip
${result}= Run Process ${PYTHON} ${HELPER} export-import-roundtrip cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-export-import-roundtrip-ok
Session Tell Appends Message
[Documentation] Verify that ``session tell`` appends a message
${result}= Run Process ${PYTHON} ${HELPER} tell-message cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-tell-message-ok
+494
View File
@@ -0,0 +1,494 @@
"""Session management commands for CleverAgents CLI.
The ``agents session`` command group manages interactive sessions — persistent
conversation threads tied to orchestrator actors.
Based on specification.md ``agents session`` section and implementation_plan.md
task A7.cli.
"""
from __future__ import annotations
import json
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Annotated, Any
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionImportError,
SessionNotFoundError,
SessionService,
)
# Create sub-app for session commands
app = typer.Typer(help="Manage interactive sessions.")
console = Console()
# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
# ---------------------------------------------------------------------------
# Module-level service accessor (patchable in tests)
# ---------------------------------------------------------------------------
_service: SessionService | None = None
def _get_session_service() -> SessionService:
"""Get or create the SessionService instance.
Production usage goes through the DI container; tests can patch
``_service`` or this function directly.
"""
global _service
if _service is not None:
return _service
from cleveragents.application.container import get_container
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
from cleveragents.infrastructure.database.repositories import (
SessionMessageRepository,
SessionRepository,
)
container = get_container()
db = container.db()
session_repo = SessionRepository(db)
message_repo = SessionMessageRepository(db)
return PersistentSessionService(session_repo, message_repo)
def _reset_session_service() -> None:
"""Reset the module-level service (used by tests)."""
global _service
_service = None
# ---------------------------------------------------------------------------
# Helpers — dict builders and rich printing
# ---------------------------------------------------------------------------
def _session_summary_dict(session: Session) -> OrderedDict[str, Any]:
"""Build a stable-ordered summary dict for a session."""
result: OrderedDict[str, Any] = OrderedDict()
result["session_id"] = session.session_id
result["actor"] = session.actor_name or "(none)"
result["namespace"] = session.namespace
result["messages"] = session.message_count
result["created"] = session.created_at.isoformat()
result["updated"] = session.updated_at.isoformat()
return result
def _session_list_dict(sessions: list[Session]) -> dict[str, Any]:
"""Build the list output with summary stats."""
items = []
for s in sessions:
items.append(
{
"id": s.session_id,
"actor": s.actor_name or "(none)",
"messages": s.message_count,
"updated": s.updated_at.isoformat(),
}
)
return {
"sessions": items,
"total": len(sessions),
}
# ---------------------------------------------------------------------------
# CLI Commands
# ---------------------------------------------------------------------------
@app.command()
def create(
actor: Annotated[
str | None,
typer.Option("--actor", help="Orchestrator actor name (namespace/name)"),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Create a new interactive session.
Optionally bind the session to an orchestrator actor via ``--actor``.
Examples:
agents session create
agents session create --actor openai/gpt-4
agents session create --format json
"""
try:
service = _get_session_service()
session = service.create(actor_name=actor)
data = _session_summary_dict(session)
if fmt != OutputFormat.RICH.value:
console.print(format_output(dict(data), fmt))
return
details = (
f"[bold]Session ID:[/bold] {session.session_id}\n"
f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n"
f"[bold]Namespace:[/bold] {session.namespace}\n"
f"[bold]Created:[/bold] {session.created_at.strftime('%Y-%m-%d %H:%M')}"
)
console.print(Panel(details, title="Session Created", expand=False))
console.print("[green]✓ OK[/green] Session created")
except SessionNotFoundError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1) from exc
@app.command("list")
def list_sessions(
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""List all sessions.
Displays session IDs, bound actors, message counts, and last update times.
Examples:
agents session list
agents session list --format json
agents session list --format table
"""
service = _get_session_service()
sessions = service.list()
if not sessions:
console.print("[yellow]No sessions found.[/yellow]")
console.print("Create one with 'agents session create'")
return
data = _session_list_dict(sessions)
if fmt != OutputFormat.RICH.value:
console.print(format_output(data, fmt))
return
# Rich table
table = Table(title=f"Sessions ({len(sessions)} total)", show_header=True)
table.add_column("ID", style="cyan")
table.add_column("Actor", style="blue")
table.add_column("Messages", justify="right")
table.add_column("Updated", style="green")
for s in sessions:
table.add_row(
s.session_id,
s.actor_name or "(none)",
str(s.message_count),
s.updated_at.strftime("%Y-%m-%d %H:%M"),
)
console.print(table)
# Summary
total_msgs = sum(s.message_count for s in sessions)
summary = (
f"[yellow]Total Sessions:[/yellow] {len(sessions)}\n"
f"[blue]Total Messages:[/blue] {total_msgs}"
)
console.print(Panel(summary, title="Summary", expand=False))
@app.command()
def show(
session_id: Annotated[
str,
typer.Argument(help="Session ULID to show"),
],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show session details and recent messages.
Displays session metadata, recent messages, linked plans, and token usage.
Examples:
agents session show 01HXYZ...
agents session show 01HXYZ... --format json
"""
try:
service = _get_session_service()
session = service.get(session_id)
data = session.as_cli_dict()
if fmt != OutputFormat.RICH.value:
console.print(format_output(dict(data), fmt))
return
# Session summary panel
details = (
f"[bold]Session ID:[/bold] {session.session_id}\n"
f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n"
f"[bold]Namespace:[/bold] {session.namespace}\n"
f"[bold]Messages:[/bold] {session.message_count}\n"
f"[bold]Created:[/bold] {session.created_at.strftime('%Y-%m-%d %H:%M')}\n"
f"[bold]Updated:[/bold] {session.updated_at.strftime('%Y-%m-%d %H:%M')}"
)
console.print(Panel(details, title="Session Details", expand=False))
# Recent messages
if session.messages:
recent = session.messages[-5:]
msg_table = Table(title="Recent Messages", show_header=True)
msg_table.add_column("Role", style="cyan")
msg_table.add_column("Content")
msg_table.add_column("Timestamp", style="dim")
for msg in recent:
content = msg.content
if len(content) > 80:
content = content[:77] + "..."
msg_table.add_row(
msg.role.value,
content,
msg.timestamp.strftime("%H:%M:%S"),
)
console.print(msg_table)
# Linked plans
if session.linked_plan_ids:
plan_text = "\n".join(f"{pid}" for pid in session.linked_plan_ids)
console.print(Panel(plan_text, title="Linked Plans", expand=False))
# Token usage
tu = session.token_usage
usage_text = (
f"[blue]Input Tokens:[/blue] {tu.input_tokens}\n"
f"[blue]Output Tokens:[/blue] {tu.output_tokens}\n"
f"[yellow]Estimated Cost:[/yellow] ${tu.estimated_cost:.4f}"
)
console.print(Panel(usage_text, title="Token Usage", expand=False))
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
@app.command()
def delete(
session_id: Annotated[
str,
typer.Argument(help="Session ULID to delete"),
],
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
) -> None:
"""Delete a session permanently.
A confirmation prompt is shown unless ``--yes`` is provided.
Examples:
agents session delete 01HXYZ...
agents session delete 01HXYZ... --yes
"""
try:
service = _get_session_service()
# Verify session exists before prompting
service.get(session_id)
if not yes:
confirm = typer.confirm(f"Delete session {session_id}?", default=False)
if not confirm:
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
service.delete(session_id)
console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
@app.command("export")
def export_session(
session_id: Annotated[
str,
typer.Argument(help="Session ULID to export"),
],
output: Annotated[
Path | None,
typer.Option("--output", "-o", help="Output file path (default: stdout)"),
] = None,
force: Annotated[
bool,
typer.Option("--force", help="Overwrite existing output file"),
] = False,
) -> None:
"""Export a session as JSON.
Writes the full session data (including messages and checksum) to a file
or stdout.
Examples:
agents session export 01HXYZ...
agents session export 01HXYZ... -o session.json
agents session export 01HXYZ... -o session.json --force
"""
try:
service = _get_session_service()
data = service.export_session(session_id)
json_str = json.dumps(data, indent=2, default=str)
if output is not None:
if output.exists() and not force:
console.print(
f"[red]File already exists:[/red] {output}\n"
"Use --force to overwrite."
)
raise typer.Exit(1)
# Create parent directories if needed
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json_str, encoding="utf-8")
console.print(f"[green]✓ OK[/green] Session exported to {output}")
else:
typer.echo(json_str)
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
except SessionExportError as exc:
console.print(f"[red]Export error:[/red] {exc}")
raise typer.Exit(1) from exc
@app.command("import")
def import_session(
input_file: Annotated[
Path,
typer.Option("--input", "-i", help="Input JSON file path"),
],
) -> None:
"""Import a session from a JSON file.
The file must have been produced by ``agents session export`` and must
contain a valid schema version and checksum.
Examples:
agents session import -i session.json
"""
if not input_file.exists():
console.print(f"[red]File not found:[/red] {input_file}")
raise typer.Exit(1)
try:
raw = input_file.read_text(encoding="utf-8")
data = json.loads(raw)
except json.JSONDecodeError as exc:
console.print(f"[red]Invalid JSON:[/red] {exc}")
raise typer.Exit(1) from exc
try:
service = _get_session_service()
session = service.import_session(data)
details = (
f"[bold]Session ID:[/bold] {session.session_id}\n"
f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n"
f"[bold]Messages:[/bold] {session.message_count}\n"
f"[bold]Namespace:[/bold] {session.namespace}"
)
console.print(Panel(details, title="Session Imported", expand=False))
console.print("[green]✓ OK[/green] Session imported")
except SessionImportError as exc:
console.print(f"[red]Import error:[/red] {exc}")
raise typer.Exit(1) from exc
@app.command()
def tell(
prompt: Annotated[
str,
typer.Argument(help="Message to send to the session"),
],
session_id: Annotated[
str,
typer.Option("--session", help="Session ULID to send message to"),
],
actor: Annotated[
str | None,
typer.Option("--actor", help="Override actor for this message"),
] = None,
stream: Annotated[
bool,
typer.Option("--stream", help="Stream response in real-time"),
] = False,
) -> None:
"""Send a message to a session.
Appends a user message and generates an assistant response. For M3, the
actor execution is stubbed — the assistant echoes an acknowledgement.
Examples:
agents session tell --session 01HXYZ... "Hello, world"
agents session tell --session 01HXYZ... --actor openai/gpt-4 "Plan a feature"
agents session tell --session 01HXYZ... --stream "Build tests"
"""
try:
service = _get_session_service()
# Append user message
service.append_message(
session_id=session_id,
role=MessageRole.USER,
content=prompt,
)
# Stub actor execution: generate simple assistant response
assistant_content = (
f"Acknowledged: {prompt[:100]}"
if not actor
else f"[{actor}] Acknowledged: {prompt[:100]}"
)
service.append_message(
session_id=session_id,
role=MessageRole.ASSISTANT,
content=assistant_content,
)
if stream:
# Simulate streaming by printing character by character
for char in assistant_content:
sys.stdout.write(char)
sys.stdout.flush()
sys.stdout.write("\n")
else:
from rich.markup import escape
console.print(f"[dim]user:[/dim] {escape(prompt)}")
console.print(f"[cyan]assistant:[/cyan] {escape(assistant_content)}")
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
+7
View File
@@ -85,6 +85,7 @@ def _register_subcommands() -> None:
plan,
project,
resource,
session,
skill,
)
from cleveragents.cli.commands.auto_debug import app as auto_debug_app
@@ -140,6 +141,11 @@ def _register_subcommands() -> None:
name="audit",
help="View and manage the audit log for security-relevant operations",
)
app.add_typer(
session.app,
name="session",
help="Manage interactive sessions",
)
_subcommands_registered = True
@@ -527,6 +533,7 @@ def main(args: list[str] | None = None) -> int:
"skill", # Skill management
"cleanup", # Garbage collection and cleanup
"auto-debug", # Auto-debug commands
"session", # Session management
"tell", # Shortcut for plan tell
"build", # Shortcut for plan build
"apply", # Shortcut for plan apply