feat(tui): implement session export/import (JSON + Markdown)

Add Markdown export format to the session export/import system.

- Add Session.as_markdown_export() domain method that renders a
  human-readable Markdown document (metadata table, messages, token
  usage, linked plans)
- Extract render_session_markdown() and build_session_from_export_dict()
  into a dedicated session_markdown module to keep file sizes manageable
- Extend 'agents session export' CLI command with --format/-f option
  accepting 'json' (default, re-importable) or 'markdown' (human-readable)
- Add Behave BDD tests covering: Markdown export to stdout/file, --force
  overwrite, invalid format rejection, domain method structure, and
  JSON round-trip (export then import preserves actor, messages, namespace)

JSON export/import round-trip fidelity is unchanged; Markdown format is
intentionally read-only (not re-importable).

ISSUES CLOSED: #1004
This commit is contained in:
2026-04-02 07:51:09 +00:00
parent c937abdc09
commit 9aa73cec03
5 changed files with 646 additions and 8 deletions
+81
View File
@@ -0,0 +1,81 @@
Feature: Session export in Markdown format
As a developer
I want to export sessions as human-readable Markdown
So that I can share and review session history outside the tool
Background:
Given a session CLI runner with mocked service
# ── Markdown export via CLI ──────────────────────────────────────────────
Scenario: Export session as Markdown to stdout
Given there is a mocked session for markdown export
When I run session CLI export with --format markdown
Then the session CLI export should succeed
And the session CLI output should contain "# Session"
And the session CLI output should contain "## Metadata"
And the session CLI output should contain "## Token Usage"
Scenario: Export session with messages as Markdown
Given there is a mocked session with messages for markdown export
When I run session CLI export with --format markdown
Then the session CLI export should succeed
And the session CLI output should contain "## Messages"
And the session CLI output should contain "[user]"
Scenario: Export session as Markdown to file
Given there is a mocked session for markdown export
When I run session CLI export with --format markdown to a temp file
Then the session CLI export should succeed
And the exported markdown file should exist
And the exported markdown file should contain "# Session"
Scenario: Export session as Markdown with --force overwrites file
Given there is a mocked session for markdown export
And there is an existing markdown export file
When I run session CLI export with --format markdown --force to existing file
Then the session CLI export should succeed
Scenario: Export session as Markdown refuses overwrite without --force
Given there is a mocked session for markdown export
And there is an existing markdown export file
When I run session CLI export with --format markdown to existing file without --force
Then the session CLI should exit with error
And the session CLI output should contain "File already exists"
Scenario: Export with invalid format is rejected
Given there is a mocked session for markdown export
When I run session CLI export with --format invalid
Then the session CLI should exit with error
And the session CLI output should contain "Invalid format"
# ── Session.as_markdown_export() domain method ───────────────────────────
Scenario: Session.as_markdown_export produces valid Markdown structure
Given a session domain object with actor and messages
When I call as_markdown_export on the session
Then the markdown output should start with "# Session"
And the markdown output should contain a metadata table
And the markdown output should contain a messages section
And the markdown output should contain a token usage section
Scenario: Session.as_markdown_export with no messages omits messages section
Given a session domain object with no messages
When I call as_markdown_export on the session
Then the markdown output should start with "# Session"
And the markdown output should not contain "## Messages"
Scenario: Session.as_markdown_export with linked plans includes plans section
Given a session domain object with linked plans
When I call as_markdown_export on the session
Then the markdown output should contain "## Linked Plans"
# ── Round-trip: JSON export then import preserves data ───────────────────
Scenario: Round-trip export then import preserves session data
Given a session with actor and messages for round-trip
When I export the session to JSON
And I import the exported JSON
Then the imported session should have the same actor name
And the imported session should have the same message count
And the imported session should have the same namespace
@@ -0,0 +1,367 @@
"""Step definitions for session_export_markdown.feature.
Tests Markdown export via the CLI ``agents session export --format markdown``
command and the :meth:`Session.as_markdown_export` domain method.
Round-trip tests verify that JSON export → import preserves session data.
"""
from __future__ import annotations
import os
import tempfile
from datetime import datetime
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from ulid import ULID
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
_SESSION_ID = str(ULID())
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session(
*,
session_id: str | None = None,
actor_name: str | None = None,
messages: list[SessionMessage] | None = None,
linked_plan_ids: list[str] | None = None,
) -> Session:
return Session(
session_id=session_id or str(ULID()),
actor_name=actor_name,
namespace="local",
messages=messages or [],
linked_plan_ids=linked_plan_ids or [],
token_usage=SessionTokenUsage(
input_tokens=100,
output_tokens=50,
estimated_cost=0.005,
),
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _make_message(
role: MessageRole = MessageRole.USER,
content: str = "Hello",
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=str(ULID()),
role=role,
content=content,
sequence=sequence,
timestamp=datetime.now(),
)
def _cleanup(context: Context) -> None:
for path in getattr(context, "_cleanup_paths", []):
if os.path.exists(path):
os.unlink(path)
# ---------------------------------------------------------------------------
# Background (reuses session_cli_steps background step)
# ---------------------------------------------------------------------------
# The "a session CLI runner with mocked service" step is defined in
# session_cli_steps.py and is reused here via Behave's shared step registry.
# ---------------------------------------------------------------------------
# Given — CLI markdown export scenarios
# ---------------------------------------------------------------------------
@given("there is a mocked session for markdown export")
def step_session_for_markdown_export(context: Context) -> None:
session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
context.mock_service.get.return_value = session
context.mock_service.export_session.return_value = session.as_export_dict()
context.session_id = _SESSION_ID
if not hasattr(context, "_cleanup_paths"):
context._cleanup_paths = []
@given("there is a mocked session with messages for markdown export")
def step_session_with_messages_for_markdown_export(context: Context) -> None:
messages = [
_make_message(MessageRole.USER, "Hello there", 0),
_make_message(MessageRole.ASSISTANT, "Hi! How can I help?", 1),
]
session = _make_session(
session_id=_SESSION_ID,
actor_name="openai/gpt-4",
messages=messages,
)
context.mock_service.get.return_value = session
context.mock_service.export_session.return_value = session.as_export_dict()
context.session_id = _SESSION_ID
if not hasattr(context, "_cleanup_paths"):
context._cleanup_paths = []
@given("there is an existing markdown export file")
def step_existing_markdown_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".md")
with os.fdopen(fd, "w") as fh:
fh.write("# Old content\n")
context.existing_md_path = path
if not hasattr(context, "_cleanup_paths"):
context._cleanup_paths = []
context._cleanup_paths.append(path)
# ---------------------------------------------------------------------------
# When — CLI markdown export scenarios
# ---------------------------------------------------------------------------
@when("I run session CLI export with --format markdown")
def step_export_markdown_stdout(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
["export", context.session_id, "--format", "markdown"],
)
@when("I run session CLI export with --format markdown to a temp file")
def step_export_markdown_to_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".md")
os.close(fd)
os.unlink(path)
context.md_export_path = path
if not hasattr(context, "_cleanup_paths"):
context._cleanup_paths = []
context._cleanup_paths.append(path)
context.result = context.runner.invoke(
session_app,
["export", context.session_id, "--format", "markdown", "--output", path],
)
@when("I run session CLI export with --format markdown --force to existing file")
def step_export_markdown_force(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
[
"export",
context.session_id,
"--format",
"markdown",
"--output",
context.existing_md_path,
"--force",
],
)
@when(
"I run session CLI export with --format markdown to existing file without --force"
)
def step_export_markdown_no_force(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
[
"export",
context.session_id,
"--format",
"markdown",
"--output",
context.existing_md_path,
],
)
@when("I run session CLI export with --format invalid")
def step_export_invalid_format(context: Context) -> None:
context.result = context.runner.invoke(
session_app,
["export", context.session_id, "--format", "invalid"],
)
# ---------------------------------------------------------------------------
# Then — CLI markdown export scenarios
# ---------------------------------------------------------------------------
@then("the exported markdown file should exist")
def step_md_file_exists(context: Context) -> None:
assert os.path.exists(context.md_export_path), (
f"Markdown export file not found: {context.md_export_path}"
)
@then('the exported markdown file should contain "{text}"')
def step_md_file_contains(context: Context, text: str) -> None:
with open(context.md_export_path, encoding="utf-8") as fh:
content = fh.read()
assert text in content, f"Expected '{text}' in file content:\n{content[:500]}"
# ---------------------------------------------------------------------------
# Given — domain method scenarios
# ---------------------------------------------------------------------------
@given("a session domain object with actor and messages")
def step_domain_session_with_actor_and_messages(context: Context) -> None:
messages = [
_make_message(MessageRole.USER, "What is the plan?", 0),
_make_message(MessageRole.ASSISTANT, "Here is the plan...", 1),
]
context.domain_session = _make_session(
actor_name="openai/gpt-4",
messages=messages,
)
@given("a session domain object with no messages")
def step_domain_session_no_messages(context: Context) -> None:
context.domain_session = _make_session()
@given("a session domain object with linked plans")
def step_domain_session_with_plans(context: Context) -> None:
plan_id = str(ULID())
context.domain_session = _make_session(linked_plan_ids=[plan_id])
# ---------------------------------------------------------------------------
# When — domain method scenarios
# ---------------------------------------------------------------------------
@when("I call as_markdown_export on the session")
def step_call_as_markdown_export(context: Context) -> None:
context.md_output = context.domain_session.as_markdown_export()
# ---------------------------------------------------------------------------
# Then — domain method scenarios
# ---------------------------------------------------------------------------
@then('the markdown output should start with "{prefix}"')
def step_md_starts_with(context: Context, prefix: str) -> None:
assert context.md_output.startswith(prefix), (
f"Expected output to start with '{prefix}':\n{context.md_output[:200]}"
)
@then("the markdown output should contain a metadata table")
def step_md_has_metadata_table(context: Context) -> None:
assert "## Metadata" in context.md_output, (
f"Expected '## Metadata' in output:\n{context.md_output[:500]}"
)
assert "| Field | Value |" in context.md_output
@then("the markdown output should contain a messages section")
def step_md_has_messages_section(context: Context) -> None:
assert "## Messages" in context.md_output, (
f"Expected '## Messages' in output:\n{context.md_output[:500]}"
)
@then("the markdown output should contain a token usage section")
def step_md_has_token_usage(context: Context) -> None:
assert "## Token Usage" in context.md_output, (
f"Expected '## Token Usage' in output:\n{context.md_output[:500]}"
)
@then('the markdown output should not contain "{text}"')
def step_md_not_contains(context: Context, text: str) -> None:
assert text not in context.md_output, (
f"Expected '{text}' NOT in output:\n{context.md_output[:500]}"
)
@then('the markdown output should contain "{section}"')
def step_md_contains_section(context: Context, section: str) -> None:
assert section in context.md_output, (
f"Expected '{section}' in output:\n{context.md_output[:500]}"
)
# ---------------------------------------------------------------------------
# Given / When / Then — round-trip scenarios
# ---------------------------------------------------------------------------
@given("a session with actor and messages for round-trip")
def step_round_trip_session(context: Context) -> None:
messages = [
_make_message(MessageRole.USER, "Round-trip test message", 0),
_make_message(MessageRole.ASSISTANT, "Round-trip response", 1),
]
context.original_session = _make_session(
actor_name="openai/gpt-4",
messages=messages,
)
@when("I export the session to JSON")
def step_export_to_json(context: Context) -> None:
context.export_data = context.original_session.as_export_dict()
@when("I import the exported JSON")
def step_import_from_json(context: Context) -> None:
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
mock_session_repo = MagicMock()
mock_message_repo = MagicMock()
mock_message_repo.count_for_session.return_value = 0
service = PersistentSessionService(
session_repo=mock_session_repo,
message_repo=mock_message_repo,
)
context.imported_session = service.import_session(context.export_data)
@then("the imported session should have the same actor name")
def step_imported_actor_matches(context: Context) -> None:
assert context.imported_session.actor_name == context.original_session.actor_name, (
f"Actor mismatch: {context.imported_session.actor_name!r} != "
f"{context.original_session.actor_name!r}"
)
@then("the imported session should have the same message count")
def step_imported_message_count_matches(context: Context) -> None:
assert (
context.imported_session.message_count == context.original_session.message_count
), (
f"Message count mismatch: {context.imported_session.message_count} != "
f"{context.original_session.message_count}"
)
@then("the imported session should have the same namespace")
def step_imported_namespace_matches(context: Context) -> None:
assert context.imported_session.namespace == context.original_session.namespace, (
f"Namespace mismatch: {context.imported_session.namespace!r} != "
f"{context.original_session.namespace!r}"
)
+47 -8
View File
@@ -426,6 +426,22 @@ def delete(
raise typer.Exit(1) from exc
def _export_as_markdown(service: SessionService, session_id: str) -> str:
"""Build a Markdown export string for *session_id* via *service*.
Fetches the full session (including all messages) through the service's
export mechanism and delegates rendering to
:func:`~cleveragents.domain.models.core.session_markdown.render_session_markdown`.
"""
from cleveragents.domain.models.core.session_markdown import (
build_session_from_export_dict,
)
export_data = service.export_session(session_id)
session = build_session_from_export_dict(export_data)
return session.as_markdown_export()
@app.command("export")
def export_session(
session_id: Annotated[
@@ -440,21 +456,44 @@ def export_session(
bool,
typer.Option("--force", help="Overwrite existing output file"),
] = False,
fmt: Annotated[
str,
typer.Option(
"--format",
"-f",
help=(
"Export format: json (full fidelity, re-importable) "
"or markdown (human-readable)"
),
),
] = "json",
) -> None:
"""Export a session as JSON.
"""Export a session as JSON or Markdown.
Writes the full session data (including messages and checksum) to a file
or stdout.
JSON format (default) writes full session data with checksum and is
re-importable via ``agents session import``. Markdown format produces
a human-readable document but cannot be re-imported.
Examples:
agents session export 01HXYZ...
agents session export 01HXYZ... --format markdown
agents session export 01HXYZ... -o session.json
agents session export 01HXYZ... -o session.json --force
agents session export 01HXYZ... -o session.md --format markdown --force
"""
if fmt not in ("json", "markdown"):
console.print(
f"[red]Invalid format:[/red] {fmt!r}. Choose 'json' or 'markdown'."
)
raise typer.Exit(1)
try:
service = _get_session_service()
data = service.export_session(session_id)
json_str = json.dumps(data, indent=2, default=str)
if fmt == "markdown":
content = _export_as_markdown(service, session_id)
else:
data = service.export_session(session_id)
content = json.dumps(data, indent=2, default=str)
if output is not None:
if output.exists() and not force:
@@ -465,10 +504,10 @@ def export_session(
raise typer.Exit(1)
# Create parent directories if needed
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json_str, encoding="utf-8")
output.write_text(content, encoding="utf-8")
console.print(f"[green]✓ OK[/green] Session exported to {output}")
else:
typer.echo(json_str)
typer.echo(content)
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
@@ -366,6 +366,19 @@ class Session(BaseModel):
}
return result
def as_markdown_export(self) -> str:
"""Return a human-readable Markdown representation of the session.
Produces a Markdown document with metadata, full message history,
token usage, and linked plan IDs. The Markdown format is **not**
re-importable; use :meth:`as_export_dict` for round-trip fidelity.
"""
from cleveragents.domain.models.core.session_markdown import (
render_session_markdown,
)
return render_session_markdown(self)
def as_export_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable dict for session export.
@@ -0,0 +1,138 @@
"""Markdown rendering for Session export.
Provides :func:`render_session_markdown` which converts a
:class:`~cleveragents.domain.models.core.session.Session` into a
human-readable Markdown document, and :func:`build_session_from_export_dict`
which reconstructs a :class:`Session` from an export dict for rendering.
The Markdown format is **not** re-importable. For round-trip fidelity use
:meth:`~cleveragents.domain.models.core.session.Session.as_export_dict`
together with
:meth:`~cleveragents.application.services.session_service.PersistentSessionService.import_session`.
Based on specification.md ``agents session export --format markdown`` section
and issue #1004 (feat(tui): implement session export/import (JSON + Markdown)).
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from cleveragents.domain.models.core.session import Session
def build_session_from_export_dict(export_data: dict[str, Any]) -> Session:
"""Reconstruct a :class:`Session` from an *export_data* dict.
Used by the CLI ``export --format markdown`` path to hydrate a full
:class:`Session` (including messages) from the dict returned by
:meth:`~cleveragents.application.services.session_service.PersistentSessionService.export_session`.
Args:
export_data: Dict previously produced by ``export_session()``.
Returns:
A :class:`Session` with all messages and token usage populated.
"""
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
msgs = [
SessionMessage(
message_id=m["message_id"],
role=MessageRole(m["role"]),
content=m["content"],
sequence=m["sequence"],
timestamp=datetime.datetime.fromisoformat(m["timestamp"]),
metadata=m.get("metadata", {}),
tool_call_id=m.get("tool_call_id"),
)
for m in export_data.get("messages", [])
]
tu_data = export_data.get("token_usage", {})
return Session(
session_id=export_data["session_id"],
actor_name=export_data.get("actor_name"),
namespace=export_data.get("namespace", "local"),
messages=msgs,
linked_plan_ids=export_data.get("linked_plan_ids", []),
token_usage=SessionTokenUsage(
input_tokens=tu_data.get("input_tokens", 0),
output_tokens=tu_data.get("output_tokens", 0),
estimated_cost=tu_data.get("estimated_cost", 0.0),
),
metadata=export_data.get("metadata", {}),
)
def render_session_markdown(session: Session) -> str:
"""Render *session* as a Markdown document.
The document structure is:
1. ``# Session <id>`` — top-level heading
2. ``## Metadata`` — GFM table with actor, namespace, timestamps, count
3. ``## Messages`` — one ``###`` sub-heading per message (role + timestamp)
4. ``## Token Usage`` — GFM table with input/output tokens and cost
5. ``## Linked Plans`` — bullet list (omitted when empty)
Args:
session: The :class:`Session` to render.
Returns:
A UTF-8 Markdown string.
"""
lines: list[str] = []
# ── Title ──────────────────────────────────────────────────────────────
lines.append(f"# Session {session.session_id}")
lines.append("")
# ── Metadata table ─────────────────────────────────────────────────────
lines.append("## Metadata")
lines.append("")
lines.append("| Field | Value |")
lines.append("|-------|-------|")
lines.append(f"| Actor | {session.actor_name or '(none)'} |")
lines.append(f"| Namespace | {session.namespace} |")
lines.append(f"| Created | {session.created_at.isoformat()} |")
lines.append(f"| Updated | {session.updated_at.isoformat()} |")
lines.append(f"| Messages | {session.message_count} |")
lines.append("")
# ── Messages ───────────────────────────────────────────────────────────
if session.messages:
lines.append("## Messages")
lines.append("")
for msg in session.messages:
lines.append(f"### [{msg.role.value}] \u2014 {msg.timestamp.isoformat()}")
lines.append("")
lines.append(msg.content)
lines.append("")
# ── Token usage ────────────────────────────────────────────────────────
tu = session.token_usage
lines.append("## Token Usage")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Input Tokens | {tu.input_tokens} |")
lines.append(f"| Output Tokens | {tu.output_tokens} |")
lines.append(f"| Estimated Cost | ${tu.estimated_cost:.4f} |")
lines.append("")
# ── Linked plans ───────────────────────────────────────────────────────
if session.linked_plan_ids:
lines.append("## Linked Plans")
lines.append("")
for pid in session.linked_plan_ids:
lines.append(f"- {pid}")
lines.append("")
return "\n".join(lines)