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

Add Markdown export format for sessions alongside the existing JSON export.
Implement TUI slash command routing for /session:export and /session:import.

- Add Session.as_export_markdown() domain method producing a human-readable
  Markdown transcript (lossy, not importable — for sharing/documentation)
- Extend CLI 'agents session export' with --format flag (json|md)
- Extend TuiCommandRouter._session_command() to handle 'export' and 'import'
  subcommands, delegating to the session service via the DI container
- Add 16 Behave scenarios covering domain model, CLI, and TUI command paths

Closes #1004
This commit is contained in:
2026-04-02 17:16:59 +00:00
parent 52bfb1658a
commit 2e24483947
5 changed files with 706 additions and 9 deletions
@@ -0,0 +1,403 @@
"""Step definitions for tui_session_export_import.feature.
Tests for:
- Session.as_export_markdown() domain method
- CLI export --format md flag
- TUI TuiCommandRouter /session export and /session import commands
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
from cleveragents.tui.commands import TuiCommandRouter
# ---------------------------------------------------------------------------
# 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=10,
output_tokens=5,
estimated_cost=0.001,
),
created_at=datetime(2026, 1, 1, 12, 0, 0),
updated_at=datetime(2026, 1, 1, 12, 30, 0),
)
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(2026, 1, 1, 12, sequence, 0),
)
@dataclass
class FakePersona:
name: str
@dataclass
class FakePersonaRegistry:
_personas: list[FakePersona] = field(default_factory=list)
personas_dir: Path = field(default_factory=lambda: Path("/tmp/fake-personas"))
def list_personas(self) -> list[FakePersona]:
return list(self._personas)
@dataclass
class FakePersonaState:
_active: dict[str, FakePersona] = field(default_factory=dict)
def set_active_persona(self, session_id: str, name: str) -> FakePersona:
persona = FakePersona(name=name)
self._active[session_id] = persona
return persona
def active_name(self, session_id: str) -> str | None:
p = self._active.get(session_id)
return p.name if p else None
# ---------------------------------------------------------------------------
# Domain model: as_export_markdown()
# ---------------------------------------------------------------------------
@given("a session with two messages for markdown export")
def step_session_two_messages(context: Context) -> None:
msgs = [
_make_message(MessageRole.USER, "Hello from user", 0),
_make_message(MessageRole.ASSISTANT, "Hello from assistant", 1),
]
context.md_session = _make_session(messages=msgs)
@given("a session with no messages for markdown export")
def step_session_no_messages(context: Context) -> None:
context.md_session = _make_session()
@given('a session with actor "{actor}" for markdown export')
def step_session_with_actor(context: Context, actor: str) -> None:
context.md_session = _make_session(actor_name=actor)
@given("a session with linked plans for markdown export")
def step_session_with_linked_plans(context: Context) -> None:
context.md_session = _make_session(linked_plan_ids=[str(ULID()), str(ULID())])
@when("I call as_export_markdown on the session")
def step_call_as_export_markdown(context: Context) -> None:
context.md_output = context.md_session.as_export_markdown()
@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 markdown to start with {prefix!r}, got: {context.md_output[:80]!r}"
)
@then('the markdown output should contain "{text}"')
def step_md_contains(context: Context, text: str) -> None:
assert text in context.md_output, (
f"Expected {text!r} in markdown output.\nGot:\n{context.md_output[:500]}"
)
# ---------------------------------------------------------------------------
# CLI export: --format md
# ---------------------------------------------------------------------------
@given("there is a mocked session for markdown export")
def step_mocked_session_for_md_export(context: Context) -> None:
context.runner = CliRunner()
context.mock_service = MagicMock()
session_id = str(ULID())
context.session_id = session_id
msgs = [
_make_message(MessageRole.USER, "Test user message", 0),
_make_message(MessageRole.ASSISTANT, "Test assistant reply", 1),
]
session = _make_session(session_id=session_id, messages=msgs)
export_data = session.as_export_dict()
context.mock_service.export_session.return_value = export_data
context.mock_service.get.return_value = session
session_mod._service = context.mock_service
def _teardown_mock_service(context: Context) -> None:
session_mod._service = None
@when("I run session CLI export with --format md and no output file")
def step_export_md_stdout(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["export", context.session_id, "--format", "md"]
)
_teardown_mock_service(context)
@when("I run session CLI export with --format md to a temp file")
def step_export_md_to_file(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".md")
os.close(fd)
os.unlink(path) # Remove so export can create it
context.export_md_path = path
context.result = context.runner.invoke(
session_app,
["export", context.session_id, "--format", "md", "--output", path],
)
_teardown_mock_service(context)
@when("I run session CLI export with no format flag")
def step_export_default_format(context: Context) -> None:
context.result = context.runner.invoke(session_app, ["export", context.session_id])
_teardown_mock_service(context)
@when("I run session CLI export with --format xml")
def step_export_invalid_format(context: Context) -> None:
context.result = context.runner.invoke(
session_app, ["export", context.session_id, "--format", "xml"]
)
_teardown_mock_service(context)
@then("the md export CLI result code should be zero")
def step_cli_md_export_succeeds(context: Context) -> None:
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
@then("the md export CLI result code should be nonzero")
def step_cli_md_exit_with_error(context: Context) -> None:
assert context.result.exit_code != 0, (
f"Expected non-zero exit code, got {context.result.exit_code}.\n"
f"Output: {context.result.output}"
)
@then('the md export CLI output should include "{text}"')
def step_cli_md_output_contains(context: Context, text: str) -> None:
assert text in context.result.output, (
f"Expected {text!r} in CLI output.\nGot: {context.result.output}"
)
@then("the md export CLI output should be parseable as JSON")
def step_cli_md_output_valid_json(context: Context) -> None:
try:
json.loads(context.result.output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"CLI output is not valid JSON: {exc}\nOutput: {context.result.output}"
) from exc
@then("the exported markdown file should exist")
def step_exported_md_file_exists(context: Context) -> None:
assert os.path.exists(context.export_md_path), (
f"Markdown export file not found: {context.export_md_path}"
)
@then('the exported markdown file should contain "{text}"')
def step_exported_md_file_contains(context: Context, text: str) -> None:
content = Path(context.export_md_path).read_text(encoding="utf-8")
assert text in content, (
f"Expected {text!r} in exported markdown file.\nGot: {content[:500]}"
)
# ---------------------------------------------------------------------------
# TUI command router: /session export and /session import
# ---------------------------------------------------------------------------
def _make_tui_router_with_export_mock(
session_id: str,
export_data: dict,
session_obj: Session,
) -> tuple[TuiCommandRouter, MagicMock]:
"""Build a TuiCommandRouter with a mocked container/service for export."""
mock_service = MagicMock()
mock_service.export_session.return_value = export_data
mock_service.get.return_value = session_obj
mock_container = MagicMock()
mock_container.session_service.return_value = mock_service
registry = FakePersonaRegistry()
state = FakePersonaState()
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
return router, mock_container
@given("a TUI command router with mocked session service for export")
def step_tui_router_for_export(context: Context) -> None:
session_id = str(ULID())
context.tui_session_id = session_id
msgs = [
_make_message(MessageRole.USER, "TUI user message", 0),
_make_message(MessageRole.ASSISTANT, "TUI assistant reply", 1),
]
session = _make_session(session_id=session_id, messages=msgs)
export_data = session.as_export_dict()
router, mock_container = _make_tui_router_with_export_mock(
session_id, export_data, session
)
context.tui_router = router
context.tui_mock_container = mock_container
@given("a TUI command router with mocked session service for import")
def step_tui_router_for_import(context: Context) -> None:
session_id = str(ULID())
context.tui_session_id = session_id
imported_session = _make_session(session_id=str(ULID()))
mock_service = MagicMock()
mock_service.import_session.return_value = imported_session
mock_container = MagicMock()
mock_container.session_service.return_value = mock_service
registry = FakePersonaRegistry()
state = FakePersonaState()
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
context.tui_router = router
context.tui_mock_container = mock_container
context.tui_imported_session = imported_session
@given("there is a valid JSON export file for TUI import")
def step_valid_json_export_for_tui(context: Context) -> None:
msgs = [_make_message(MessageRole.USER, "Import test", 0)]
session = _make_session(messages=msgs)
export_data = session.as_export_dict()
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(export_data, fh, default=str)
context.tui_import_path = path
@given("there is an invalid JSON file for TUI import")
def step_invalid_json_for_tui(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
fh.write("{ not valid json }")
context.tui_invalid_json_path = path
@when('I call TUI handle with "{command}" for the current session')
def step_tui_handle_command(context: Context, command: str) -> None:
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@when('I call TUI handle with "session import <path>" for the import file')
def step_tui_handle_import_valid(context: Context) -> None:
command = f"session import {context.tui_import_path}"
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@when('I call TUI handle with "session import <invalid_path>" for the import file')
def step_tui_handle_import_invalid(context: Context) -> None:
command = f"session import {context.tui_invalid_json_path}"
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@then('the TUI handle result should contain "{text}"')
def step_tui_result_contains(context: Context, text: str) -> None:
assert text in context.tui_result, (
f"Expected {text!r} in TUI result.\nGot: {context.tui_result!r}"
)
@then('the TUI handle result should be "{text}"')
def step_tui_result_equals(context: Context, text: str) -> None:
assert context.tui_result == text, (
f"Expected TUI result {text!r}.\nGot: {context.tui_result!r}"
)
@then('the tui exported file "{path}" should exist')
def step_tui_file_exists(context: Context, path: str) -> None:
import contextlib
assert os.path.exists(path), f"Expected file to exist: {path}"
# Cleanup
with contextlib.suppress(OSError):
os.unlink(path)
+112
View File
@@ -0,0 +1,112 @@
Feature: TUI session export/import (JSON + Markdown)
As a TUI user
I want to export sessions to JSON and Markdown formats
And import sessions from JSON files via slash commands
So that I can share, archive, and restore conversation history
# ---------------------------------------------------------------------------
# Session domain model: as_export_markdown()
# ---------------------------------------------------------------------------
Scenario: Session with messages exports to Markdown
Given a session with two messages for markdown export
When I call as_export_markdown on the session
Then the markdown output should start with "# Session:"
And the markdown output should contain "## Messages"
And the markdown output should contain "USER"
And the markdown output should contain "ASSISTANT"
And the markdown output should contain "Hello from user"
And the markdown output should contain "Hello from assistant"
Scenario: Session with no messages exports to Markdown with placeholder
Given a session with no messages for markdown export
When I call as_export_markdown on the session
Then the markdown output should start with "# Session:"
And the markdown output should contain "*(no messages)*"
Scenario: Markdown export includes actor and namespace
Given a session with actor "openai/gpt-4" for markdown export
When I call as_export_markdown on the session
Then the markdown output should contain "**Actor:** openai/gpt-4"
And the markdown output should contain "**Namespace:** local"
Scenario: Markdown export includes linked plan IDs
Given a session with linked plans for markdown export
When I call as_export_markdown on the session
Then the markdown output should contain "**Linked Plans:**"
# ---------------------------------------------------------------------------
# CLI export command: --format md flag
# ---------------------------------------------------------------------------
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:"
Scenario: Export session as JSON (default format)
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
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
# ---------------------------------------------------------------------------
Scenario: TUI session export command returns success message
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export" for the current session
Then the TUI handle result should contain "Session exported"
Scenario: TUI session export with path writes file
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export /tmp/tui_test_export.json" for the current session
Then the TUI handle result should contain "Session exported to"
And the tui exported file "/tmp/tui_test_export.json" should exist
Scenario: TUI session export with --format md returns success
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format md" for the current session
Then the TUI handle result should contain "Session exported (MD)"
Scenario: TUI session export with invalid format returns error
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"
Scenario: TUI session import with valid file returns success
Given a TUI command router with mocked session service for import
And there is a valid JSON export file for TUI import
When I call TUI handle with "session import <path>" for the import file
Then the TUI handle result should contain "Session imported:"
Scenario: TUI session import with no path returns usage
Given a TUI command router with mocked session service for import
When I call TUI handle with "session import" for the current session
Then the TUI handle result should be "Usage: /session:import <path>"
Scenario: TUI session import with non-existent file returns error
Given a TUI command router with mocked session service for import
When I call TUI handle with "session import /tmp/nonexistent_tui_file.json" for the current session
Then the TUI handle result should contain "File not found"
Scenario: TUI session import with invalid JSON returns error
Given a TUI command router with mocked session service for import
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"
+42 -7
View File
@@ -34,6 +34,7 @@ from cleveragents.domain.models.core.session import (
Session,
SessionExportError,
SessionImportError,
SessionMessage,
SessionNotFoundError,
SessionService,
)
@@ -440,21 +441,55 @@ def export_session(
bool,
typer.Option("--force", help="Overwrite existing output file"),
] = False,
fmt: Annotated[
str,
typer.Option(
"--format",
help="Export format: json (default) or md (Markdown transcript)",
),
] = "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.
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).
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
"""
if fmt not in ("json", "md"):
console.print(f"[red]Invalid format:[/red] {fmt!r}. Use 'json' or 'md'.")
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 == "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)
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 export_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)
if output is not None:
if output.exists() and not force:
@@ -465,10 +500,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}")
@@ -409,6 +409,60 @@ class Session(BaseModel):
export["checksum"] = hashlib.sha256(canonical.encode()).hexdigest()
return export
def as_export_markdown(self) -> str:
"""Return a human-readable Markdown transcript of the session.
This is a **lossy** export intended for sharing and documentation.
It does not contain enough information to fully restore a session via
``import_session`` use :meth:`as_export_dict` for that.
The output format is::
# Session: <session_id>
**Actor:** <actor_name>
**Namespace:** <namespace>
**Created:** <created_at>
**Updated:** <updated_at>
---
## Messages
### [<sequence>] <ROLE> — <timestamp>
<content>
---
"""
lines: list[str] = []
lines.append(f"# Session: {self.session_id}")
lines.append("")
lines.append(f"**Actor:** {self.actor_name or '(none)'}")
lines.append(f"**Namespace:** {self.namespace}")
lines.append(f"**Created:** {self.created_at.isoformat()}")
lines.append(f"**Updated:** {self.updated_at.isoformat()}")
if self.linked_plan_ids:
plan_refs = ", ".join(self.linked_plan_ids)
lines.append(f"**Linked Plans:** {plan_refs}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Messages")
lines.append("")
for msg in self.messages:
ts = msg.timestamp.strftime("%Y-%m-%d %H:%M:%S")
lines.append(f"### [{msg.sequence}] {msg.role.value.upper()}{ts}")
lines.append("")
lines.append(msg.content)
lines.append("")
lines.append("---")
lines.append("")
if not self.messages:
lines.append("*(no messages)*")
lines.append("")
return "\n".join(lines)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
+95 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from cleveragents.application.container import get_container
from cleveragents.tui.app import CleverAgentsTuiApp, textual_available
@@ -41,12 +42,104 @@ class TuiCommandRouter:
return f"Active persona: {persona.name}"
return f"Unknown persona command: {' '.join(tokens)}"
@staticmethod
def _session_command(tokens: list[str], *, session_id: str) -> str:
def _session_command(self, tokens: list[str], *, session_id: str) -> str:
if not tokens or tokens[0] == "show":
return f"Current session: {session_id}"
if tokens[0] == "export":
return self._session_export(tokens[1:], session_id=session_id)
if tokens[0] == "import":
return self._session_import(tokens[1:])
return f"Unknown session command: {' '.join(tokens)}"
def _session_export(self, tokens: list[str], *, session_id: str) -> str:
"""Handle /session:export [--format md] [path].
Exports the current session to JSON (default) or Markdown.
If a path is provided the file is written there; otherwise a
confirmation message is returned for display in the TUI.
"""
fmt = "json"
path: str | None = None
# Parse optional --format flag and optional path
i = 0
while i < len(tokens):
if tokens[i] == "--format" and i + 1 < len(tokens):
fmt = tokens[i + 1]
i += 2
else:
path = tokens[i]
i += 1
if fmt not in ("json", "md"):
return f"Invalid format: {fmt!r}. Use 'json' or 'md'."
try:
container = get_container()
service = container.session_service()
export_data = service.export_session(session_id)
if fmt == "md":
from cleveragents.domain.models.core.session import (
MessageRole,
SessionMessage,
)
session = service.get(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 export_data.get("messages", [])
]
session.messages = messages
content = session.as_export_markdown()
else:
content = json.dumps(export_data, indent=2, default=str)
if path is not None:
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(content, encoding="utf-8")
return f"Session exported to {path}"
return f"Session exported ({fmt.upper()})"
except Exception as exc:
return f"Export failed: {exc}"
def _session_import(self, tokens: list[str]) -> str:
"""Handle /session:import <path>.
Imports a session from a JSON file previously produced by
``/session:export`` or ``agents session export``.
"""
if not tokens:
return "Usage: /session:import <path>"
path = Path(tokens[0])
if not path.exists():
return f"File not found: {path}"
try:
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
except json.JSONDecodeError as exc:
return f"Invalid JSON: {exc}"
try:
container = get_container()
service = container.session_service()
session = service.import_session(data)
return f"Session imported: {session.session_id}"
except Exception as exc:
return f"Import failed: {exc}"
def run_tui(*, headless: bool = False) -> int:
"""Run the Textual TUI app or a headless startup check."""