forked from cleveragents/cleveragents-core
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:
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user