fix(cli): add --format flag to session export command per spec #1451 #1482

Open
freemo wants to merge 7 commits from bugfix/session-export-format-flag into master
5 changed files with 51 additions and 209 deletions
+1 -1
View File
@@ -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
+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
+20 -128
View File
@@ -36,12 +36,10 @@ from cleveragents.application.services.strategy_resolution import (
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,
)
@@ -830,12 +828,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."
),
),
] = "json",
] = None,
output_format: Annotated[
str | None,
typer.Option(
@@ -848,55 +850,29 @@ def export_session(
),
] = None,
) -> None:
"""Export a session as JSON or Markdown.
"""Export a session as portable JSON.
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).
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).
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...
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'.")
if fmt is not None and fmt != "json":
console.print(
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()
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)
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:
@@ -908,6 +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")
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).
@@ -918,7 +896,7 @@ def export_session(
envelope_data: dict[str, Any] = {
"session_id": session_id,
"output": str(output) if output is not None else None,
"format": fmt,
"format": "json",
"messages_exported": len(json_data.get("messages", [])),
"schema_version": json_data.get("schema_version", "v1"),
}
@@ -930,15 +908,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 +924,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[