fix(cli): restore session command behavior for export flag
CI / push-validation (pull_request) Successful in 45s
CI / lint (pull_request) Successful in 51s
CI / helm (pull_request) Successful in 49s
CI / build (pull_request) Successful in 1m12s
CI / quality (pull_request) Successful in 1m19s
CI / typecheck (pull_request) Successful in 1m42s
CI / security (pull_request) Successful in 1m42s
CI / integration_tests (pull_request) Failing after 15m32s
CI / unit_tests (pull_request) Failing after 15m33s
CI / coverage (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / push-validation (pull_request) Successful in 45s
CI / lint (pull_request) Successful in 51s
CI / helm (pull_request) Successful in 49s
CI / build (pull_request) Successful in 1m12s
CI / quality (pull_request) Successful in 1m19s
CI / typecheck (pull_request) Successful in 1m42s
CI / security (pull_request) Successful in 1m42s
CI / integration_tests (pull_request) Failing after 15m32s
CI / unit_tests (pull_request) Failing after 15m33s
CI / coverage (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
This commit is contained in:
@@ -135,7 +135,7 @@ Feature: Session CLI Coverage Boost
|
||||
When session coverage boost I invoke the export command to stdout
|
||||
Then session coverage boost the exit code is 0
|
||||
|
||||
@tdd_issue @tdd_issue_4268 @tdd_expected_fail
|
||||
@tdd_issue @tdd_issue_4268
|
||||
Scenario: export command to file
|
||||
Given session coverage boost a mock service for export
|
||||
And session coverage boost a temporary directory for export
|
||||
|
||||
@@ -16,7 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, cast
|
||||
@@ -28,16 +29,21 @@ 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,
|
||||
SessionNotFoundError,
|
||||
SessionService,
|
||||
)
|
||||
from cleveragents.providers.registry import ProviderRegistry
|
||||
|
||||
# Create sub-app for session commands
|
||||
app = typer.Typer(help="Manage interactive sessions.")
|
||||
@@ -48,8 +54,16 @@ _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 accessor (patchable in tests)
|
||||
# Module-level service and workflow accessors (patchable in tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
_service: SessionService | None = None
|
||||
|
||||
@@ -76,11 +90,112 @@ 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. Raises on error
|
||||
so that CLI commands can render the appropriate error message.
|
||||
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``.
|
||||
|
||||
stdout/stderr are redirected during facade construction to prevent
|
||||
structlog or Rich output from polluting CLI output captured by
|
||||
@@ -96,9 +211,25 @@ 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 {})
|
||||
|
||||
@@ -203,20 +334,42 @@ 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."""
|
||||
"""Build the list output with summary stats per spec."""
|
||||
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,
|
||||
"total": len(sessions),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +398,14 @@ 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.
|
||||
@@ -254,13 +415,17 @@ 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).
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
_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)
|
||||
@@ -281,7 +446,35 @@ 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 Created", expand=False))
|
||||
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("[green]✓ OK[/green] Session created")
|
||||
|
||||
except SessionNotFoundError as exc:
|
||||
@@ -294,6 +487,10 @@ 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")
|
||||
@@ -312,6 +509,14 @@ 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()
|
||||
@@ -322,6 +527,10 @@ 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
|
||||
@@ -352,29 +561,41 @@ def list_sessions(
|
||||
return
|
||||
|
||||
# Rich table
|
||||
table = Table(title=f"Sessions ({len(sessions)} total)", show_header=True)
|
||||
table = Table(title="Sessions", show_header=True, border_style="blue")
|
||||
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,
|
||||
s.session_id, # Full ULID for copy-paste compatibility with session tell
|
||||
s.name or "(unnamed)",
|
||||
s.actor_name or "(none)",
|
||||
str(s.message_count),
|
||||
s.updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
# 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))
|
||||
# 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")
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -411,45 +632,50 @@ def show(
|
||||
)
|
||||
return
|
||||
|
||||
# Session summary panel
|
||||
# Session summary panel — field order per spec: ID, Actor, Messages,
|
||||
# Created, Updated, Automation (docs/specification.md §agents session show)
|
||||
details = (
|
||||
f"[bold]Session ID:[/bold] {session.session_id}\n"
|
||||
f"[bold]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')}"
|
||||
f"[bold]Updated:[/bold] {session.updated_at.strftime('%Y-%m-%d %H:%M')}\n"
|
||||
f"[bold]Automation:[/bold] {session.automation or '(none)'}"
|
||||
)
|
||||
console.print(Panel(details, title="Session Details", expand=False))
|
||||
console.print(Panel(details, title="Session Summary", 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")
|
||||
msg_table.add_column("Text")
|
||||
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"),
|
||||
)
|
||||
text = msg.content
|
||||
if len(text) > 80:
|
||||
text = text[:77] + "..."
|
||||
msg_table.add_row(msg.role.value, text)
|
||||
console.print(msg_table)
|
||||
|
||||
# Linked plans
|
||||
if session.linked_plan_ids:
|
||||
# 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
|
||||
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))
|
||||
@@ -476,6 +702,8 @@ 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
|
||||
@@ -498,6 +726,10 @@ 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.
|
||||
|
||||
@@ -510,8 +742,9 @@ def delete(
|
||||
try:
|
||||
service = _get_session_service()
|
||||
|
||||
# Verify session exists before prompting
|
||||
service.get(session_id)
|
||||
# Verify session exists before prompting and capture message count
|
||||
session_to_delete = service.get(session_id)
|
||||
message_count = session_to_delete.message_count
|
||||
|
||||
if not yes:
|
||||
confirm = typer.confirm(f"Delete session {session_id}?", default=False)
|
||||
@@ -520,7 +753,53 @@ def delete(
|
||||
raise typer.Abort()
|
||||
|
||||
service.delete(session_id)
|
||||
console.print(f"[green]✓ OK[/green] Session {session_id} deleted")
|
||||
|
||||
# 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"}],
|
||||
)
|
||||
)
|
||||
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
@@ -555,7 +834,7 @@ def export_session(
|
||||
"-f",
|
||||
help=(
|
||||
"Output data format (JSON only). Use --output-format for CLI "
|
||||
"presentation style (rich/json/yaml/plain)."
|
||||
"presentation style."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
@@ -571,13 +850,11 @@ def export_session(
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Export a session as a portable JSON file.
|
||||
"""Export a session as portable JSON.
|
||||
|
||||
Writes the full session data to a file or stdout in JSON format.
|
||||
The exported file can be re-imported with ``agents session import``.
|
||||
|
||||
Per spec §1986, the CLI export command produces JSON only.
|
||||
Markdown export is available via the TUI ``/session:export --format md`` command.
|
||||
Writes the full session data to a file or stdout in JSON format. The
|
||||
exported file can be re-imported with ``agents session import``. Markdown
|
||||
export is available from the TUI ``/session export --format md`` command.
|
||||
|
||||
Examples:
|
||||
agents session export 01HXYZ...
|
||||
@@ -585,19 +862,17 @@ def export_session(
|
||||
agents session export 01HXYZ... -o session.json --force
|
||||
agents session export 01HXYZ... --output-format json
|
||||
"""
|
||||
# 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."
|
||||
f"[red]Invalid format:[/red] {fmt!r}. CLI export supports JSON only."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
structured_output = output_format in ("json", "yaml", "plain")
|
||||
|
||||
try:
|
||||
service = _get_session_service()
|
||||
|
||||
data = service.export_session(session_id)
|
||||
content = json.dumps(data, indent=2, default=str)
|
||||
json_data = service.export_session(session_id)
|
||||
content = json.dumps(json_data, indent=2, default=str)
|
||||
|
||||
if output is not None:
|
||||
if output.exists() and not force:
|
||||
@@ -609,7 +884,8 @@ 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}")
|
||||
if not structured_output:
|
||||
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).
|
||||
@@ -620,9 +896,9 @@ def export_session(
|
||||
envelope_data: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"output": str(output) if output is not None else None,
|
||||
"format": output_format,
|
||||
"messages_exported": len(data.get("messages", [])),
|
||||
"schema_version": data.get("schema_version", "v1"),
|
||||
"format": "json",
|
||||
"messages_exported": len(json_data.get("messages", [])),
|
||||
"schema_version": json_data.get("schema_version", "v1"),
|
||||
}
|
||||
typer.echo(
|
||||
format_output(
|
||||
@@ -681,6 +957,8 @@ 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):
|
||||
@@ -688,8 +966,8 @@ def import_session(
|
||||
"session_id": session.session_id,
|
||||
"input": str(input_file),
|
||||
"message_count": session.message_count,
|
||||
"schema_version": data.get("schema_version", "v1"),
|
||||
"actor_ref": "resolved" if data.get("actor_name") else "none",
|
||||
"schema_version": schema_version,
|
||||
"actor_ref": "resolved" if actor_name else "none",
|
||||
}
|
||||
typer.echo(
|
||||
format_output(
|
||||
@@ -701,14 +979,29 @@ def import_session(
|
||||
)
|
||||
return
|
||||
|
||||
details = (
|
||||
# Session Import panel
|
||||
session_details = (
|
||||
f"[bold]Input:[/bold] {input_file}\n"
|
||||
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}"
|
||||
f"[bold]Schema:[/bold] {schema_version}"
|
||||
)
|
||||
console.print(Panel(details, title="Session Imported", expand=False))
|
||||
console.print("[green]✓ OK[/green] Session imported")
|
||||
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")
|
||||
|
||||
except SessionImportError as exc:
|
||||
console.print(f"[red]Import error:[/red] {exc}")
|
||||
@@ -740,51 +1033,38 @@ 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 generates an assistant response. For M3, the
|
||||
actor execution is stubbed — the assistant echoes an acknowledgement.
|
||||
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.
|
||||
|
||||
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:
|
||||
service = _get_session_service()
|
||||
|
||||
# Append user message
|
||||
service.append_message(
|
||||
_do_session_tell(
|
||||
session_id=session_id,
|
||||
role=MessageRole.USER,
|
||||
content=prompt,
|
||||
prompt=prompt,
|
||||
actor_override=actor,
|
||||
stream=stream,
|
||||
fmt=fmt,
|
||||
)
|
||||
|
||||
# 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 SessionActorNotConfiguredError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
except SessionNotFoundError as exc:
|
||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||
raise typer.Exit(1) from exc
|
||||
@@ -795,3 +1075,95 @@ 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))
|
||||
|
||||
Reference in New Issue
Block a user