Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aa73cec03 |
@@ -0,0 +1,81 @@
|
||||
Feature: Session export in Markdown format
|
||||
As a developer
|
||||
I want to export sessions as human-readable Markdown
|
||||
So that I can share and review session history outside the tool
|
||||
|
||||
Background:
|
||||
Given a session CLI runner with mocked service
|
||||
|
||||
# ── Markdown export via CLI ──────────────────────────────────────────────
|
||||
|
||||
Scenario: Export session as Markdown to stdout
|
||||
Given there is a mocked session for markdown export
|
||||
When I run session CLI export with --format markdown
|
||||
Then the session CLI export should succeed
|
||||
And the session CLI output should contain "# Session"
|
||||
And the session CLI output should contain "## Metadata"
|
||||
And the session CLI output should contain "## Token Usage"
|
||||
|
||||
Scenario: Export session with messages as Markdown
|
||||
Given there is a mocked session with messages for markdown export
|
||||
When I run session CLI export with --format markdown
|
||||
Then the session CLI export should succeed
|
||||
And the session CLI output should contain "## Messages"
|
||||
And the session CLI output should contain "[user]"
|
||||
|
||||
Scenario: Export session as Markdown to file
|
||||
Given there is a mocked session for markdown export
|
||||
When I run session CLI export with --format markdown to a temp file
|
||||
Then the session CLI export should succeed
|
||||
And the exported markdown file should exist
|
||||
And the exported markdown file should contain "# Session"
|
||||
|
||||
Scenario: Export session as Markdown with --force overwrites file
|
||||
Given there is a mocked session for markdown export
|
||||
And there is an existing markdown export file
|
||||
When I run session CLI export with --format markdown --force to existing file
|
||||
Then the session CLI export should succeed
|
||||
|
||||
Scenario: Export session as Markdown refuses overwrite without --force
|
||||
Given there is a mocked session for markdown export
|
||||
And there is an existing markdown export file
|
||||
When I run session CLI export with --format markdown to existing file without --force
|
||||
Then the session CLI should exit with error
|
||||
And the session CLI output should contain "File already exists"
|
||||
|
||||
Scenario: Export with invalid format is rejected
|
||||
Given there is a mocked session for markdown export
|
||||
When I run session CLI export with --format invalid
|
||||
Then the session CLI should exit with error
|
||||
And the session CLI output should contain "Invalid format"
|
||||
|
||||
# ── Session.as_markdown_export() domain method ───────────────────────────
|
||||
|
||||
Scenario: Session.as_markdown_export produces valid Markdown structure
|
||||
Given a session domain object with actor and messages
|
||||
When I call as_markdown_export on the session
|
||||
Then the markdown output should start with "# Session"
|
||||
And the markdown output should contain a metadata table
|
||||
And the markdown output should contain a messages section
|
||||
And the markdown output should contain a token usage section
|
||||
|
||||
Scenario: Session.as_markdown_export with no messages omits messages section
|
||||
Given a session domain object with no messages
|
||||
When I call as_markdown_export on the session
|
||||
Then the markdown output should start with "# Session"
|
||||
And the markdown output should not contain "## Messages"
|
||||
|
||||
Scenario: Session.as_markdown_export with linked plans includes plans section
|
||||
Given a session domain object with linked plans
|
||||
When I call as_markdown_export on the session
|
||||
Then the markdown output should contain "## Linked Plans"
|
||||
|
||||
# ── Round-trip: JSON export then import preserves data ───────────────────
|
||||
|
||||
Scenario: Round-trip export then import preserves session data
|
||||
Given a session with actor and messages for round-trip
|
||||
When I export the session to JSON
|
||||
And I import the exported JSON
|
||||
Then the imported session should have the same actor name
|
||||
And the imported session should have the same message count
|
||||
And the imported session should have the same namespace
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Step definitions for session_export_markdown.feature.
|
||||
|
||||
Tests Markdown export via the CLI ``agents session export --format markdown``
|
||||
command and the :meth:`Session.as_markdown_export` domain method.
|
||||
|
||||
Round-trip tests verify that JSON export → import preserves session data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.cli.commands.session import app as session_app
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionMessage,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
_SESSION_ID = str(ULID())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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=100,
|
||||
output_tokens=50,
|
||||
estimated_cost=0.005,
|
||||
),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
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.now(),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup(context: Context) -> None:
|
||||
for path in getattr(context, "_cleanup_paths", []):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background (reuses session_cli_steps background step)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The "a session CLI runner with mocked service" step is defined in
|
||||
# session_cli_steps.py and is reused here via Behave's shared step registry.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — CLI markdown export scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("there is a mocked session for markdown export")
|
||||
def step_session_for_markdown_export(context: Context) -> None:
|
||||
session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
|
||||
context.mock_service.get.return_value = session
|
||||
context.mock_service.export_session.return_value = session.as_export_dict()
|
||||
context.session_id = _SESSION_ID
|
||||
if not hasattr(context, "_cleanup_paths"):
|
||||
context._cleanup_paths = []
|
||||
|
||||
|
||||
@given("there is a mocked session with messages for markdown export")
|
||||
def step_session_with_messages_for_markdown_export(context: Context) -> None:
|
||||
messages = [
|
||||
_make_message(MessageRole.USER, "Hello there", 0),
|
||||
_make_message(MessageRole.ASSISTANT, "Hi! How can I help?", 1),
|
||||
]
|
||||
session = _make_session(
|
||||
session_id=_SESSION_ID,
|
||||
actor_name="openai/gpt-4",
|
||||
messages=messages,
|
||||
)
|
||||
context.mock_service.get.return_value = session
|
||||
context.mock_service.export_session.return_value = session.as_export_dict()
|
||||
context.session_id = _SESSION_ID
|
||||
if not hasattr(context, "_cleanup_paths"):
|
||||
context._cleanup_paths = []
|
||||
|
||||
|
||||
@given("there is an existing markdown export file")
|
||||
def step_existing_markdown_file(context: Context) -> None:
|
||||
fd, path = tempfile.mkstemp(suffix=".md")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write("# Old content\n")
|
||||
context.existing_md_path = path
|
||||
if not hasattr(context, "_cleanup_paths"):
|
||||
context._cleanup_paths = []
|
||||
context._cleanup_paths.append(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — CLI markdown export scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run session CLI export with --format markdown")
|
||||
def step_export_markdown_stdout(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
["export", context.session_id, "--format", "markdown"],
|
||||
)
|
||||
|
||||
|
||||
@when("I run session CLI export with --format markdown to a temp file")
|
||||
def step_export_markdown_to_file(context: Context) -> None:
|
||||
fd, path = tempfile.mkstemp(suffix=".md")
|
||||
os.close(fd)
|
||||
os.unlink(path)
|
||||
context.md_export_path = path
|
||||
if not hasattr(context, "_cleanup_paths"):
|
||||
context._cleanup_paths = []
|
||||
context._cleanup_paths.append(path)
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
["export", context.session_id, "--format", "markdown", "--output", path],
|
||||
)
|
||||
|
||||
|
||||
@when("I run session CLI export with --format markdown --force to existing file")
|
||||
def step_export_markdown_force(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
[
|
||||
"export",
|
||||
context.session_id,
|
||||
"--format",
|
||||
"markdown",
|
||||
"--output",
|
||||
context.existing_md_path,
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
"I run session CLI export with --format markdown to existing file without --force"
|
||||
)
|
||||
def step_export_markdown_no_force(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
[
|
||||
"export",
|
||||
context.session_id,
|
||||
"--format",
|
||||
"markdown",
|
||||
"--output",
|
||||
context.existing_md_path,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when("I run session CLI export with --format invalid")
|
||||
def step_export_invalid_format(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
["export", context.session_id, "--format", "invalid"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — CLI markdown export scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the exported markdown file should exist")
|
||||
def step_md_file_exists(context: Context) -> None:
|
||||
assert os.path.exists(context.md_export_path), (
|
||||
f"Markdown export file not found: {context.md_export_path}"
|
||||
)
|
||||
|
||||
|
||||
@then('the exported markdown file should contain "{text}"')
|
||||
def step_md_file_contains(context: Context, text: str) -> None:
|
||||
with open(context.md_export_path, encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
assert text in content, f"Expected '{text}' in file content:\n{content[:500]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given — domain method scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session domain object with actor and messages")
|
||||
def step_domain_session_with_actor_and_messages(context: Context) -> None:
|
||||
messages = [
|
||||
_make_message(MessageRole.USER, "What is the plan?", 0),
|
||||
_make_message(MessageRole.ASSISTANT, "Here is the plan...", 1),
|
||||
]
|
||||
context.domain_session = _make_session(
|
||||
actor_name="openai/gpt-4",
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@given("a session domain object with no messages")
|
||||
def step_domain_session_no_messages(context: Context) -> None:
|
||||
context.domain_session = _make_session()
|
||||
|
||||
|
||||
@given("a session domain object with linked plans")
|
||||
def step_domain_session_with_plans(context: Context) -> None:
|
||||
plan_id = str(ULID())
|
||||
context.domain_session = _make_session(linked_plan_ids=[plan_id])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — domain method scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I call as_markdown_export on the session")
|
||||
def step_call_as_markdown_export(context: Context) -> None:
|
||||
context.md_output = context.domain_session.as_markdown_export()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — domain method scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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 output to start with '{prefix}':\n{context.md_output[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the markdown output should contain a metadata table")
|
||||
def step_md_has_metadata_table(context: Context) -> None:
|
||||
assert "## Metadata" in context.md_output, (
|
||||
f"Expected '## Metadata' in output:\n{context.md_output[:500]}"
|
||||
)
|
||||
assert "| Field | Value |" in context.md_output
|
||||
|
||||
|
||||
@then("the markdown output should contain a messages section")
|
||||
def step_md_has_messages_section(context: Context) -> None:
|
||||
assert "## Messages" in context.md_output, (
|
||||
f"Expected '## Messages' in output:\n{context.md_output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
@then("the markdown output should contain a token usage section")
|
||||
def step_md_has_token_usage(context: Context) -> None:
|
||||
assert "## Token Usage" in context.md_output, (
|
||||
f"Expected '## Token Usage' in output:\n{context.md_output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
@then('the markdown output should not contain "{text}"')
|
||||
def step_md_not_contains(context: Context, text: str) -> None:
|
||||
assert text not in context.md_output, (
|
||||
f"Expected '{text}' NOT in output:\n{context.md_output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
@then('the markdown output should contain "{section}"')
|
||||
def step_md_contains_section(context: Context, section: str) -> None:
|
||||
assert section in context.md_output, (
|
||||
f"Expected '{section}' in output:\n{context.md_output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given / When / Then — round-trip scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a session with actor and messages for round-trip")
|
||||
def step_round_trip_session(context: Context) -> None:
|
||||
messages = [
|
||||
_make_message(MessageRole.USER, "Round-trip test message", 0),
|
||||
_make_message(MessageRole.ASSISTANT, "Round-trip response", 1),
|
||||
]
|
||||
context.original_session = _make_session(
|
||||
actor_name="openai/gpt-4",
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@when("I export the session to JSON")
|
||||
def step_export_to_json(context: Context) -> None:
|
||||
context.export_data = context.original_session.as_export_dict()
|
||||
|
||||
|
||||
@when("I import the exported JSON")
|
||||
def step_import_from_json(context: Context) -> None:
|
||||
from cleveragents.application.services.session_service import (
|
||||
PersistentSessionService,
|
||||
)
|
||||
|
||||
mock_session_repo = MagicMock()
|
||||
mock_message_repo = MagicMock()
|
||||
mock_message_repo.count_for_session.return_value = 0
|
||||
|
||||
service = PersistentSessionService(
|
||||
session_repo=mock_session_repo,
|
||||
message_repo=mock_message_repo,
|
||||
)
|
||||
context.imported_session = service.import_session(context.export_data)
|
||||
|
||||
|
||||
@then("the imported session should have the same actor name")
|
||||
def step_imported_actor_matches(context: Context) -> None:
|
||||
assert context.imported_session.actor_name == context.original_session.actor_name, (
|
||||
f"Actor mismatch: {context.imported_session.actor_name!r} != "
|
||||
f"{context.original_session.actor_name!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the imported session should have the same message count")
|
||||
def step_imported_message_count_matches(context: Context) -> None:
|
||||
assert (
|
||||
context.imported_session.message_count == context.original_session.message_count
|
||||
), (
|
||||
f"Message count mismatch: {context.imported_session.message_count} != "
|
||||
f"{context.original_session.message_count}"
|
||||
)
|
||||
|
||||
|
||||
@then("the imported session should have the same namespace")
|
||||
def step_imported_namespace_matches(context: Context) -> None:
|
||||
assert context.imported_session.namespace == context.original_session.namespace, (
|
||||
f"Namespace mismatch: {context.imported_session.namespace!r} != "
|
||||
f"{context.original_session.namespace!r}"
|
||||
)
|
||||
@@ -426,6 +426,22 @@ def delete(
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
def _export_as_markdown(service: SessionService, session_id: str) -> str:
|
||||
"""Build a Markdown export string for *session_id* via *service*.
|
||||
|
||||
Fetches the full session (including all messages) through the service's
|
||||
export mechanism and delegates rendering to
|
||||
:func:`~cleveragents.domain.models.core.session_markdown.render_session_markdown`.
|
||||
"""
|
||||
from cleveragents.domain.models.core.session_markdown import (
|
||||
build_session_from_export_dict,
|
||||
)
|
||||
|
||||
export_data = service.export_session(session_id)
|
||||
session = build_session_from_export_dict(export_data)
|
||||
return session.as_markdown_export()
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def export_session(
|
||||
session_id: Annotated[
|
||||
@@ -440,21 +456,44 @@ def export_session(
|
||||
bool,
|
||||
typer.Option("--force", help="Overwrite existing output file"),
|
||||
] = False,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help=(
|
||||
"Export format: json (full fidelity, re-importable) "
|
||||
"or markdown (human-readable)"
|
||||
),
|
||||
),
|
||||
] = "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.
|
||||
JSON format (default) writes full session data with checksum and is
|
||||
re-importable via ``agents session import``. Markdown format produces
|
||||
a human-readable document but cannot be re-imported.
|
||||
|
||||
Examples:
|
||||
agents session export 01HXYZ...
|
||||
agents session export 01HXYZ... --format markdown
|
||||
agents session export 01HXYZ... -o session.json
|
||||
agents session export 01HXYZ... -o session.json --force
|
||||
agents session export 01HXYZ... -o session.md --format markdown --force
|
||||
"""
|
||||
if fmt not in ("json", "markdown"):
|
||||
console.print(
|
||||
f"[red]Invalid format:[/red] {fmt!r}. Choose 'json' or 'markdown'."
|
||||
)
|
||||
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 == "markdown":
|
||||
content = _export_as_markdown(service, session_id)
|
||||
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 +504,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}")
|
||||
|
||||
@@ -366,6 +366,19 @@ class Session(BaseModel):
|
||||
}
|
||||
return result
|
||||
|
||||
def as_markdown_export(self) -> str:
|
||||
"""Return a human-readable Markdown representation of the session.
|
||||
|
||||
Produces a Markdown document with metadata, full message history,
|
||||
token usage, and linked plan IDs. The Markdown format is **not**
|
||||
re-importable; use :meth:`as_export_dict` for round-trip fidelity.
|
||||
"""
|
||||
from cleveragents.domain.models.core.session_markdown import (
|
||||
render_session_markdown,
|
||||
)
|
||||
|
||||
return render_session_markdown(self)
|
||||
|
||||
def as_export_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable dict for session export.
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Markdown rendering for Session export.
|
||||
|
||||
Provides :func:`render_session_markdown` which converts a
|
||||
:class:`~cleveragents.domain.models.core.session.Session` into a
|
||||
human-readable Markdown document, and :func:`build_session_from_export_dict`
|
||||
which reconstructs a :class:`Session` from an export dict for rendering.
|
||||
|
||||
The Markdown format is **not** re-importable. For round-trip fidelity use
|
||||
:meth:`~cleveragents.domain.models.core.session.Session.as_export_dict`
|
||||
together with
|
||||
:meth:`~cleveragents.application.services.session_service.PersistentSessionService.import_session`.
|
||||
|
||||
Based on specification.md ``agents session export --format markdown`` section
|
||||
and issue #1004 (feat(tui): implement session export/import (JSON + Markdown)).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.domain.models.core.session import Session
|
||||
|
||||
|
||||
def build_session_from_export_dict(export_data: dict[str, Any]) -> Session:
|
||||
"""Reconstruct a :class:`Session` from an *export_data* dict.
|
||||
|
||||
Used by the CLI ``export --format markdown`` path to hydrate a full
|
||||
:class:`Session` (including messages) from the dict returned by
|
||||
:meth:`~cleveragents.application.services.session_service.PersistentSessionService.export_session`.
|
||||
|
||||
Args:
|
||||
export_data: Dict previously produced by ``export_session()``.
|
||||
|
||||
Returns:
|
||||
A :class:`Session` with all messages and token usage populated.
|
||||
"""
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
Session,
|
||||
SessionMessage,
|
||||
SessionTokenUsage,
|
||||
)
|
||||
|
||||
msgs = [
|
||||
SessionMessage(
|
||||
message_id=m["message_id"],
|
||||
role=MessageRole(m["role"]),
|
||||
content=m["content"],
|
||||
sequence=m["sequence"],
|
||||
timestamp=datetime.datetime.fromisoformat(m["timestamp"]),
|
||||
metadata=m.get("metadata", {}),
|
||||
tool_call_id=m.get("tool_call_id"),
|
||||
)
|
||||
for m in export_data.get("messages", [])
|
||||
]
|
||||
tu_data = export_data.get("token_usage", {})
|
||||
return Session(
|
||||
session_id=export_data["session_id"],
|
||||
actor_name=export_data.get("actor_name"),
|
||||
namespace=export_data.get("namespace", "local"),
|
||||
messages=msgs,
|
||||
linked_plan_ids=export_data.get("linked_plan_ids", []),
|
||||
token_usage=SessionTokenUsage(
|
||||
input_tokens=tu_data.get("input_tokens", 0),
|
||||
output_tokens=tu_data.get("output_tokens", 0),
|
||||
estimated_cost=tu_data.get("estimated_cost", 0.0),
|
||||
),
|
||||
metadata=export_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
def render_session_markdown(session: Session) -> str:
|
||||
"""Render *session* as a Markdown document.
|
||||
|
||||
The document structure is:
|
||||
|
||||
1. ``# Session <id>`` — top-level heading
|
||||
2. ``## Metadata`` — GFM table with actor, namespace, timestamps, count
|
||||
3. ``## Messages`` — one ``###`` sub-heading per message (role + timestamp)
|
||||
4. ``## Token Usage`` — GFM table with input/output tokens and cost
|
||||
5. ``## Linked Plans`` — bullet list (omitted when empty)
|
||||
|
||||
Args:
|
||||
session: The :class:`Session` to render.
|
||||
|
||||
Returns:
|
||||
A UTF-8 Markdown string.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# ── Title ──────────────────────────────────────────────────────────────
|
||||
lines.append(f"# Session {session.session_id}")
|
||||
lines.append("")
|
||||
|
||||
# ── Metadata table ─────────────────────────────────────────────────────
|
||||
lines.append("## Metadata")
|
||||
lines.append("")
|
||||
lines.append("| Field | Value |")
|
||||
lines.append("|-------|-------|")
|
||||
lines.append(f"| Actor | {session.actor_name or '(none)'} |")
|
||||
lines.append(f"| Namespace | {session.namespace} |")
|
||||
lines.append(f"| Created | {session.created_at.isoformat()} |")
|
||||
lines.append(f"| Updated | {session.updated_at.isoformat()} |")
|
||||
lines.append(f"| Messages | {session.message_count} |")
|
||||
lines.append("")
|
||||
|
||||
# ── Messages ───────────────────────────────────────────────────────────
|
||||
if session.messages:
|
||||
lines.append("## Messages")
|
||||
lines.append("")
|
||||
for msg in session.messages:
|
||||
lines.append(f"### [{msg.role.value}] \u2014 {msg.timestamp.isoformat()}")
|
||||
lines.append("")
|
||||
lines.append(msg.content)
|
||||
lines.append("")
|
||||
|
||||
# ── Token usage ────────────────────────────────────────────────────────
|
||||
tu = session.token_usage
|
||||
lines.append("## Token Usage")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Value |")
|
||||
lines.append("|--------|-------|")
|
||||
lines.append(f"| Input Tokens | {tu.input_tokens} |")
|
||||
lines.append(f"| Output Tokens | {tu.output_tokens} |")
|
||||
lines.append(f"| Estimated Cost | ${tu.estimated_cost:.4f} |")
|
||||
lines.append("")
|
||||
|
||||
# ── Linked plans ───────────────────────────────────────────────────────
|
||||
if session.linked_plan_ids:
|
||||
lines.append("## Linked Plans")
|
||||
lines.append("")
|
||||
for pid in session.linked_plan_ids:
|
||||
lines.append(f"- {pid}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user