supercode-harness 0.4.13

The optional native Supercode agent and tool harness
Documentation
//! P4e (COMPOSABLE-HARNESS-DESIGN.md §1.6/§3.1 `core.session.export_format`,
//! catalog:283 "transcript export for humans"): a READ-ONLY rendering of a
//! [`crate::Session`]'s conversation into text a human reads directly
//! (terminal/file/clipboard) or opens in a browser — CC's `/export`+`/copy`,
//! CX's Ctrl+O copy-last. This is core, not gated by the `session.share`
//! module (§1.6: "export-to-human is universal while *share links* … are
//! the OC+PI-only part `session.share` actually narrows to").
//!
//! Deliberately distinct from [`crate::reduce::export_session`], which
//! translates a session losslessly BETWEEN harness wire formats (priority-1
//! "translate" — machine-to-machine, round-trippable, JSONL). This module
//! goes the other direction: session (any harness, already loaded) to a
//! human-readable rendering (JSONL in, prose/markup out — deliberately NOT
//! round-trippable, and never claims to be). Per §1.13, the session DATA
//! itself stays typed/lossless in the sidecar; a render is a projection a
//! human reads, never a channel anything is reconstructed from — so
//! [`render_transcript`] takes `&Session` (never mutates it) and returns an
//! owned `String`.

use crate::message::{ChatMessage, Role};
use crate::session::Session;

/// `core.session.export_format` (§3.1): which rendering
/// [`render_transcript`] produces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HumanExportFormat {
    /// Plain text, one paragraph per message, role-labeled headers. The
    /// default.
    #[default]
    Text,
    /// A minimal, dependency-free (no template engine) standalone HTML
    /// document — safe to open directly in a browser.
    Html,
}

impl HumanExportFormat {
    /// Parse the `"text"` / `"html"` config strings (§3.1
    /// `core.session.export_format`). Unrecognized input is `None` — the
    /// caller decides the fail-safe fallback (mirrors
    /// `SteeringMode::parse`'s contract).
    pub fn parse(s: &str) -> Option<HumanExportFormat> {
        match s {
            "text" => Some(HumanExportFormat::Text),
            "html" => Some(HumanExportFormat::Html),
            _ => None,
        }
    }
}

/// Render `session`'s conversation (`session.messages`, in order) for a
/// human, in `format`. Pure/read-only: `session` is untouched, and calling
/// this twice on the same session is idempotent (same output both times).
/// System messages are included — they're part of the honest record of
/// what happened (e.g. compaction markers, `context_injections` blocks).
pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
    render_messages(
        &session.messages,
        session.meta.session_id.as_deref(),
        session.meta.model.as_deref(),
        format,
    )
}

/// The lower-level entry point [`render_transcript`] delegates to: render a
/// bare `messages` slice (no [`Session`] wrapper required) — for a caller
/// (e.g. the CLI's `sessions export`) that already has the parsed
/// [`ChatMessage`]s and a session name/model but not a full [`Session`]
/// (which is `#[non_exhaustive]` and cannot be constructed outside this
/// crate). Same read-only/idempotent contract as [`render_transcript`].
pub fn render_messages(
    messages: &[ChatMessage],
    session_id: Option<&str>,
    model: Option<&str>,
    format: HumanExportFormat,
) -> String {
    match format {
        HumanExportFormat::Text => render_text(messages, session_id, model),
        HumanExportFormat::Html => render_html(messages, session_id, model),
    }
}

fn role_label(role: Role) -> &'static str {
    match role {
        Role::System => "System",
        Role::User => "User",
        Role::Assistant => "Assistant",
        Role::Tool => "Tool",
    }
}

/// The body text a single message contributes to a render: its plain
/// `content` if set, else a placeholder describing any tool calls / an
/// empty turn — every message contributes SOME visible line, so a reader
/// never sees a silently-skipped turn.
fn message_body(msg: &ChatMessage) -> String {
    let mut parts = Vec::new();
    if let Some(content) = &msg.content {
        if !content.is_empty() {
            parts.push(content.clone());
        }
    }
    if let Some(calls) = &msg.tool_calls {
        for call in calls {
            parts.push(format!(
                "[tool call: {}({})]",
                call.function.name, call.function.arguments
            ));
        }
    }
    if parts.is_empty() {
        parts.push("(empty)".to_string());
    }
    parts.join("\n")
}

fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
    let mut out = String::new();
    if let Some(id) = session_id {
        out.push_str(&format!("Session: {id}\n"));
    }
    if let Some(model) = model {
        out.push_str(&format!("Model: {model}\n"));
    }
    if !out.is_empty() {
        out.push('\n');
    }
    for (i, msg) in messages.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(&format!("## {}\n", role_label(msg.role)));
        out.push_str(&message_body(msg));
        out.push('\n');
    }
    out
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
    let mut out = String::new();
    out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
    out.push_str("<title>supercode session export</title>\n");
    out.push_str(
        "<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
         .msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
         .role{font-weight:bold}</style>\n",
    );
    out.push_str("</head><body>\n");
    if let Some(id) = session_id {
        out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
    }
    if let Some(model) = model {
        out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
    }
    for msg in messages {
        out.push_str("<div class=\"msg\"><div class=\"role\">");
        out.push_str(role_label(msg.role));
        out.push_str("</div><div class=\"body\">");
        out.push_str(&html_escape(&message_body(msg)));
        out.push_str("</div></div>\n");
    }
    out.push_str("</body></html>\n");
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::message::{FunctionCall, ToolCall};
    use crate::session::{Session, SessionSource};

    fn sample_session() -> Session {
        let mut session = Session::from_native_messages(vec![
            ChatMessage::system("you are helpful".to_string()),
            ChatMessage::user("list files".to_string()),
            ChatMessage {
                role: Role::Assistant,
                content: None,
                content_parts: None,
                tool_calls: Some(vec![ToolCall {
                    id: "call-1".to_string(),
                    kind: "function".to_string(),
                    function: FunctionCall {
                        name: "bash".to_string(),
                        arguments: r#"{"command":"ls"}"#.to_string(),
                    },
                }]),
                tool_call_id: None,
                name: None,
                metadata: Default::default(),
            },
            ChatMessage::tool_result("call-1", "bash", "a.txt\nb.txt".to_string()),
        ]);
        session.meta.source = SessionSource::ClaudeCode;
        session.meta.session_id = Some("sess-1".to_string());
        session.meta.model = Some("anthropic/claude-opus-4-8".to_string());
        session
    }

    /// Default-unchanged: `HumanExportFormat::default()` is `Text` (the
    /// annotated §3.1 schema's illustrative `export_format = "text"`
    /// example value).
    #[test]
    fn default_export_format_is_text() {
        assert_eq!(HumanExportFormat::default(), HumanExportFormat::Text);
    }

    #[test]
    fn parse_round_trips_known_strings() {
        assert_eq!(
            HumanExportFormat::parse("text"),
            Some(HumanExportFormat::Text)
        );
        assert_eq!(
            HumanExportFormat::parse("html"),
            Some(HumanExportFormat::Html)
        );
        assert_eq!(HumanExportFormat::parse("xml"), None);
    }

    /// Happy path: every message contributes a visible section, including
    /// the tool-call/tool-result pair, and the render never panics/loses a
    /// turn silently.
    #[test]
    fn text_render_includes_every_message() {
        let session = sample_session();
        let text = render_transcript(&session, HumanExportFormat::Text);
        assert!(text.contains("Session: sess-1"));
        assert!(text.contains("Model: anthropic/claude-opus-4-8"));
        assert!(text.contains("## System"));
        assert!(text.contains("you are helpful"));
        assert!(text.contains("## User"));
        assert!(text.contains("list files"));
        assert!(text.contains("## Assistant"));
        assert!(text.contains("[tool call: bash({\"command\":\"ls\"})]"));
        assert!(text.contains("## Tool"));
        assert!(text.contains("a.txt\nb.txt"));
    }

    /// Boundary: an empty session (no messages) renders without panicking
    /// and without fabricating content.
    #[test]
    fn text_render_handles_empty_session() {
        let mut session = sample_session();
        session.messages.clear();
        let text = render_transcript(&session, HumanExportFormat::Text);
        assert!(text.contains("Session: sess-1"));
        assert!(!text.contains("##"));
    }

    /// HTML render escapes hostile content instead of injecting it — a
    /// transcript containing `<script>` must not become live markup in the
    /// rendered document.
    #[test]
    fn html_render_escapes_message_content() {
        let mut session = sample_session();
        session
            .messages
            .push(ChatMessage::user("<script>alert(1)</script>".to_string()));
        let html = render_transcript(&session, HumanExportFormat::Html);
        assert!(!html.contains("<script>alert(1)</script>"));
        assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
        assert!(html.contains("<!doctype html>"));
    }

    /// Read-only guarantee (§1.6 "a RENDER … never mutates the session"):
    /// rendering twice is idempotent and the session's own fields are
    /// untouched (checked via a full clone-and-compare of the messages,
    /// since `Session` has no derived `PartialEq`).
    #[test]
    fn render_is_read_only_and_idempotent() {
        let session = sample_session();
        let before_len = session.messages.len();
        let first = render_transcript(&session, HumanExportFormat::Text);
        let second = render_transcript(&session, HumanExportFormat::Text);
        assert_eq!(first, second);
        assert_eq!(session.messages.len(), before_len);
    }

    /// `render_messages` is what a caller without a full `Session` (e.g.
    /// the CLI, which only has a parsed `Vec<ChatMessage>` plus a name) can
    /// call directly — proves it agrees with `render_transcript` on the
    /// same underlying data.
    #[test]
    fn render_messages_agrees_with_render_transcript() {
        let session = sample_session();
        let via_session = render_transcript(&session, HumanExportFormat::Text);
        let via_messages = render_messages(
            &session.messages,
            session.meta.session_id.as_deref(),
            session.meta.model.as_deref(),
            HumanExportFormat::Text,
        );
        assert_eq!(via_session, via_messages);
    }
}