From 2e244839473992503bdb9d180ed45f249dbbd596 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 17:16:59 +0000 Subject: [PATCH] feat(tui): implement session export/import (JSON + Markdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../steps/tui_session_export_import_steps.py | 403 ++++++++++++++++++ features/tui_session_export_import.feature | 112 +++++ src/cleveragents/cli/commands/session.py | 49 ++- .../domain/models/core/session.py | 54 +++ src/cleveragents/tui/commands.py | 97 ++++- 5 files changed, 706 insertions(+), 9 deletions(-) create mode 100644 features/steps/tui_session_export_import_steps.py create mode 100644 features/tui_session_export_import.feature diff --git a/features/steps/tui_session_export_import_steps.py b/features/steps/tui_session_export_import_steps.py new file mode 100644 index 00000000..229b0a79 --- /dev/null +++ b/features/steps/tui_session_export_import_steps.py @@ -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 " 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 " 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) diff --git a/features/tui_session_export_import.feature b/features/tui_session_export_import.feature new file mode 100644 index 00000000..5add50a8 --- /dev/null +++ b/features/tui_session_export_import.feature @@ -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 " 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 " + + 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 " for the import file + Then the TUI handle result should contain "Invalid JSON" diff --git a/src/cleveragents/cli/commands/session.py b/src/cleveragents/cli/commands/session.py index 7c202021..e766e39f 100644 --- a/src/cleveragents/cli/commands/session.py +++ b/src/cleveragents/cli/commands/session.py @@ -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}") diff --git a/src/cleveragents/domain/models/core/session.py b/src/cleveragents/domain/models/core/session.py index c26ed208..7242f330 100644 --- a/src/cleveragents/domain/models/core/session.py +++ b/src/cleveragents/domain/models/core/session.py @@ -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: + + **Actor:** + **Namespace:** + **Created:** + **Updated:** + + --- + + ## Messages + + ### [] + + + + --- + """ + 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, diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 1134e995..b205d0d4 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -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 . + + 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(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."""