From 9aa73cec033a80ddb016f33a99ec6c6509ada1f0 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 07:51:09 +0000 Subject: [PATCH] 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 --- features/session_export_markdown.feature | 81 ++++ .../steps/session_export_markdown_steps.py | 367 ++++++++++++++++++ src/cleveragents/cli/commands/session.py | 55 ++- .../domain/models/core/session.py | 13 + .../domain/models/core/session_markdown.py | 138 +++++++ 5 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 features/session_export_markdown.feature create mode 100644 features/steps/session_export_markdown_steps.py create mode 100644 src/cleveragents/domain/models/core/session_markdown.py diff --git a/features/session_export_markdown.feature b/features/session_export_markdown.feature new file mode 100644 index 00000000..9b7cdcda --- /dev/null +++ b/features/session_export_markdown.feature @@ -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 diff --git a/features/steps/session_export_markdown_steps.py b/features/steps/session_export_markdown_steps.py new file mode 100644 index 00000000..99333f4e --- /dev/null +++ b/features/steps/session_export_markdown_steps.py @@ -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}" + ) diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 7c202021..0de76811 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -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}") diff --git a/src/cleveragents/domain/models/core/session.py b/src/cleveragents/domain/models/core/session.py index c26ed208..f66b3f87 100644 --- a/src/cleveragents/domain/models/core/session.py +++ b/src/cleveragents/domain/models/core/session.py @@ -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. diff --git a/src/cleveragents/domain/models/core/session_markdown.py b/src/cleveragents/domain/models/core/session_markdown.py new file mode 100644 index 00000000..a2a4baa2 --- /dev/null +++ b/src/cleveragents/domain/models/core/session_markdown.py @@ -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 `` — 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)