Skip to main content

supercode_harness/
human_export.rs

1//! P4e (COMPOSABLE-HARNESS-DESIGN.md §1.6/§3.1 `core.session.export_format`,
2//! catalog:283 "transcript export for humans"): a READ-ONLY rendering of a
3//! [`crate::Session`]'s conversation into text a human reads directly
4//! (terminal/file/clipboard) or opens in a browser — CC's `/export`+`/copy`,
5//! CX's Ctrl+O copy-last. This is core, not gated by the `session.share`
6//! module (§1.6: "export-to-human is universal while *share links* … are
7//! the OC+PI-only part `session.share` actually narrows to").
8//!
9//! Deliberately distinct from [`crate::reduce::export_session`], which
10//! translates a session losslessly BETWEEN harness wire formats (priority-1
11//! "translate" — machine-to-machine, round-trippable, JSONL). This module
12//! goes the other direction: session (any harness, already loaded) to a
13//! human-readable rendering (JSONL in, prose/markup out — deliberately NOT
14//! round-trippable, and never claims to be). Per §1.13, the session DATA
15//! itself stays typed/lossless in the sidecar; a render is a projection a
16//! human reads, never a channel anything is reconstructed from — so
17//! [`render_transcript`] takes `&Session` (never mutates it) and returns an
18//! owned `String`.
19
20use crate::message::{ChatMessage, Role};
21use crate::session::Session;
22
23/// `core.session.export_format` (§3.1): which rendering
24/// [`render_transcript`] produces.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum HumanExportFormat {
27    /// Plain text, one paragraph per message, role-labeled headers. The
28    /// default.
29    #[default]
30    Text,
31    /// A minimal, dependency-free (no template engine) standalone HTML
32    /// document — safe to open directly in a browser.
33    Html,
34}
35
36impl HumanExportFormat {
37    /// Parse the `"text"` / `"html"` config strings (§3.1
38    /// `core.session.export_format`). Unrecognized input is `None` — the
39    /// caller decides the fail-safe fallback (mirrors
40    /// `SteeringMode::parse`'s contract).
41    pub fn parse(s: &str) -> Option<HumanExportFormat> {
42        match s {
43            "text" => Some(HumanExportFormat::Text),
44            "html" => Some(HumanExportFormat::Html),
45            _ => None,
46        }
47    }
48}
49
50/// Render `session`'s conversation (`session.messages`, in order) for a
51/// human, in `format`. Pure/read-only: `session` is untouched, and calling
52/// this twice on the same session is idempotent (same output both times).
53/// System messages are included — they're part of the honest record of
54/// what happened (e.g. compaction markers, `context_injections` blocks).
55pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
56    render_messages(
57        &session.messages,
58        session.meta.session_id.as_deref(),
59        session.meta.model.as_deref(),
60        format,
61    )
62}
63
64/// The lower-level entry point [`render_transcript`] delegates to: render a
65/// bare `messages` slice (no [`Session`] wrapper required) — for a caller
66/// (e.g. the CLI's `sessions export`) that already has the parsed
67/// [`ChatMessage`]s and a session name/model but not a full [`Session`]
68/// (which is `#[non_exhaustive]` and cannot be constructed outside this
69/// crate). Same read-only/idempotent contract as [`render_transcript`].
70pub fn render_messages(
71    messages: &[ChatMessage],
72    session_id: Option<&str>,
73    model: Option<&str>,
74    format: HumanExportFormat,
75) -> String {
76    match format {
77        HumanExportFormat::Text => render_text(messages, session_id, model),
78        HumanExportFormat::Html => render_html(messages, session_id, model),
79    }
80}
81
82fn role_label(role: Role) -> &'static str {
83    match role {
84        Role::System => "System",
85        Role::User => "User",
86        Role::Assistant => "Assistant",
87        Role::Tool => "Tool",
88    }
89}
90
91/// The body text a single message contributes to a render: its plain
92/// `content` if set, else a placeholder describing any tool calls / an
93/// empty turn — every message contributes SOME visible line, so a reader
94/// never sees a silently-skipped turn.
95fn message_body(msg: &ChatMessage) -> String {
96    let mut parts = Vec::new();
97    if let Some(content) = &msg.content {
98        if !content.is_empty() {
99            parts.push(content.clone());
100        }
101    }
102    if let Some(calls) = &msg.tool_calls {
103        for call in calls {
104            parts.push(format!(
105                "[tool call: {}({})]",
106                call.function.name, call.function.arguments
107            ));
108        }
109    }
110    if parts.is_empty() {
111        parts.push("(empty)".to_string());
112    }
113    parts.join("\n")
114}
115
116fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
117    let mut out = String::new();
118    if let Some(id) = session_id {
119        out.push_str(&format!("Session: {id}\n"));
120    }
121    if let Some(model) = model {
122        out.push_str(&format!("Model: {model}\n"));
123    }
124    if !out.is_empty() {
125        out.push('\n');
126    }
127    for (i, msg) in messages.iter().enumerate() {
128        if i > 0 {
129            out.push('\n');
130        }
131        out.push_str(&format!("## {}\n", role_label(msg.role)));
132        out.push_str(&message_body(msg));
133        out.push('\n');
134    }
135    out
136}
137
138fn html_escape(s: &str) -> String {
139    s.replace('&', "&amp;")
140        .replace('<', "&lt;")
141        .replace('>', "&gt;")
142        .replace('"', "&quot;")
143}
144
145fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
146    let mut out = String::new();
147    out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
148    out.push_str("<title>supercode session export</title>\n");
149    out.push_str(
150        "<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
151         .msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
152         .role{font-weight:bold}</style>\n",
153    );
154    out.push_str("</head><body>\n");
155    if let Some(id) = session_id {
156        out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
157    }
158    if let Some(model) = model {
159        out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
160    }
161    for msg in messages {
162        out.push_str("<div class=\"msg\"><div class=\"role\">");
163        out.push_str(role_label(msg.role));
164        out.push_str("</div><div class=\"body\">");
165        out.push_str(&html_escape(&message_body(msg)));
166        out.push_str("</div></div>\n");
167    }
168    out.push_str("</body></html>\n");
169    out
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::message::{FunctionCall, ToolCall};
176    use crate::session::{Session, SessionSource};
177
178    fn sample_session() -> Session {
179        let mut session = Session::from_native_messages(vec![
180            ChatMessage::system("you are helpful".to_string()),
181            ChatMessage::user("list files".to_string()),
182            ChatMessage {
183                role: Role::Assistant,
184                content: None,
185                content_parts: None,
186                tool_calls: Some(vec![ToolCall {
187                    id: "call-1".to_string(),
188                    kind: "function".to_string(),
189                    function: FunctionCall {
190                        name: "bash".to_string(),
191                        arguments: r#"{"command":"ls"}"#.to_string(),
192                    },
193                }]),
194                tool_call_id: None,
195                name: None,
196                metadata: Default::default(),
197            },
198            ChatMessage::tool_result("call-1", "bash", "a.txt\nb.txt".to_string()),
199        ]);
200        session.meta.source = SessionSource::ClaudeCode;
201        session.meta.session_id = Some("sess-1".to_string());
202        session.meta.model = Some("anthropic/claude-opus-4-8".to_string());
203        session
204    }
205
206    /// Default-unchanged: `HumanExportFormat::default()` is `Text` (the
207    /// annotated §3.1 schema's illustrative `export_format = "text"`
208    /// example value).
209    #[test]
210    fn default_export_format_is_text() {
211        assert_eq!(HumanExportFormat::default(), HumanExportFormat::Text);
212    }
213
214    #[test]
215    fn parse_round_trips_known_strings() {
216        assert_eq!(
217            HumanExportFormat::parse("text"),
218            Some(HumanExportFormat::Text)
219        );
220        assert_eq!(
221            HumanExportFormat::parse("html"),
222            Some(HumanExportFormat::Html)
223        );
224        assert_eq!(HumanExportFormat::parse("xml"), None);
225    }
226
227    /// Happy path: every message contributes a visible section, including
228    /// the tool-call/tool-result pair, and the render never panics/loses a
229    /// turn silently.
230    #[test]
231    fn text_render_includes_every_message() {
232        let session = sample_session();
233        let text = render_transcript(&session, HumanExportFormat::Text);
234        assert!(text.contains("Session: sess-1"));
235        assert!(text.contains("Model: anthropic/claude-opus-4-8"));
236        assert!(text.contains("## System"));
237        assert!(text.contains("you are helpful"));
238        assert!(text.contains("## User"));
239        assert!(text.contains("list files"));
240        assert!(text.contains("## Assistant"));
241        assert!(text.contains("[tool call: bash({\"command\":\"ls\"})]"));
242        assert!(text.contains("## Tool"));
243        assert!(text.contains("a.txt\nb.txt"));
244    }
245
246    /// Boundary: an empty session (no messages) renders without panicking
247    /// and without fabricating content.
248    #[test]
249    fn text_render_handles_empty_session() {
250        let mut session = sample_session();
251        session.messages.clear();
252        let text = render_transcript(&session, HumanExportFormat::Text);
253        assert!(text.contains("Session: sess-1"));
254        assert!(!text.contains("##"));
255    }
256
257    /// HTML render escapes hostile content instead of injecting it — a
258    /// transcript containing `<script>` must not become live markup in the
259    /// rendered document.
260    #[test]
261    fn html_render_escapes_message_content() {
262        let mut session = sample_session();
263        session
264            .messages
265            .push(ChatMessage::user("<script>alert(1)</script>".to_string()));
266        let html = render_transcript(&session, HumanExportFormat::Html);
267        assert!(!html.contains("<script>alert(1)</script>"));
268        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
269        assert!(html.contains("<!doctype html>"));
270    }
271
272    /// Read-only guarantee (§1.6 "a RENDER … never mutates the session"):
273    /// rendering twice is idempotent and the session's own fields are
274    /// untouched (checked via a full clone-and-compare of the messages,
275    /// since `Session` has no derived `PartialEq`).
276    #[test]
277    fn render_is_read_only_and_idempotent() {
278        let session = sample_session();
279        let before_len = session.messages.len();
280        let first = render_transcript(&session, HumanExportFormat::Text);
281        let second = render_transcript(&session, HumanExportFormat::Text);
282        assert_eq!(first, second);
283        assert_eq!(session.messages.len(), before_len);
284    }
285
286    /// `render_messages` is what a caller without a full `Session` (e.g.
287    /// the CLI, which only has a parsed `Vec<ChatMessage>` plus a name) can
288    /// call directly — proves it agrees with `render_transcript` on the
289    /// same underlying data.
290    #[test]
291    fn render_messages_agrees_with_render_transcript() {
292        let session = sample_session();
293        let via_session = render_transcript(&session, HumanExportFormat::Text);
294        let via_messages = render_messages(
295            &session.messages,
296            session.meta.session_id.as_deref(),
297            session.meta.model.as_deref(),
298            HumanExportFormat::Text,
299        );
300        assert_eq!(via_session, via_messages);
301    }
302}