feat(tui): add session export/import command support #1244

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-session-export-import into master
6 changed files with 406 additions and 9 deletions
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Added TUI session export/import command handling for JSON and Markdown.
`/session:export` now supports `--format md` and writes transcript-oriented
Markdown output, while `/session:import` restores sessions from JSON exports.
Updated TUI command/router Behave coverage and Robot smoke coverage for the
new session command paths. (#1004)
- Expanded the TUI slash command overlay catalog to include 67 commands across
14 groups, aligned with the specification command reference for session,
persona, scope, plan, project, registry/config, context, and utility flows.
@@ -8,9 +8,12 @@ Targets uncovered lines in cleveragents.tui.commands:
"""
import json
import shutil
import tempfile
from dataclasses import dataclass, field
from io import StringIO
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
@@ -55,6 +58,65 @@ class FakePersonaState:
return p.name if p else "default"
@dataclass
class FakeImportedSession:
"""Minimal imported-session payload for command responses."""
session_id: str = "imported-session-001"
message_count: int = 2
@dataclass
class FakeSessionService:
"""Session service stand-in supporting export/import."""
exported_session_id: str | None = None
imported_payload: dict[str, Any] | None = None
def export_session(self, session_id: str) -> dict[str, Any]:
self.exported_session_id = session_id
return {
"schema_version": "1.0",
"session_id": session_id,
"actor_name": "local/mock-default",
"namespace": "local",
"messages": [
{
"message_id": "m-1",
"role": "user",
"content": "hello",
"sequence": 0,
"timestamp": "2026-01-01T00:00:00+00:00",
"metadata": {},
"tool_call_id": None,
},
{
"message_id": "m-2",
"role": "assistant",
"content": "hi there",
"sequence": 1,
"timestamp": "2026-01-01T00:00:01+00:00",
"metadata": {},
"tool_call_id": None,
},
],
"linked_plan_ids": [],
"token_usage": {
"input_tokens": 1,
"output_tokens": 2,
"estimated_cost": 0.0,
},
"metadata": {},
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:01+00:00",
"checksum": "dummy",
}
def import_session(self, data: dict[str, Any]) -> FakeImportedSession:
self.imported_payload = dict(data)
return FakeImportedSession()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@@ -73,9 +135,11 @@ def step_router_with_mock_deps(context):
"""Create a router with an empty registry and basic state."""
context.registry = FakePersonaRegistry()
context.state = FakePersonaState()
context.session_service = FakeSessionService()
context.router = TuiCommandRouter(
persona_registry=context.registry,
persona_state=context.state,
session_service=context.session_service,
)
@@ -84,9 +148,11 @@ def step_router_with_set_support(context):
"""Create a router whose state can set an active persona."""
context.registry = FakePersonaRegistry()
context.state = FakePersonaState()
context.session_service = FakeSessionService()
context.router = TuiCommandRouter(
persona_registry=context.registry,
persona_state=context.state,
session_service=context.session_service,
)
@@ -97,9 +163,11 @@ def step_router_with_two_personas(context):
_personas=[FakePersona(name="Alice"), FakePersona(name="Bob")]
)
context.state = FakePersonaState()
context.session_service = FakeSessionService()
context.router = TuiCommandRouter(
persona_registry=context.registry,
persona_state=context.state,
session_service=context.session_service,
)
@@ -108,9 +176,11 @@ def step_router_with_empty_registry(context):
"""Create a router whose registry returns no personas."""
context.registry = FakePersonaRegistry(_personas=[])
context.state = FakePersonaState()
context.session_service = FakeSessionService()
context.router = TuiCommandRouter(
persona_registry=context.registry,
persona_state=context.state,
session_service=context.session_service,
)
@@ -148,6 +218,66 @@ def step_handle_result_starts_with(context, prefix):
)
@then('the handle result should contain "{fragment}"')
def step_handle_result_contains(context, fragment):
assert fragment in context.handle_result, (
f"Expected result to contain {fragment!r}, got {context.handle_result!r}"
)
@when("I export the current session as markdown to a temporary file")
def step_export_session_markdown_to_temp_file(context):
temp_dir = Path(tempfile.mkdtemp(prefix="tui-session-export-", dir=Path.cwd()))
context.add_cleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True))
relative_dir = temp_dir.relative_to(Path.cwd())
relative_path = relative_dir / "session.md"
context._session_markdown_path = Path.cwd() / relative_path
context.handle_result = context.router.handle(
f"session:export --format md {relative_path.as_posix()}",
session_id="test-session",
)
@then("markdown session export should be written")
def step_verify_markdown_export(context):
assert context._session_markdown_path.exists()
payload = context._session_markdown_path.read_text(encoding="utf-8")
assert "# Session Transcript" in payload
assert "## Transcript" in payload
assert "Session exported to" in context.handle_result
@when("I import a session from a temporary JSON file")
def step_import_session_from_temp_json(context):
temp_dir = Path(tempfile.mkdtemp(prefix="tui-session-import-", dir=Path.cwd()))
context.add_cleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True))
relative_dir = temp_dir.relative_to(Path.cwd())
relative_path = relative_dir / "session.json"
payload = {
"schema_version": "1.0",
"messages": [],
"checksum": "dummy",
}
(Path.cwd() / relative_path).write_text(json.dumps(payload), encoding="utf-8")
context.handle_result = context.router.handle(
f"session:import {relative_path.as_posix()}",
session_id="test-session",
)
@when("I import a session from an invalid JSON file")
def step_import_session_from_invalid_json(context):
temp_dir = Path(tempfile.mkdtemp(prefix="tui-session-import-bad-", dir=Path.cwd()))
context.add_cleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True))
relative_dir = temp_dir.relative_to(Path.cwd())
relative_path = relative_dir / "bad-session.json"
(Path.cwd() / relative_path).write_text("{invalid", encoding="utf-8")
context.handle_result = context.router.handle(
f"session:import {relative_path.as_posix()}",
session_id="test-session",
)
# ---------------------------------------------------------------------------
# run_tui() headless scenario
# ---------------------------------------------------------------------------
@@ -168,6 +298,7 @@ def step_run_tui_headless(context):
mock_container = MagicMock()
mock_container.persona_registry.return_value = mock_registry
mock_container.persona_state.return_value = mock_state
mock_container.session_service.return_value = MagicMock()
captured = StringIO()
with (
@@ -189,6 +320,7 @@ def step_run_tui_non_headless(context):
mock_container = MagicMock()
mock_container.persona_registry.return_value = mock_registry
mock_container.persona_state.return_value = mock_state
mock_container.session_service.return_value = MagicMock()
mock_app = MagicMock()
mock_app.run = MagicMock()
+25
View File
@@ -66,6 +66,31 @@ Feature: TUI Command Router and run_tui coverage
When I call handle with raw input "session"
Then the handle result should be "Current session: test-session"
Scenario: session colon command alias shows current session
Given a TuiCommandRouter with a mock registry and state
When I call handle with raw input "session:show"
Then the handle result should be "Current session: test-session"
Scenario: session export returns JSON payload by default
Given a TuiCommandRouter with a mock registry and state
When I call handle with raw input "session export"
Then the handle result should contain "schema_version"
Scenario: session export writes markdown transcript to file
Given a TuiCommandRouter with a mock registry and state
When I export the current session as markdown to a temporary file
Then markdown session export should be written
Scenario: session import reads JSON file and reports imported session
Given a TuiCommandRouter with a mock registry and state
When I import a session from a temporary JSON file
Then the handle result should start with "Session imported:"
Scenario: session import with invalid JSON returns an error
Given a TuiCommandRouter with a mock registry and state
When I import a session from an invalid JSON file
Then the handle result should start with "Session import failed: invalid JSON"
Scenario: session unknown subcommand returns error
Given a TuiCommandRouter with a mock registry and state
When I call handle with raw input "session reset abc"
+30
View File
@@ -40,3 +40,33 @@ TUI Headless Includes Router Help Payload
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} "help"
Should Contain ${result.stdout} /persona
TUI Session Export Import Commands
${script}= Catenate SEPARATOR=\n
... import json
... import shutil
... import tempfile
... from pathlib import Path
... from types import SimpleNamespace
... from cleveragents.tui.commands import TuiCommandRouter
... imported = SimpleNamespace(session_id="imported-session-robot", message_count=0)
... export_payload = {"schema_version": "1.0", "session_id": "sess-1", "actor_name": "local/mock-default", "namespace": "local", "messages": [{"role": "user", "content": "hello", "timestamp": "2026-01-01T00:00:00+00:00"}], "linked_plan_ids": [], "token_usage": {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0.0}, "metadata": {}, "created_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-01T00:00:00+00:00", "checksum": "dummy"}
... session_service = SimpleNamespace(export_session=lambda session_id: dict(export_payload, session_id=session_id), import_session=lambda data: imported)
... registry = SimpleNamespace(list_personas=lambda: [])
... state = SimpleNamespace(set_active_persona=lambda session_id, name: (_ for _ in ()).throw(ValueError("unused")))
... router = TuiCommandRouter(persona_registry=registry, persona_state=state, session_service=session_service)
... temp_dir = Path(tempfile.mkdtemp(prefix="tui-session-router-", dir=Path.cwd()))
... rel_md = temp_dir.relative_to(Path.cwd()) / "session.md"
... export_result = router.handle(f"session:export --format md {rel_md.as_posix()}", session_id="sess-1")
... assert "Session exported to" in export_result
... md_payload = (Path.cwd() / rel_md).read_text(encoding="utf-8")
... assert "# Session Transcript" in md_payload
... rel_json = temp_dir.relative_to(Path.cwd()) / "session.json"
... (Path.cwd() / rel_json).write_text(json.dumps({"schema_version": "1.0", "checksum": "dummy", "messages": []}), encoding="utf-8")
... import_result = router.handle(f"session:import {rel_json.as_posix()}", session_id="sess-1")
... assert "Session imported:" in import_result
... shutil.rmtree(temp_dir, ignore_errors=True)
... print("tui-session-router-ok")
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tui-session-router-ok
+213 -8
View File
@@ -4,8 +4,15 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from cleveragents.application.container import get_container
from cleveragents.domain.models.core.session import (
SessionImportError,
SessionNotFoundError,
SessionService,
)
from cleveragents.tui.app import CleverAgentsTuiApp, textual_available
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
@@ -17,19 +24,32 @@ class TuiCommandRouter:
persona_registry: PersonaRegistry
persona_state: PersonaState
session_service: SessionService | None = None
def handle(self, raw: str, *, session_id: str) -> str:
tokens = raw.strip().split()
if not tokens:
return "Empty command"
if tokens[0] == "persona":
return self._persona_command(tokens[1:], session_id=session_id)
if tokens[0] == "session":
return self._session_command(tokens[1:], session_id=session_id)
if tokens[0] == "help":
command, rest = self._split_command(tokens)
if command == "persona":
return self._persona_command(rest, session_id=session_id)
if command == "session":
return self._session_command(rest, session_id=session_id)
if command == "help":
return "Commands: /persona, /session, /help"
return f"Unknown command: /{raw}"
@staticmethod
def _split_command(tokens: list[str]) -> tuple[str, list[str]]:
command = tokens[0]
if ":" in command:
root, subcommand = command.split(":", maxsplit=1)
if root:
rest = [subcommand] if subcommand else []
rest.extend(tokens[1:])
return root, rest
return command, tokens[1:]
def _persona_command(self, tokens: list[str], *, session_id: str) -> str:
if not tokens or tokens[0] == "list":
names = [persona.name for persona in self.persona_registry.list_personas()]
@@ -41,19 +61,204 @@ 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)}"
@staticmethod
def _resolve_relative_path(path: Path) -> Path:
if path.is_absolute():
raise ValueError("Path must be relative to current working directory")
base = Path.cwd().resolve()
resolved = (base / path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Path must stay within current working directory")
return resolved
@staticmethod
def _render_session_markdown(export_data: dict[str, Any]) -> str:
lines: list[str] = ["# Session Transcript", ""]
session_id = str(export_data.get("session_id", "(unknown)"))
actor = str(export_data.get("actor_name") or "(none)")
namespace = str(export_data.get("namespace") or "local")
created_at = str(export_data.get("created_at") or "")
updated_at = str(export_data.get("updated_at") or "")
lines.extend(
[
"## Metadata",
f"- Session ID: `{session_id}`",
f"- Actor: `{actor}`",
f"- Namespace: `{namespace}`",
f"- Created At: `{created_at}`",
f"- Updated At: `{updated_at}`",
]
)
linked_plan_ids = export_data.get("linked_plan_ids", [])
if isinstance(linked_plan_ids, list) and linked_plan_ids:
plans = ", ".join(f"`{item!s}`" for item in linked_plan_ids)
lines.append(f"- Linked Plans: {plans}")
lines.extend(["", "## Transcript", ""])
messages = export_data.get("messages", [])
if not isinstance(messages, list) or not messages:
lines.append("(no messages)")
else:
for message in messages:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "unknown").upper()
timestamp = str(message.get("timestamp") or "")
content = str(message.get("content") or "").strip()
lines.append(f"### {role}")
if timestamp:
lines.append(f"_{timestamp}_")
lines.append("")
if content:
lines.extend(content.splitlines())
else:
lines.append("(empty)")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
@staticmethod
def _parse_export_args(args: list[str]) -> tuple[Path | None, str]:
output_path: Path | None = None
output_format = "json"
idx = 0
while idx < len(args):
arg = args[idx]
if arg == "--format":
idx += 1
if idx >= len(args):
raise ValueError("--format requires a value")
output_format = args[idx].strip().lower()
elif arg.startswith("--format="):
output_format = arg.split("=", maxsplit=1)[1].strip().lower()
elif arg.startswith("--"):
raise ValueError(f"Unknown option: {arg}")
else:
if output_path is not None:
raise ValueError("Only one output path is allowed")
output_path = Path(arg)
idx += 1
if output_format in {"md", "markdown"}:
return output_path, "md"
if output_format == "json":
return output_path, "json"
raise ValueError("--format must be one of: json, md")
@staticmethod
def _parse_import_args(args: list[str]) -> Path:
input_path: Path | None = None
idx = 0
while idx < len(args):
arg = args[idx]
if arg == "--input":
idx += 1
if idx >= len(args):
raise ValueError("--input requires a file path")
input_path = Path(args[idx])
elif arg.startswith("--input="):
input_path = Path(arg.split("=", maxsplit=1)[1])
elif arg.startswith("--"):
raise ValueError(f"Unknown option: {arg}")
else:
Review

The SessionService.export_session() contract can raise SessionExportError in addition to SessionNotFoundError. Consider catching it here to provide a user-friendly error message instead of letting it propagate as an unhandled exception in the TUI:

except SessionNotFoundError:
    return f"Session not found: {session_id}"
except SessionExportError as exc:
    return f"Session export failed: {exc}"
The `SessionService.export_session()` contract can raise `SessionExportError` in addition to `SessionNotFoundError`. Consider catching it here to provide a user-friendly error message instead of letting it propagate as an unhandled exception in the TUI: ```python except SessionNotFoundError: return f"Session not found: {session_id}" except SessionExportError as exc: return f"Session export failed: {exc}" ```
if input_path is not None:
raise ValueError("Only one input path is allowed")
input_path = Path(arg)
idx += 1
if input_path is None:
raise ValueError("Missing input path")
return input_path
def _session_export(self, args: list[str], *, session_id: str) -> str:
if self.session_service is None:
return "Session export unavailable: session service not configured"
try:
output_path, output_format = self._parse_export_args(args)
except ValueError as exc:
return f"Usage: /session:export [path] [--format json|md] ({exc})"
try:
export_data = self.session_service.export_session(session_id)
except SessionNotFoundError:
return f"Session not found: {session_id}"
rendered = (
json.dumps(export_data, indent=2, default=str)
if output_format == "json"
else self._render_session_markdown(export_data)
)
if output_path is None:
return rendered
try:
safe_output = self._resolve_relative_path(output_path)
safe_output.parent.mkdir(parents=True, exist_ok=True)
safe_output.write_text(rendered, encoding="utf-8")
except (OSError, ValueError) as exc:
return f"Session export failed: {exc}"
label = "JSON" if output_format == "json" else "Markdown"
return f"Session exported to {safe_output} ({label})"
def _session_import(self, args: list[str]) -> str:
if self.session_service is None:
return "Session import unavailable: session service not configured"
try:
input_path = self._parse_import_args(args)
safe_input = self._resolve_relative_path(input_path)
except ValueError as exc:
return f"Usage: /session:import <path> ({exc})"
if not safe_input.exists():
return f"Session import failed: file not found: {safe_input}"
try:
raw = safe_input.read_text(encoding="utf-8")
data = json.loads(raw)
except OSError as exc:
return f"Session import failed: {exc}"
except json.JSONDecodeError as exc:
return f"Session import failed: invalid JSON ({exc})"
if not isinstance(data, dict):
return "Session import failed: top-level JSON value must be an object"
try:
imported = self.session_service.import_session(data)
except SessionImportError as exc:
return f"Session import failed: {exc}"
return (
f"Session imported: {imported.session_id} "
f"({imported.message_count} messages)"
)
def run_tui(*, headless: bool = False) -> int:
"""Run the Textual TUI app or a headless startup check."""
container = get_container()
registry = container.persona_registry()
state = container.persona_state(registry=registry)
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
session_service = container.session_service()
router = TuiCommandRouter(
persona_registry=registry,
persona_state=state,
session_service=session_service,
)
if headless:
payload = {
+1 -1
View File
@@ -22,7 +22,7 @@ SLASH_COMMAND_SPECS: tuple[SlashCommandSpec, ...] = (
SlashCommandSpec("session:close", "Session", "Close the current session"),
SlashCommandSpec("session:delete", "Session", "Delete a saved session"),
SlashCommandSpec("session:rename", "Session", "Rename current session"),
SlashCommandSpec("session:export", "Session", "Export session to JSON"),
SlashCommandSpec("session:export", "Session", "Export session (JSON or Markdown)"),
SlashCommandSpec("session:import", "Session", "Import session from JSON"),
SlashCommandSpec("persona:list", "Persona", "Display all personas"),
SlashCommandSpec("persona:set", "Persona", "Switch active persona"),