diff --git a/features/session_cli.feature b/features/session_cli.feature index e58ced64b..c203cc08e 100644 --- a/features/session_cli.feature +++ b/features/session_cli.feature @@ -69,17 +69,36 @@ Feature: Session CLI commands And the session CLI output should contain "Session not found" # Export command tests - Scenario: Export session to stdout + Scenario: Export session to stdout renders Rich panels Given there is a mocked session for export When I run session CLI export with no output file Then the session CLI export should succeed - And the session CLI output should be valid JSON + And the session CLI output should contain "Session Export" + And the session CLI output should contain "Contents" + And the session CLI output should contain "Integrity" + And the session CLI output should contain "Export completed" - Scenario: Export session to file + Scenario: Export session to file renders Rich panels Given there is a mocked session for export When I run session CLI export with --output to a temp file Then the session CLI export should succeed And the exported file should exist + And the session CLI output should contain "Session Export" + And the session CLI output should contain "Contents" + And the session CLI output should contain "Integrity" + And the session CLI output should contain "Export completed" + + Scenario: Export session to file shows correct output path in panel + Given there is a mocked session for export + When I run session CLI export with --output to a temp file + Then the session CLI export should succeed + And the session CLI output should contain "Output:" + + Scenario: Export session to stdout shows stdout indicator in panel + Given there is a mocked session for export + When I run session CLI export with no output file + Then the session CLI export should succeed + And the session CLI output should contain "(stdout)" Scenario: Export refuses overwrite without --force Given there is a mocked session for export @@ -93,6 +112,7 @@ Feature: Session CLI commands And there is an existing export file When I run session CLI export to an existing file with --force Then the session CLI export should succeed + And the session CLI output should contain "Export completed" Scenario: Export non-existent session When I run session CLI export with a non-existent session ID diff --git a/robot/helper_session_cli.py b/robot/helper_session_cli.py index a66d48323..4dc21ad88 100644 --- a/robot/helper_session_cli.py +++ b/robot/helper_session_cli.py @@ -181,7 +181,7 @@ def delete_yes() -> None: def export_import_roundtrip() -> None: - """Test export to file then import back.""" + """Test export to file then import back, verifying Rich panels are rendered.""" sid = str(ULID()) svc = _setup_service() @@ -195,10 +195,22 @@ def export_import_roundtrip() -> None: os.unlink(path) try: - # Export + # Export — verify Rich panels are rendered result = runner.invoke(session_app, ["export", sid, "--output", path]) assert result.exit_code == 0, f"export exit={result.exit_code}: {result.output}" - assert os.path.exists(path) + assert os.path.exists(path), "Export file was not created" + assert "Session Export" in result.output, ( + f"Missing 'Session Export' panel in output:\n{result.output}" + ) + assert "Contents" in result.output, ( + f"Missing 'Contents' panel in output:\n{result.output}" + ) + assert "Integrity" in result.output, ( + f"Missing 'Integrity' panel in output:\n{result.output}" + ) + assert "Export completed" in result.output, ( + f"Missing 'Export completed' in output:\n{result.output}" + ) # Set up import mock imported = _mock_session(actor_name="openai/gpt-4") @@ -286,6 +298,70 @@ def tell_message() -> None: _teardown() +def export_rich_panels() -> None: + """Test that export renders all three spec-required Rich panels.""" + sid = str(ULID()) + svc = _setup_service() + + session = _mock_session(session_id=sid, actor_name="openai/gpt-4") + svc.get.return_value = session + export_data = session.as_export_dict() + svc.export_session.return_value = export_data + + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + os.unlink(path) + + try: + 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") + finally: + if os.path.exists(path): + os.unlink(path) + _teardown() + + +def export_stdout_rich_panels() -> None: + """Test that stdout export also renders Rich panels.""" + sid = str(ULID()) + svc = _setup_service() + + session = _mock_session(session_id=sid, actor_name="openai/gpt-4") + svc.get.return_value = session + export_data = session.as_export_dict() + svc.export_session.return_value = export_data + + 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") + finally: + _teardown() + + # --------------------------------------------------------------------------- # Dispatcher # --------------------------------------------------------------------------- @@ -300,6 +376,8 @@ _COMMANDS: dict[str, object] = { "delete-yes": delete_yes, "export-import-roundtrip": export_import_roundtrip, "import-rich-panels": import_rich_panels, + "export-rich-panels": export_rich_panels, + "export-stdout-rich-panels": export_stdout_rich_panels, "tell-message": tell_message, } diff --git a/robot/session_cli.robot b/robot/session_cli.robot index 917dbb384..e9f1c7bfe 100644 --- a/robot/session_cli.robot +++ b/robot/session_cli.robot @@ -55,6 +55,8 @@ Session Delete With Yes Session Export Import Roundtrip [Documentation] Verify export/import round-trip ${result}= Run Process ${PYTHON} ${HELPER} export-import-roundtrip cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} session-cli-export-import-roundtrip-ok @@ -64,6 +66,22 @@ Session Import Rich Output Panels Should Be Equal As Integers ${result.rc} 0 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 + ${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 + +Session Export Stdout Rich Panels + [Documentation] Verify that ``session export`` to stdout also renders Rich panels + ${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 + Session Tell Appends Message [Documentation] Verify that ``session tell`` appends a message ${result}= Run Process ${PYTHON} ${HELPER} tell-message cwd=${WORKSPACE} diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index c237e781c..99b412fbf 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -565,6 +565,11 @@ def export_session( 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). + Examples: agents session export 01HXYZ... agents session export 01HXYZ... -o session.json @@ -577,12 +582,13 @@ def export_session( 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 - export_data = service.export_session(session_id) + json_data = service.export_session(session_id) messages = [ SessionMessage( message_id=m["message_id"], @@ -593,13 +599,13 @@ def export_session( metadata=m.get("metadata", {}), tool_call_id=m.get("tool_call_id"), ) - for m in export_data.get("messages", []) + for m in json_data.get("messages", []) ] session.messages = messages content = session.as_export_markdown() else: - 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: @@ -611,10 +617,18 @@ 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}") else: typer.echo(content) + # 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}") raise typer.Exit(1) from exc @@ -629,6 +643,83 @@ 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[