fix(tui): add plain text format support to session export command
CI / lint (pull_request) Successful in 21s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 1m4s
CI / quality (pull_request) Successful in 35s
CI / build (pull_request) Successful in 18s
CI / helm (pull_request) Successful in 23s
CI / unit_tests (pull_request) Successful in 7m0s
CI / e2e_tests (pull_request) Successful in 15m25s
CI / integration_tests (pull_request) Successful in 23m13s
CI / coverage (pull_request) Successful in 11m18s
CI / docker (pull_request) Successful in 1m29s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m57s

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
This commit is contained in:
2026-04-05 09:09:14 +00:00
parent ffb67e15b9
commit 8d49a3b242
4 changed files with 141 additions and 2 deletions
@@ -399,3 +399,41 @@ def step_tui_file_exists(context: Context, path: str) -> None:
# 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]}"
)
@@ -110,3 +110,35 @@ Feature: TUI session export/import (JSON + Markdown)
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"
Scenario: TUI session export with --format txt returns success
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format txt" for the current session
Then the TUI handle result should contain "Session exported (TXT)"
Scenario: TUI session export with --format txt to file writes plain text
Given a TUI command router with mocked session service for export
When I call TUI handle with "session export --format txt /tmp/tui_test_export.txt" for the current session
Then the TUI handle result should contain "Session exported to"
And the tui exported file "/tmp/tui_test_export.txt" should exist
Scenario: Plain text export contains session header and messages
Given a session with two messages for plain text export
When I call as_export_plain_text on the session
Then the plain text output should start with "Session:"
And the plain text output should contain "USER"
And the plain text output should contain "ASSISTANT"
And the plain text output should contain "Hello from user"
And the plain text output should contain "Hello from assistant"
Scenario: Plain text export with no messages contains placeholder
Given a session with no messages for plain text export
When I call as_export_plain_text on the session
Then the plain text output should start with "Session:"
And the plain text output should contain "(no messages)"
Scenario: TUI session export error message includes txt format
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"
And the TUI handle result should contain "txt"
@@ -467,6 +467,54 @@ class Session(BaseModel):
lines.append("")
return "\n".join(lines)
def as_export_plain_text(self) -> str:
"""Return a plain text transcript of the session.
This is a **lossy** export intended for sharing and reading in plain
text editors. 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>
----------------------------------------
[<sequence>] <ROLE> (<timestamp>):
<content>
----------------------------------------
"""
separator = "-" * 40
lines: list[str] = []
lines.append(f"Session: {self.session_id}")
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(separator)
lines.append("")
if not self.messages:
lines.append("(no messages)")
lines.append("")
else:
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(msg.content)
lines.append("")
lines.append(separator)
lines.append("")
return "\n".join(lines)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
+23 -2
View File
@@ -95,8 +95,8 @@ class TuiCommandRouter:
path = tokens[i]
i += 1
if fmt not in ("json", "md"):
return f"Invalid format: {fmt!r}. Use 'json' or 'md'."
if fmt not in ("json", "md", "txt"):
return f"Invalid format: {fmt!r}. Use 'json', 'md', or 'txt'."
try:
container = self._resolve_container()
@@ -124,6 +124,27 @@ class TuiCommandRouter:
]
session.messages = messages
content = session.as_export_markdown()
elif fmt == "txt":
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_plain_text()
else:
content = json.dumps(export_data, indent=2, default=str)