feat(cli): add session commands
This commit is contained in:
@@ -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"])
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"**
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user