fix(cli): add spec-required Session Export, Contents, and Integrity panels to agents session export
CI / lint (pull_request) Successful in 26s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 33s
CI / security (pull_request) Successful in 1m1s
CI / build (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 6m52s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 16m23s
CI / integration_tests (pull_request) Failing after 23m3s
CI / coverage (pull_request) Successful in 10m45s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m43s
CI / lint (pull_request) Successful in 26s
CI / typecheck (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 33s
CI / security (pull_request) Successful in 1m1s
CI / build (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 6m52s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 16m23s
CI / integration_tests (pull_request) Failing after 23m3s
CI / coverage (pull_request) Successful in 10m45s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m43s
Implements the three Rich panels required by specification.md §"agents session export"
(lines 1987–2115) that were missing from the export_session() command.
Changes:
- Add _render_export_panels() helper that renders three spec-required panels:
* "Session Export" panel: session ID, output path, message count, file size, format
* "Contents" panel: messages, plan references, metadata keys, actor config, schema version
* "Integrity" panel: checksum (sha256:xxxx...xxxx), encrypted flag
- Fix success message from "Session exported to {output}" to "Export completed"
- Fix stdout export path to also render Rich panels (previously only printed raw JSON)
- Keep --format and --force options (useful extensions, do not break spec compliance)
- Update Behave scenarios in features/session_cli.feature to verify panel rendering
- Update Robot Framework helper and test file with new export panel assertions
ISSUES CLOSED: #3424
This commit is contained in:
@@ -69,17 +69,36 @@ Feature: Session CLI commands
|
|||||||
And the session CLI output should contain "Session not found"
|
And the session CLI output should contain "Session not found"
|
||||||
|
|
||||||
# Export command tests
|
# Export command tests
|
||||||
Scenario: Export session to stdout
|
Scenario: Export session to stdout renders Rich panels
|
||||||
Given there is a mocked session for export
|
Given there is a mocked session for export
|
||||||
When I run session CLI export with no output file
|
When I run session CLI export with no output file
|
||||||
Then the session CLI export should succeed
|
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
|
Given there is a mocked session for export
|
||||||
When I run session CLI export with --output to a temp file
|
When I run session CLI export with --output to a temp file
|
||||||
Then the session CLI export should succeed
|
Then the session CLI export should succeed
|
||||||
And the exported file should exist
|
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
|
Scenario: Export refuses overwrite without --force
|
||||||
Given there is a mocked session for export
|
Given there is a mocked session for export
|
||||||
@@ -93,6 +112,7 @@ Feature: Session CLI commands
|
|||||||
And there is an existing export file
|
And there is an existing export file
|
||||||
When I run session CLI export to an existing file with --force
|
When I run session CLI export to an existing file with --force
|
||||||
Then the session CLI export should succeed
|
Then the session CLI export should succeed
|
||||||
|
And the session CLI output should contain "Export completed"
|
||||||
|
|
||||||
Scenario: Export non-existent session
|
Scenario: Export non-existent session
|
||||||
When I run session CLI export with a non-existent session ID
|
When I run session CLI export with a non-existent session ID
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ def delete_yes() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def export_import_roundtrip() -> 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())
|
sid = str(ULID())
|
||||||
svc = _setup_service()
|
svc = _setup_service()
|
||||||
|
|
||||||
@@ -195,10 +195,22 @@ def export_import_roundtrip() -> None:
|
|||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Export
|
# Export — verify Rich panels are rendered
|
||||||
result = runner.invoke(session_app, ["export", sid, "--output", path])
|
result = runner.invoke(session_app, ["export", sid, "--output", path])
|
||||||
assert result.exit_code == 0, f"export exit={result.exit_code}: {result.output}"
|
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
|
# Set up import mock
|
||||||
imported = _mock_session(actor_name="openai/gpt-4")
|
imported = _mock_session(actor_name="openai/gpt-4")
|
||||||
@@ -286,6 +298,70 @@ def tell_message() -> None:
|
|||||||
_teardown()
|
_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
|
# Dispatcher
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -300,6 +376,8 @@ _COMMANDS: dict[str, object] = {
|
|||||||
"delete-yes": delete_yes,
|
"delete-yes": delete_yes,
|
||||||
"export-import-roundtrip": export_import_roundtrip,
|
"export-import-roundtrip": export_import_roundtrip,
|
||||||
"import-rich-panels": import_rich_panels,
|
"import-rich-panels": import_rich_panels,
|
||||||
|
"export-rich-panels": export_rich_panels,
|
||||||
|
"export-stdout-rich-panels": export_stdout_rich_panels,
|
||||||
"tell-message": tell_message,
|
"tell-message": tell_message,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Session Delete With Yes
|
|||||||
Session Export Import Roundtrip
|
Session Export Import Roundtrip
|
||||||
[Documentation] Verify export/import round-trip
|
[Documentation] Verify export/import round-trip
|
||||||
${result}= Run Process ${PYTHON} ${HELPER} export-import-roundtrip cwd=${WORKSPACE}
|
${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 Be Equal As Integers ${result.rc} 0
|
||||||
Should Contain ${result.stdout} session-cli-export-import-roundtrip-ok
|
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 Be Equal As Integers ${result.rc} 0
|
||||||
Should Contain ${result.stdout} session-cli-import-rich-panels-ok
|
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
|
Session Tell Appends Message
|
||||||
[Documentation] Verify that ``session tell`` appends a message
|
[Documentation] Verify that ``session tell`` appends a message
|
||||||
${result}= Run Process ${PYTHON} ${HELPER} tell-message cwd=${WORKSPACE}
|
${result}= Run Process ${PYTHON} ${HELPER} tell-message cwd=${WORKSPACE}
|
||||||
|
|||||||
@@ -565,6 +565,11 @@ def export_session(
|
|||||||
JSON (canonical, importable). Use ``--format md`` for a human-readable
|
JSON (canonical, importable). Use ``--format md`` for a human-readable
|
||||||
Markdown transcript (lossy — cannot be re-imported).
|
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:
|
Examples:
|
||||||
agents session export 01HXYZ...
|
agents session export 01HXYZ...
|
||||||
agents session export 01HXYZ... -o session.json
|
agents session export 01HXYZ... -o session.json
|
||||||
@@ -577,12 +582,13 @@ def export_session(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
service = _get_session_service()
|
service = _get_session_service()
|
||||||
|
json_data: dict[str, Any] = {}
|
||||||
|
|
||||||
if fmt == "md":
|
if fmt == "md":
|
||||||
# Markdown export: load full session with messages
|
# Markdown export: load full session with messages
|
||||||
session = service.get(session_id)
|
session = service.get(session_id)
|
||||||
# Load messages via export_session to populate session.messages
|
# Load messages via export_session to populate session.messages
|
||||||
export_data = service.export_session(session_id)
|
json_data = service.export_session(session_id)
|
||||||
messages = [
|
messages = [
|
||||||
SessionMessage(
|
SessionMessage(
|
||||||
message_id=m["message_id"],
|
message_id=m["message_id"],
|
||||||
@@ -593,13 +599,13 @@ def export_session(
|
|||||||
metadata=m.get("metadata", {}),
|
metadata=m.get("metadata", {}),
|
||||||
tool_call_id=m.get("tool_call_id"),
|
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
|
session.messages = messages
|
||||||
content = session.as_export_markdown()
|
content = session.as_export_markdown()
|
||||||
else:
|
else:
|
||||||
data = service.export_session(session_id)
|
json_data = service.export_session(session_id)
|
||||||
content = json.dumps(data, indent=2, default=str)
|
content = json.dumps(json_data, indent=2, default=str)
|
||||||
|
|
||||||
if output is not None:
|
if output is not None:
|
||||||
if output.exists() and not force:
|
if output.exists() and not force:
|
||||||
@@ -611,10 +617,18 @@ def export_session(
|
|||||||
# Create parent directories if needed
|
# Create parent directories if needed
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output.write_text(content, encoding="utf-8")
|
output.write_text(content, encoding="utf-8")
|
||||||
console.print(f"[green]✓ OK[/green] Session exported to {output}")
|
|
||||||
else:
|
else:
|
||||||
typer.echo(content)
|
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:
|
except SessionNotFoundError as exc:
|
||||||
console.print(f"[red]Session not found:[/red] {session_id}")
|
console.print(f"[red]Session not found:[/red] {session_id}")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -629,6 +643,83 @@ def export_session(
|
|||||||
raise typer.Exit(1) from exc
|
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")
|
@app.command("import")
|
||||||
def import_session(
|
def import_session(
|
||||||
input_file: Annotated[
|
input_file: Annotated[
|
||||||
|
|||||||
Reference in New Issue
Block a user