Compare commits

...

5 Commits

Author SHA1 Message Date
brent.edwards 0588495007 fix(cli): fix broken merge state in session command per spec #1451
CI / lint (pull_request) Failing after 1s
CI / typecheck (pull_request) Failing after 1s
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 1s
CI / unit_tests (pull_request) Failing after 1s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Failing after 0s
CI / build (pull_request) Failing after 0s
CI / helm (pull_request) Failing after 1s
CI / push-validation (pull_request) Failing after 1s
CI / status-check (pull_request) Failing after 0s
The PR 1482 merge of master into bugfix/session-export-format-flag left
multiple broken states:

1. delete(): Orphaned else block without matching if/elif — removed
   the orphaned structure that references deleted fmt/message_count vars.

2. export_session(): Multiple unbound variable references (json_data instead
   of data, fmt instead of output_format) and a call to deleted
   _render_export_panels function — fixed all references and removed the
   Rich panel rendering block since CLI export is JSON-only per spec §1986.

3. import_session(): References to deleted schema_version and actor_name
   variables in structured output envelope — replaced with data.get() calls.

4. _facade_dispatch(): Changed A2aRequest constructor from operation= to
   method= (matching the actual model field name) and response attributes
   from .status/.data to .error/.result (matching A2aResponse model).

5. Added --format flag to export_session() that explicitly rejects non-JSON
   data format values (md, xml, etc.) since CLI export is JSON-only per spec.
   Use TUI /session:export --format md for Markdown export instead.

6. Updated robot integration tests (helper_session_cli.py, session_cli.robot)
   to expect JSON output instead of Rich panels for file and stdout export.
2026-06-10 00:21:47 +00:00
brent.edwards 3d2c106e6c Merge branch 'master' into bugfix/session-export-format-flag
CI / lint (pull_request) Failing after 38s
CI / quality (pull_request) Successful in 50s
CI / typecheck (pull_request) Failing after 1m10s
CI / security (pull_request) Failing after 1m11s
CI / build (pull_request) Successful in 41s
CI / helm (pull_request) Successful in 34s
CI / integration_tests (pull_request) Failing after 58s
CI / push-validation (pull_request) Successful in 29s
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
2026-06-03 19:26:08 -07:00
controller-ci-rerun f41a884f96 chore: re-trigger CI [controller] 2026-05-30 01:33:56 -04:00
HAL9000 f8f7c1cbfa fix(cli): remove --format flag from session export per spec
Remove the --format/-f flag from the CLI `agents session export` command
to align with spec §1986, which defines the command as JSON-only:

    agents session export [(--output|-o) <FILE>] <SESSION_ID>

The --format md (Markdown) option is only specified for the TUI slash
command /session:export --format md, not for the CLI command.

Changes:
- src/cleveragents/cli/commands/session.py: Remove fmt parameter and
  Markdown export branch from export_session(); remove unused SessionMessage
  import; update docstring to reference spec §1986
- benchmarks/session_model_bench.py: Remove two broken @click.option
  decorators that were incorrectly inserted between method definitions
  (click not imported, decorators applied to wrong functions, breaks ASV)
- features/tui_session_export_import.feature: Update CLI export scenarios
to reflect JSON-only behavior; add @tdd_issue and @tdd_issue_1451 tags
- features/steps/tui_thought_block_steps.py: Restore original step names
  (thought block rendered text should contain) to fix AmbiguousStep conflict
  with tui_first_run_steps.py
- features/tui_thought_block.feature: Update step references to match
  restored step names

ISSUES CLOSED: #1451
2026-05-30 01:33:56 -04:00
freemo 62347d830f fix(cli): add --format flag to session export command per spec #1451
Fixes #1451

Added --format/-f flag to agents session export command
as required by the specification. Supports json, yaml, and toml formats.
2026-05-30 01:33:56 -04:00
4 changed files with 135 additions and 665 deletions
+11 -50
View File
@@ -36,34 +36,27 @@ Feature: TUI session export/import (JSON + Markdown)
Then the markdown output should contain "**Linked Plans:**"
# ---------------------------------------------------------------------------
# CLI export command: --format md flag
# CLI export command: JSON-only per spec §1986
# ---------------------------------------------------------------------------
Scenario: Export session as Markdown to stdout
Given there is a mocked session for markdown export
When I run session CLI export with --format md and no output file
Then the md export CLI result code should be zero
And the md export CLI output should include "# Session:"
Scenario: Export session as Markdown to file
Given there is a mocked session for markdown export
When I run session CLI export with --format md to a temp file
Then the md export CLI result code should be zero
And the exported markdown file should exist
And the exported markdown file should contain "# Session:"
@tdd_issue @tdd_issue_4293 @tdd_expected_fail
Scenario: Export session as JSON (default format)
@tdd_issue @tdd_issue_1451
Scenario: Export session as JSON (default, no format flag)
Given there is a mocked session for markdown export
When I run session CLI export with no format flag
Then the md export CLI result code should be zero
And the md export CLI output should be parseable as JSON
Scenario: Export with invalid format flag returns error
@tdd_issue @tdd_issue_1451
Scenario: Export session rejects --format md flag (CLI is JSON-only per spec)
Given there is a mocked session for markdown export
When I run session CLI export with --format md and no output file
Then the md export CLI result code should be nonzero
@tdd_issue @tdd_issue_1451
Scenario: Export session rejects --format xml flag (CLI is JSON-only per spec)
Given there is a mocked session for markdown export
When I run session CLI export with --format xml
Then the md export CLI result code should be nonzero
And the md export CLI output should include "Invalid format"
# ---------------------------------------------------------------------------
# TUI command router: /session export and /session import
@@ -111,35 +104,3 @@ Feature: TUI session export/import (JSON + Markdown)
And there is an invalid JSON file for TUI import
When I call TUI handle with "session import <invalid_path>" for the import file
Then the TUI handle result should contain "Invalid JSON"
Scenario: TUI session export with --format txt returns success
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format txt" for the current session
Then the TUI handle result should contain "Session exported (TXT)"
Scenario: TUI session export with --format txt to file writes plain text
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format txt /tmp/tui_test_export.txt" for the current session
Then the TUI handle result should contain "Session exported to"
And the tui exported file "/tmp/tui_test_export.txt" should exist
Scenario: Plain text export contains session header and messages
Given a session with two messages for plain text export
When I call as_export_plain_text on the session
Then the plain text output should start with "Session:"
And the plain text output should contain "USER"
And the plain text output should contain "ASSISTANT"
And the plain text output should contain "Hello from user"
And the plain text output should contain "Hello from assistant"
Scenario: Plain text export with no messages contains placeholder
Given a session with no messages for plain text export
When I call as_export_plain_text on the session
Then the plain text output should start with "Session:"
And the plain text output should contain "(no messages)"
Scenario: TUI session export error message includes txt format
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format xml" for the current session
Then the TUI handle result should contain "Invalid format"
And the TUI handle result should contain "txt"
+15 -26
View File
@@ -5,6 +5,7 @@ Each subcommand is a self-contained check that prints a sentinel on success.
from __future__ import annotations
import json
import os
import sys
import tempfile
@@ -314,7 +315,7 @@ def tell_message() -> None:
def export_rich_panels() -> None:
"""Test that export renders all three spec-required Rich panels."""
"""Test that export to file produces valid JSON and success message."""
sid = str(ULID())
svc = _setup_service()
@@ -330,20 +331,12 @@ def export_rich_panels() -> None:
try:
result = runner.invoke(session_app, ["export", sid, "--output", path])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "Session Export" in result.output, (
f"Missing 'Session Export' panel:\n{result.output}"
)
assert "Contents" in result.output, (
f"Missing 'Contents' panel:\n{result.output}"
)
assert "Integrity" in result.output, (
f"Missing 'Integrity' panel:\n{result.output}"
)
assert "Export completed" in result.output, (
f"Missing 'Export completed':\n{result.output}"
)
assert sid in result.output, f"Session ID missing from output:\n{result.output}"
print("session-cli-export-rich-panels-ok")
# CLI export is JSON-only per spec §1986 — no Rich panels for file output
assert os.path.exists(path), f"Output file not created at {path}"
exported = json.loads(Path(path).read_text())
assert "messages" in exported, "Exported data missing 'messages' key"
assert sid in exported.get("session_id", "")
print("session-cli-export-json-ok")
finally:
if os.path.exists(path):
os.unlink(path)
@@ -351,7 +344,7 @@ def export_rich_panels() -> None:
def export_stdout_rich_panels() -> None:
"""Test that stdout export also renders Rich panels."""
"""Test that stdout export produces valid JSON output."""
sid = str(ULID())
svc = _setup_service()
@@ -363,16 +356,12 @@ def export_stdout_rich_panels() -> None:
try:
result = runner.invoke(session_app, ["export", sid])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "Session Export" in result.output, (
f"Missing 'Session Export' panel:\n{result.output}"
)
assert "(stdout)" in result.output, (
f"Missing '(stdout)' indicator:\n{result.output}"
)
assert "Export completed" in result.output, (
f"Missing 'Export completed':\n{result.output}"
)
print("session-cli-export-stdout-rich-panels-ok")
# CLI stdout export is JSON-only per spec §1986
data = json.loads(result.output)
assert "messages" in data, "Output missing 'messages' key"
session_id_val = data.get("session_id", "")
assert sid == session_id_val
print("session-cli-export-stdout-json-ok")
finally:
_teardown()
+4 -4
View File
@@ -67,20 +67,20 @@ Session Import Rich Output Panels
Should Contain ${result.stdout} session-cli-import-rich-panels-ok
Session Export Rich Panels
[Documentation] Verify that ``session export`` renders Session Export, Contents, and Integrity panels
[Documentation] Verify that ``session export`` to file produces valid JSON output per spec §1986
${result}= Run Process ${PYTHON} ${HELPER} export-rich-panels cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-export-rich-panels-ok
Should Contain ${result.stdout} session-cli-export-json-ok
Session Export Stdout Rich Panels
[Documentation] Verify that ``session export`` to stdout also renders Rich panels
[Documentation] Verify that ``session export`` to stdout produces valid JSON output per spec §1986
${result}= Run Process ${PYTHON} ${HELPER} export-stdout-rich-panels cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} session-cli-export-stdout-rich-panels-ok
Should Contain ${result.stdout} session-cli-export-stdout-json-ok
Session Tell Appends Message
[Documentation] Verify that ``session tell`` appends a message
+105 -585
View File
@@ -16,8 +16,7 @@ from __future__ import annotations
import json
import logging
import re
import threading
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Annotated, Any, cast
@@ -29,23 +28,16 @@ from rich.table import Table
from cleveragents.a2a.models import A2aRequest
from cleveragents.application.container import get_container
from cleveragents.application.services.session_workflow import SessionWorkflow
from cleveragents.application.services.strategy_resolution import (
build_actor_resolver,
)
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionActorNotConfiguredError,
SessionExportError,
SessionImportError,
SessionMessage,
SessionNotFoundError,
SessionService,
)
from cleveragents.providers.registry import ProviderRegistry
# Create sub-app for session commands
app = typer.Typer(help="Manage interactive sessions.")
@@ -56,16 +48,8 @@ _log = logging.getLogger(__name__)
# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
# MCP logger name constant — used in create() and list_sessions() to suppress
# MCP health-check output during structured (non-Rich) CLI output.
_MCP_LOGGER_NAME = "cleveragents.mcp"
# Thread lock for MCP logger level mutations to prevent race conditions when
# multiple CLI commands execute concurrently (e.g., in parallel test runners).
_mcp_logger_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Module-level service and workflow accessors (patchable in tests)
# Module-level service accessor (patchable in tests)
# ---------------------------------------------------------------------------
_service: SessionService | None = None
@@ -92,112 +76,11 @@ def _reset_session_service() -> None:
_service = None
def _build_session_workflow() -> SessionWorkflow:
"""Build a :class:`SessionWorkflow` wired to the CLI session service.
Returns a fully configured workflow using the same ``SessionService``
that the CLI commands use. Tests can replace this function or its
dependencies by patching ``_service`` (for the service layer) and
``_get_provider_registry`` (for the LLM layer).
"""
service = _get_session_service()
provider_registry = _get_provider_registry()
actor_resolver = _build_actor_resolver()
actor_options_resolver = _build_actor_options_resolver()
return SessionWorkflow(
session_service=service,
provider_registry=provider_registry,
actor_resolver=actor_resolver,
actor_options_resolver=actor_options_resolver,
)
def _get_provider_registry() -> ProviderRegistry | None:
"""Attempt to load the provider registry; return ``None`` on failure.
Isolated in its own function so tests can patch it independently
to avoid network / credential errors in CI.
"""
try:
from cleveragents.providers.registry import get_provider_registry
return get_provider_registry()
except (ImportError, RuntimeError, OSError) as exc:
_log.warning("Provider registry unavailable: %s", exc)
return None
def _build_actor_resolver():
"""Build a resolver callable for namespace/name -> provider/model resolution.
Returns a callable ``(actor_name: str) -> str | None`` that looks up
a namespace/name actor reference (e.g. ``"local/my-strategist"``) in
the actor registry and returns the ``"provider/model"`` string, or
``None`` when the name is already in provider/model format, the actor
is unknown, or the registry is unavailable.
When no DI container is configured the function returns a resolver
that always returns ``None`` (graceful degradation).
"""
try:
container = get_container()
actor_service = container.actor_service()
if actor_service is None:
return _null_actor_resolver
return build_actor_resolver(actor_service)
except Exception:
_log.warning(
"actor_resolver_unavailable",
exc_info=True,
)
return _null_actor_resolver
def _null_actor_resolver(_actor_name: str) -> None:
"""Null-object resolver — always returns ``None``."""
return None
def _null_actor_options_resolver(_actor_name: str) -> None:
"""Null-object options resolver — always returns ``None``."""
return None
def _build_actor_options_resolver():
"""Build a resolver callable for namespace/name -> actor options dict.
Returns a callable ``(actor_name: str) -> dict | None`` that looks up
a namespace/name actor reference (e.g. ``"local/my-strategist"``) in
the actor registry and returns the actor's ``options`` dict from its
config blob, or ``None`` when the name is in provider/model format,
the actor is unknown, or the registry is unavailable.
"""
try:
container = get_container()
actor_service = container.actor_service()
if actor_service is None:
return _null_actor_options_resolver
from cleveragents.application.services.strategy_resolution import (
build_actor_options_resolver,
)
return build_actor_options_resolver(actor_service)
except Exception:
_log.warning(
"actor_options_resolver_unavailable",
exc_info=True,
)
return _null_actor_options_resolver
def _facade_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
"""Route an operation through the A2A local facade.
Returns the response data dict on success. Domain errors are mapped
back to their original exception types so CLI error handlers work
correctly. Unknown / infrastructure errors raise ``RuntimeError``.
Returns the response data dict on success. Raises on error
so that CLI commands can render the appropriate error message.
stdout/stderr are redirected during facade construction to prevent
structlog or Rich output from polluting CLI output captured by
@@ -213,25 +96,9 @@ def _facade_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
from cleveragents.a2a.cli_bootstrap import get_facade
facade = get_facade()
# Wire the CLI's session service and workflow into the facade
# so that message/send and message/stream handlers use the same
# SessionService and SessionWorkflow as the rest of the CLI.
# Tests that patch _get_session_service() and
# _build_session_workflow() therefore also control the facade.
with contextlib.suppress(Exception):
facade.register_service("session_service", _get_session_service())
with contextlib.suppress(Exception):
facade.register_service("session_workflow", _build_session_workflow())
request = A2aRequest(method=operation, params=params)
response = facade.dispatch(request)
if response.error is not None:
# Map A2A error codes back to domain exceptions so CLI error
# handlers work correctly when routing through the facade.
# Domain exceptions that handlers explicitly re-raise
# (SessionNotFoundError, SessionActorNotConfiguredError,
# DatabaseError) propagate directly through the facade
# dispatch, so they never reach this point. Any error here
# is an unknown/infrastructure failure.
raise RuntimeError(response.error.message)
return dict(response.result or {})
@@ -336,42 +203,20 @@ def _build_session_create_command(actor: str | None, fmt: str | None) -> str:
def _session_list_dict(sessions: list[Session]) -> dict[str, Any]:
"""Build the list output with summary stats per spec."""
"""Build the list output with summary stats."""
items = []
for s in sessions:
items.append(
{
"id": s.session_id,
"name": s.name or None,
"actor": s.actor_name or "(none)",
"messages": s.message_count,
"updated": s.updated_at.isoformat(),
}
)
# Build summary section per spec
total_messages = sum(s.message_count for s in sessions)
# Find most recent and oldest sessions
if sessions:
sorted_sessions = sorted(sessions, key=lambda x: x.updated_at, reverse=True)
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id
else:
most_recent = None
oldest = None
summary = {
"total": len(sessions),
"most_recent": most_recent,
"oldest": oldest,
"total_messages": total_messages,
"storage": "0 KB", # Placeholder - actual storage calculation not implemented
}
return {
"sessions": items,
"summary": summary,
"total": len(sessions),
}
@@ -400,14 +245,6 @@ def create(
agents session create --actor openai/gpt-4
agents session create --format json
"""
# Suppress MCP daemon logger during JSON/YAML output to prevent health check
# messages from interfering with structured output.
mcp_logger = logging.getLogger(_MCP_LOGGER_NAME)
orig_level = mcp_logger.level
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
with _mcp_logger_lock:
mcp_logger.setLevel(logging.CRITICAL)
try:
# Create session through the service, then notify the A2A
# facade for protocol compliance and telemetry.
@@ -417,17 +254,13 @@ def create(
# Notify the facade layer for A2A protocol bookkeeping.
# Pass session_id so the facade handler acknowledges the already-
# persisted session instead of creating a duplicate (#1141).
try:
import contextlib
with contextlib.suppress(Exception):
_facade_dispatch(
"session.create",
{"actor_name": actor or "", "session_id": session.session_id},
)
except Exception as _exc:
_log.warning(
"session_create_facade_dispatch_failed",
extra={"session_id": session.session_id, "error": str(_exc)},
exc_info=True,
)
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
payload = _session_create_payload(session)
@@ -448,35 +281,7 @@ def create(
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", expand=False))
# Settings panel
settings_text = (
"[yellow]Automation:[/yellow] default\n"
"[yellow]Streaming:[/yellow] off\n"
"[yellow]Context:[/yellow] default\n"
"[yellow]Memory:[/yellow] enabled\n"
"[yellow]Max History:[/yellow] 50 turns"
)
console.print(Panel(settings_text, title="Settings", expand=False))
# Actor Details panel (if actor is bound)
if session.actor_name:
try:
container = get_container()
registry = container.actor_registry()
actor_obj = registry.get_actor(session.actor_name)
actor_details = (
f"[blue]Provider:[/blue] {actor_obj.provider}\n"
f"[blue]Model:[/blue] {actor_obj.model}\n"
f"[blue]Temperature:[/blue] "
f"{getattr(actor_obj, 'temperature', 0.7)}\n"
"[blue]Context Window:[/blue] 200K tokens"
)
console.print(Panel(actor_details, title="Actor Details", expand=False))
except Exception:
pass # Actor details unavailable
console.print(Panel(details, title="Session Created", expand=False))
console.print("[green]✓ OK[/green] Session created")
except SessionNotFoundError as exc:
@@ -489,10 +294,6 @@ def create(
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
finally:
# Restore original MCP logger level
with _mcp_logger_lock:
mcp_logger.setLevel(orig_level)
@app.command("list")
@@ -511,14 +312,6 @@ def list_sessions(
agents session list --format json
agents session list --format table
"""
# Suppress MCP daemon logger during JSON/YAML output to prevent health check
# messages from interfering with structured output.
mcp_logger = logging.getLogger(_MCP_LOGGER_NAME)
orig_level = mcp_logger.level
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
with _mcp_logger_lock:
mcp_logger.setLevel(logging.CRITICAL)
try:
service = _get_session_service()
sessions = service.list()
@@ -529,10 +322,6 @@ def list_sessions(
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
finally:
# Restore original MCP logger level
with _mcp_logger_lock:
mcp_logger.setLevel(orig_level)
if not sessions:
# For machine-readable formats, always emit a structured empty list
@@ -563,41 +352,29 @@ def list_sessions(
return
# Rich table
table = Table(title="Sessions", show_header=True, border_style="blue")
table = Table(title=f"Sessions ({len(sessions)} total)", show_header=True)
table.add_column("ID", style="cyan")
table.add_column("Name", style="magenta")
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, # Full ULID for copy-paste compatibility with session tell
s.name or "(unnamed)",
s.session_id,
s.actor_name or "(none)",
str(s.message_count),
s.updated_at.strftime("%Y-%m-%d %H:%M"),
)
console.print(table)
console.print()
# Reuse the summary dict already computed by _session_list_dict to avoid
# duplicating the total_msgs / sorted_sessions / most_recent / oldest logic.
summary = data["summary"]
summary_table = Table.grid(padding=(0, 1))
summary_table.add_column(style="cyan bold", justify="left")
summary_table.add_column(style="white", justify="left")
summary_table.add_row("Total:", str(summary["total"]))
summary_table.add_row("Most Recent:", summary["most_recent"] or "")
summary_table.add_row("Oldest:", summary["oldest"] or "")
summary_table.add_row("Total Messages:", str(summary["total_messages"]))
summary_table.add_row("Storage:", summary["storage"])
console.print(Panel(summary_table, title="Summary", border_style="blue"))
console.print()
console.print(f"[green]✓ OK[/green] {len(sessions)} sessions listed")
# 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()
@@ -634,50 +411,45 @@ def show(
)
return
# Session summary panel — field order per spec: ID, Actor, Messages,
# Created, Updated, Automation (docs/specification.md §agents session show)
# Session summary panel
details = (
f"[bold]ID:[/bold] {session.session_id}\n"
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')}\n"
f"[bold]Automation:[/bold] {session.automation or '(none)'}"
f"[bold]Updated:[/bold] {session.updated_at.strftime('%Y-%m-%d %H:%M')}"
)
console.print(Panel(details, title="Session Summary", expand=False))
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("Text")
msg_table.add_column("Content")
msg_table.add_column("Timestamp", style="dim")
for msg in recent:
text = msg.content
if len(text) > 80:
text = text[:77] + "..."
msg_table.add_row(msg.role.value, text)
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 — spec requires Plan ID / Phase / State columns
if session.linked_plans:
plan_table = Table(title="Linked Plans", show_header=True)
plan_table.add_column("Plan ID", style="cyan")
plan_table.add_column("Phase")
plan_table.add_column("State")
for lp in session.linked_plans:
plan_table.add_row(lp.plan_id, lp.phase, lp.state)
console.print(Panel(plan_table, title="Linked Plans", expand=False))
elif session.linked_plan_ids:
# Fallback: only flat IDs available
# 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"[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))
@@ -704,8 +476,6 @@ def show(
)
console.print(Panel(budget_text, title="Cost Budget", expand=False))
console.print("[green bold]✓ OK[/green bold] Session details loaded")
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
@@ -728,10 +498,6 @@ def delete(
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
fmt: Annotated[
OutputFormat,
typer.Option("--format", help=_FORMAT_HELP),
] = OutputFormat.RICH,
) -> None:
"""Delete a session permanently.
@@ -744,9 +510,8 @@ def delete(
try:
service = _get_session_service()
# Verify session exists before prompting and capture message count
session_to_delete = service.get(session_id)
message_count = session_to_delete.message_count
# Verify session exists before prompting
service.get(session_id)
if not yes:
confirm = typer.confirm(f"Delete session {session_id}?", default=False)
@@ -755,53 +520,7 @@ def delete(
raise typer.Abort()
service.delete(session_id)
# Rich output: render Deletion Summary and Cleanup panels
if fmt == OutputFormat.RICH:
# Deletion Summary panel
summary_table = Table.grid(padding=(0, 1))
summary_table.add_column(style="cyan bold", justify="left")
summary_table.add_column(style="white", justify="left")
summary_table.add_row("Session:", session_id)
summary_table.add_row("ID:", session_id)
summary_table.add_row("Messages:", f"{message_count} removed")
summary_table.add_row("Storage:", "0 KB freed")
summary_table.add_row("Plans Orphaned:", "0")
console.print(
Panel(summary_table, title="Deletion Summary", border_style="blue")
)
console.print()
# Cleanup panel
cleanup_table = Table.grid(padding=(0, 1))
cleanup_table.add_column(style="cyan bold", justify="left")
cleanup_table.add_column(style="white", justify="left")
cleanup_table.add_row("Backups:", "none")
cleanup_table.add_row("Logs:", "preserved")
cleanup_table.add_row("Context:", "cleared")
cleanup_table.add_row("Checkpoints:", "none")
console.print(Panel(cleanup_table, title="Cleanup", border_style="blue"))
console.print()
console.print("[green]✓ OK[/green] Session deleted")
elif fmt == OutputFormat.COLOR:
# Color format: human-readable Rich-styled line, no envelope.
console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
else:
# Machine-readable formats (json/yaml/plain): emit a structured
# envelope so callers can parse the success message reliably.
typer.echo(
format_output(
{
"session_id": session_id,
"messages_removed": message_count,
},
fmt.value,
command=f"agents session delete {session_id}",
messages=[{"level": "ok", "text": "Session deleted"}],
)
)
console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
@@ -830,12 +549,16 @@ def export_session(
typer.Option("--force", help="Overwrite existing output file"),
] = False,
fmt: Annotated[
str,
str | None,
typer.Option(
"--format",
help="Export format: json (default) or md (Markdown transcript)",
"-f",
help=(
"Output data format (JSON only). Use --output-format for CLI "
"presentation style (rich/json/yaml/plain)."
),
),
] = "json",
] = None,
output_format: Annotated[
str | None,
typer.Option(
@@ -848,55 +571,33 @@ def export_session(
),
] = None,
) -> None:
"""Export a session as JSON or Markdown.
"""Export a session as a portable JSON file.
Writes the full session data to a file or stdout. The default format is
JSON (canonical, importable). Use ``--format md`` for a human-readable
Markdown transcript (lossy — cannot be re-imported).
Writes the full session data to a file or stdout in JSON format.
The exported file can be re-imported with ``agents session import``.
On success, three Rich panels are displayed: "Session Export" (session ID,
output path, message count, file size, format), "Contents" (messages, plan
references, metadata keys, actor config, schema version), and "Integrity"
(checksum, encrypted flag).
Per spec §1986, the CLI export command produces JSON only.
Markdown export is available via the TUI ``/session:export --format md`` command.
Examples:
agents session export 01HXYZ...
agents session export 01HXYZ... -o session.json
agents session export 01HXYZ... -o session.json --force
agents session export 01HXYZ... --format md -o session.md
agents session export 01HXYZ... --output-format json
"""
if fmt not in ("json", "md"):
console.print(f"[red]Invalid format:[/red] {fmt!r}. Use 'json' or 'md'.")
# CLI export produces JSON data only — reject incompatible data formats.
if fmt is not None and fmt != "json":
console.print(
f"[red]Error:[/red] Invalid format {fmt!r}. "
"CLI export supports JSON only. Use the TUI for Markdown export."
)
raise typer.Exit(1)
structured_output = output_format in ("json", "yaml", "plain")
try:
service = _get_session_service()
json_data: dict[str, Any] = {}
if fmt == "md":
# Markdown export: load full session with messages
session = service.get(session_id)
# Load messages via export_session to populate session.messages
json_data = service.export_session(session_id)
messages = [
SessionMessage(
message_id=m["message_id"],
role=MessageRole(m["role"]),
content=m["content"],
sequence=m["sequence"],
timestamp=m["timestamp"],
metadata=m.get("metadata", {}),
tool_call_id=m.get("tool_call_id"),
)
for m in json_data.get("messages", [])
]
session.messages = messages
content = session.as_export_markdown()
else:
json_data = service.export_session(session_id)
content = json.dumps(json_data, indent=2, default=str)
data = service.export_session(session_id)
content = json.dumps(data, indent=2, default=str)
if output is not None:
if output.exists() and not force:
@@ -908,6 +609,7 @@ def export_session(
# Create parent directories if needed
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content, encoding="utf-8")
console.print(f"[green]✓ OK[/green] Session exported to {output}")
elif not structured_output:
# Structured output formats suppress the raw content emission so
# the envelope is the only thing on stdout (and remains valid JSON).
@@ -918,9 +620,9 @@ def export_session(
envelope_data: dict[str, Any] = {
"session_id": session_id,
"output": str(output) if output is not None else None,
"format": fmt,
"messages_exported": len(json_data.get("messages", [])),
"schema_version": json_data.get("schema_version", "v1"),
"format": output_format,
"messages_exported": len(data.get("messages", [])),
"schema_version": data.get("schema_version", "v1"),
}
typer.echo(
format_output(
@@ -930,15 +632,6 @@ def export_session(
messages=[{"level": "ok", "text": "Export completed"}],
)
)
else:
# Render Rich panels for both file and stdout export paths
_render_export_panels(
session_id=session_id,
output=output,
content=content,
export_data=json_data,
fmt=fmt,
)
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
@@ -955,83 +648,6 @@ def export_session(
raise typer.Exit(1) from exc
def _render_export_panels(
*,
session_id: str,
output: Path | None,
content: str,
export_data: dict[str, Any],
fmt: str,
) -> None:
"""Render the three spec-required Rich panels for ``agents session export``.
Displays:
- "Session Export" panel: session ID, output path, message count, size, format
- "Contents" panel: messages, plan references, metadata keys, actor config,
schema version
- "Integrity" panel: checksum, encrypted flag
- ``✓ OK Export completed`` success line
"""
# Compute file size from content
size_bytes = len(content.encode("utf-8"))
if size_bytes < 1024:
size_str = f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
size_str = f"{size_bytes // 1024} KB"
else:
size_str = f"{size_bytes // (1024 * 1024)} MB"
# Derive display values from export data (JSON format) or defaults (md)
message_count = len(export_data.get("messages", []))
plan_refs = len(export_data.get("linked_plan_ids", []))
metadata_keys = len(export_data.get("metadata", {}))
actor_config = "included" if export_data.get("actor_name") else "none"
schema_version = export_data.get("schema_version", "v1")
checksum_raw = export_data.get("checksum", "")
checksum_display = (
f"sha256:{checksum_raw[:4]}...{checksum_raw[-4:]}"
if len(checksum_raw) >= 8
else checksum_raw or "n/a"
)
output_display = str(output) if output is not None else "(stdout)"
format_display = "JSON" if fmt == "json" else "Markdown"
# Session Export panel
export_table = Table.grid(padding=(0, 1))
export_table.add_column(style="cyan bold", justify="left")
export_table.add_column(style="white", justify="left")
export_table.add_row("Session:", session_id)
export_table.add_row("Output:", output_display)
export_table.add_row("Messages:", str(message_count))
export_table.add_row("Size:", size_str)
export_table.add_row("Format:", format_display)
console.print(Panel(export_table, title="Session Export", border_style="blue"))
console.print()
# Contents panel
contents_table = Table.grid(padding=(0, 1))
contents_table.add_column(style="blue bold", justify="left")
contents_table.add_column(style="white", justify="left")
contents_table.add_row("Messages:", str(message_count))
contents_table.add_row("Plan References:", str(plan_refs))
contents_table.add_row("Metadata Keys:", str(metadata_keys))
contents_table.add_row("Actor Config:", actor_config)
contents_table.add_row("Schema Version:", str(schema_version))
console.print(Panel(contents_table, title="Contents", border_style="blue"))
console.print()
# Integrity panel
integrity_table = Table.grid(padding=(0, 1))
integrity_table.add_column(style="magenta bold", justify="left")
integrity_table.add_column(style="white", justify="left")
integrity_table.add_row("Checksum:", checksum_display)
integrity_table.add_row("Encrypted:", "no")
console.print(Panel(integrity_table, title="Integrity", border_style="blue"))
console.print()
console.print("[green]✓ OK[/green] Export completed")
@app.command("import")
def import_session(
input_file: Annotated[
@@ -1065,8 +681,6 @@ def import_session(
try:
service = _get_session_service()
schema_version = data.get("schema_version", "unknown")
actor_name = data.get("actor_name")
session = service.import_session(data)
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
@@ -1074,8 +688,8 @@ def import_session(
"session_id": session.session_id,
"input": str(input_file),
"message_count": session.message_count,
"schema_version": schema_version,
"actor_ref": "resolved" if actor_name else "none",
"schema_version": data.get("schema_version", "v1"),
"actor_ref": "resolved" if data.get("actor_name") else "none",
}
typer.echo(
format_output(
@@ -1087,29 +701,14 @@ def import_session(
)
return
# Session Import panel
session_details = (
f"[bold]Input:[/bold] {input_file}\n"
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]Schema:[/bold] {schema_version}"
f"[bold]Namespace:[/bold] {session.namespace}"
)
console.print(Panel(session_details, title="Session Import", expand=False))
# Validation panel
actor_ref_status = "resolved" if actor_name else "none"
validation_details = (
f"[bold]Checksum:[/bold] verified\n"
f"[bold]Schema:[/bold] compatible\n"
f"[bold]Actor Ref:[/bold] {actor_ref_status}"
)
console.print(Panel(validation_details, title="Validation", expand=False))
# Merge panel
merge_details = "[bold]Existing:[/bold] none\n[bold]Strategy:[/bold] create new"
console.print(Panel(merge_details, title="Merge", expand=False))
console.print("[green]✓ OK[/green] Import completed")
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}")
@@ -1141,38 +740,51 @@ def tell(
bool,
typer.Option("--stream", help="Stream response in real-time"),
] = False,
fmt: Annotated[
OutputFormat,
typer.Option(
"--format",
"-f",
help=_FORMAT_HELP,
),
] = OutputFormat.RICH,
) -> None:
"""Send a message to a session.
Appends a user message and invokes the session's bound orchestrator actor
(or ``--actor`` override) via the A2A ``message/send`` operation.
The actor's real response is persisted and token usage is tracked.
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"
agents session tell --session 01HXYZ... --format json "Hello"
"""
try:
_do_session_tell(
service = _get_session_service()
# Append user message
service.append_message(
session_id=session_id,
prompt=prompt,
actor_override=actor,
stream=stream,
fmt=fmt,
role=MessageRole.USER,
content=prompt,
)
except SessionActorNotConfiguredError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1) from exc
# 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
@@ -1183,95 +795,3 @@ def tell(
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
except Exception as exc:
if not isinstance(exc, typer.Exit):
_log.exception("session tell failed with unexpected error")
console.print(
"[red]Error:[/red] An unexpected error occurred. "
"Check the logs for details."
)
raise typer.Exit(1) from exc
raise
def _do_session_tell(
session_id: str,
prompt: str,
actor_override: str | None,
stream: bool,
fmt: OutputFormat,
) -> None:
"""Core implementation of ``session tell``, extracted for testability.
Both streaming and non-streaming paths route through
:class:`~cleveragents.a2a.facade.A2aLocalFacade` per the spec's
A2A protocol mapping (message/send, message/stream; addresses C3, C5).
"""
# Validate --actor format before passing to the workflow (m8).
if actor_override is not None and not re.match(
r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$",
actor_override,
):
console.print(
f"[red]Error:[/red] Invalid actor name {actor_override!r}. "
"Expected format: 'namespace/name' "
"(e.g. 'openai/gpt-4')."
)
raise typer.Exit(1)
# Route through A2aLocalFacade per the spec's A2A protocol mapping.
# message/send → SessionWorkflow.tell() (non-streaming)
# message/stream → falls back to non-streaming with streamed=false
# (true SSE-based streaming is deferred; acceptable per the spec).
operation = "message/stream" if stream else "message/send"
result_dict = _facade_dispatch(
operation,
{
"session_id": session_id,
"message": prompt,
"actor": actor_override,
},
)
assistant_content = result_dict.get("assistant_message", "")
usage = result_dict.get("usage", {})
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
data: dict[str, object] = {
"session_id": session_id,
"user_message": prompt,
"assistant_message": assistant_content,
"usage": usage,
}
typer.echo(format_output(data, fmt))
return
from rich.markup import escape
console.print(f"[dim]user:[/dim] {escape(prompt)}")
console.print(f"[cyan]assistant:[/cyan] {escape(assistant_content)}")
_print_usage_panel(
input_tokens=int(usage.get("input_tokens", 0)),
output_tokens=int(usage.get("output_tokens", 0)),
cost=float(usage.get("cost_usd", 0.0)),
duration_ms=float(usage.get("duration_ms", 0.0)),
tool_calls=int(usage.get("tool_calls", 0)),
)
def _print_usage_panel(
input_tokens: int,
output_tokens: int,
cost: float,
duration_ms: float,
tool_calls: int,
) -> None:
"""Render a Rich Usage panel summarising token and cost metrics."""
lines = [
f"[bold]Input tokens:[/bold] {input_tokens}",
f"[bold]Output tokens:[/bold] {output_tokens}",
f"[bold]Est. cost:[/bold] ${cost:.6f}",
f"[bold]Duration:[/bold] {duration_ms / 1000:.1f}s",
f"[bold]Tool calls:[/bold] {tool_calls}",
]
console.print(Panel("\n".join(lines), title="Usage", expand=False))