diff --git a/features/session_cli_coverage_boost.feature b/features/session_cli_coverage_boost.feature index 056e998ac..d5ae97d99 100644 --- a/features/session_cli_coverage_boost.feature +++ b/features/session_cli_coverage_boost.feature @@ -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 diff --git a/features/tui_session_export_import.feature b/features/tui_session_export_import.feature index c1599b3d8..6e4efb566 100644 --- a/features/tui_session_export_import.feature +++ b/features/tui_session_export_import.feature @@ -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 " 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" diff --git a/robot/helper_session_cli.py b/robot/helper_session_cli.py index ec81524f5..4a12f30ae 100644 --- a/robot/helper_session_cli.py +++ b/robot/helper_session_cli.py @@ -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() diff --git a/robot/session_cli.robot b/robot/session_cli.robot index e9f1c7bfe..fb6976335 100644 --- a/robot/session_cli.robot +++ b/robot/session_cli.robot @@ -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 diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 4416ef265..cc40bfd91 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -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[