Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3467d67b0 | |||
| 72a0a7886f | |||
| 0588495007 | |||
|
3d2c106e6c
|
|||
| f41a884f96 | |||
| f8f7c1cbfa | |||
| 62347d830f |
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[
|
||||
|
||||
Reference in New Issue
Block a user