forked from HAL9000/cleveragents-core
8d49a3b242
Add plain text (txt) export format to the TUI /session:export command. - Add Session.as_export_plain_text() domain method that produces a human-readable plain text transcript without Markdown formatting - Update TuiCommandRouter._session_export() to accept 'txt' as a valid format, building the plain text content via as_export_plain_text() - Update the invalid-format error message to include 'txt' as a valid option alongside 'json' and 'md' - Add BDD scenarios covering plain text export via TUI command and domain model, including with-messages and no-messages cases - Add corresponding step definitions for the new BDD scenarios The plain text format uses a simple separator-based layout: Session: <id> Actor: <actor> ... ---------------------------------------- [0] USER (timestamp): message content ---------------------------------------- ISSUES CLOSED: #3036
440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""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
|
|
|
|
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[str, object],
|
|
session_obj: Session,
|
|
) -> TuiCommandRouter:
|
|
"""Build a TuiCommandRouter with a mocked container/service for export.
|
|
|
|
The mock container is injected via the ``container_factory`` constructor
|
|
parameter so the mock survives ``multiprocessing.fork()`` boundaries
|
|
used by the parallel test runner.
|
|
"""
|
|
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,
|
|
container_factory=lambda: mock_container,
|
|
)
|
|
return router
|
|
|
|
|
|
@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()
|
|
|
|
context.tui_router = _make_tui_router_with_export_mock(
|
|
session_id, export_data, session
|
|
)
|
|
|
|
|
|
@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,
|
|
container_factory=lambda: mock_container,
|
|
)
|
|
context.tui_router = router
|
|
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:
|
|
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}"
|
|
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}"
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Domain model: as_export_plain_text()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a session with two messages for plain text export")
|
|
def step_session_two_messages_plain(context: Context) -> None:
|
|
msgs = [
|
|
_make_message(MessageRole.USER, "Hello from user", 0),
|
|
_make_message(MessageRole.ASSISTANT, "Hello from assistant", 1),
|
|
]
|
|
context.txt_session = _make_session(messages=msgs)
|
|
|
|
|
|
@given("a session with no messages for plain text export")
|
|
def step_session_no_messages_plain(context: Context) -> None:
|
|
context.txt_session = _make_session()
|
|
|
|
|
|
@when("I call as_export_plain_text on the session")
|
|
def step_call_as_export_plain_text(context: Context) -> None:
|
|
context.txt_output = context.txt_session.as_export_plain_text()
|
|
|
|
|
|
@then('the plain text output should start with "{prefix}"')
|
|
def step_txt_starts_with(context: Context, prefix: str) -> None:
|
|
assert context.txt_output.startswith(prefix), (
|
|
f"Expected plain text to start with {prefix!r}, got: {context.txt_output[:80]!r}"
|
|
)
|
|
|
|
|
|
@then('the plain text output should contain "{text}"')
|
|
def step_txt_contains(context: Context, text: str) -> None:
|
|
assert text in context.txt_output, (
|
|
f"Expected {text!r} in plain text output.\nGot:\n{context.txt_output[:500]}"
|
|
)
|