Skip to main content

pi/core/export_html/
mod.rs

1//! Self-contained HTML session export compatible with the TypeScript viewer.
2
3pub mod ansi_to_html;
4
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use base64::Engine as _;
10use indexmap::IndexMap;
11use pi_agent::{AgentState, AgentStateSnapshot};
12use pi_ai::{AssistantContent, Message, ToolResultContent};
13use serde::Serialize;
14use serde_json::Value;
15use thiserror::Error;
16
17use crate::core::config::{APP_NAME, PathInputOptions, normalize_path, resolve_path};
18use crate::core::sessions::{SessionEntry, SessionError, SessionHeader, SessionManager};
19
20const TEMPLATE_HTML: &str = include_str!("../../../assets/export-html/template.html");
21const TEMPLATE_CSS: &str = include_str!("../../../assets/export-html/template.css");
22const TEMPLATE_JS: &str = include_str!("../../../assets/export-html/template.js");
23const MARKED_JS: &str = include_str!("../../../assets/export-html/vendor/marked.min.js");
24const HIGHLIGHT_JS: &str = include_str!("../../../assets/export-html/vendor/highlight.min.js");
25const DARK_THEME: &str = include_str!("../../../assets/export-html/vendor/dark.json");
26const LIGHT_THEME: &str = include_str!("../../../assets/export-html/vendor/light.json");
27const TEMPLATE_RENDERED_TOOLS: [&str; 5] = ["bash", "read", "write", "edit", "ls"];
28
29/// A tool definition embedded into an exported session.
30#[derive(Clone, Debug, PartialEq, Serialize)]
31pub struct ToolInfo {
32    /// Registered tool name.
33    pub name: String,
34    /// Human-readable description.
35    pub description: String,
36    /// JSON Schema for arguments.
37    pub parameters: Value,
38}
39
40/// Optional live-agent data unavailable when exporting an arbitrary file.
41#[derive(Clone, Debug, Default, PartialEq)]
42pub struct SessionExportState {
43    /// System prompt active for the session.
44    pub system_prompt: String,
45    /// Tools active for the session.
46    pub tools: Vec<ToolInfo>,
47}
48
49impl SessionExportState {
50    /// Capture the exportable subset of mutable agent state.
51    #[must_use]
52    pub fn from_agent_state(state: &AgentState) -> Self {
53        Self {
54            system_prompt: state.system_prompt.clone(),
55            tools: state
56                .tools
57                .iter()
58                .map(|tool| ToolInfo {
59                    name: tool.name().to_owned(),
60                    description: tool.description().to_owned(),
61                    parameters: tool.parameters().clone(),
62                })
63                .collect(),
64        }
65    }
66
67    /// Capture the exportable subset of an immutable state snapshot.
68    #[must_use]
69    pub fn from_agent_snapshot(state: &AgentStateSnapshot) -> Self {
70        Self {
71            system_prompt: state.system_prompt.clone(),
72            tools: state
73                .tools
74                .iter()
75                .map(|tool| ToolInfo {
76                    name: tool.name().to_owned(),
77                    description: tool.description().to_owned(),
78                    parameters: tool.parameters().clone(),
79                })
80                .collect(),
81        }
82    }
83}
84
85/// HTML fragments returned by a custom tool-result renderer.
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct RenderedResult {
88    /// Collapsed result fragment.
89    pub collapsed: Option<String>,
90    /// Expanded result fragment.
91    pub expanded: Option<String>,
92}
93
94/// Renderer seam for extension-defined tools.
95pub trait ToolHtmlRenderer: Send + Sync {
96    /// Render a custom tool call, or return `None` to use the generic viewer.
97    fn render_call(&self, tool_call_id: &str, tool_name: &str, arguments: &Value)
98    -> Option<String>;
99
100    /// Render a custom tool result, or return `None` to use the generic viewer.
101    fn render_result(
102        &self,
103        tool_call_id: &str,
104        tool_name: &str,
105        result: &[ToolResultContent],
106        details: Option<&Value>,
107        is_error: bool,
108    ) -> Option<RenderedResult>;
109}
110
111/// Pre-rendered custom-tool fragments keyed by tool-call id.
112#[derive(Clone, Debug, Default, PartialEq, Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RenderedToolHtml {
115    /// Tool-call fragment.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub call_html: Option<String>,
118    /// Collapsed result fragment.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub result_html_collapsed: Option<String>,
121    /// Expanded result fragment.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub result_html_expanded: Option<String>,
124}
125
126/// Fully resolved colors used by the HTML viewer.
127#[derive(Clone, Debug, PartialEq)]
128pub struct ExportTheme {
129    colors: Vec<(String, String)>,
130    page_background: Option<String>,
131    card_background: Option<String>,
132    info_background: Option<String>,
133}
134
135#[derive(serde::Deserialize)]
136struct ThemeDocument {
137    #[serde(default)]
138    vars: IndexMap<String, ThemeColor>,
139    #[serde(default)]
140    colors: IndexMap<String, ThemeColor>,
141    #[serde(default)]
142    export: ThemeExport,
143}
144
145#[derive(Clone, serde::Deserialize)]
146#[serde(untagged)]
147enum ThemeColor {
148    Text(String),
149    Ansi(u16),
150}
151
152#[derive(Default, serde::Deserialize)]
153#[serde(rename_all = "camelCase")]
154struct ThemeExport {
155    #[serde(rename = "pageBg")]
156    page: Option<ThemeColor>,
157    #[serde(rename = "cardBg")]
158    card: Option<ThemeColor>,
159    #[serde(rename = "infoBg")]
160    info: Option<ThemeColor>,
161}
162
163impl ExportTheme {
164    /// Resolve a pi theme JSON document to CSS-compatible colors.
165    ///
166    /// # Errors
167    ///
168    /// Returns a JSON error when the document is malformed.
169    pub fn from_json(source: &str, light: bool) -> Result<Self, serde_json::Error> {
170        let document: ThemeDocument = serde_json::from_str(source)?;
171        let default_text = if light { "#000000" } else { "#e5e5e7" };
172        let colors = document
173            .colors
174            .iter()
175            .map(|(name, color)| {
176                (
177                    name.clone(),
178                    resolve_theme_color(color, &document.vars, default_text),
179                )
180            })
181            .collect();
182        Ok(Self {
183            colors,
184            page_background: document
185                .export
186                .page
187                .as_ref()
188                .map(|value| resolve_theme_color(value, &document.vars, ""))
189                .filter(|value| !value.is_empty()),
190            card_background: document
191                .export
192                .card
193                .as_ref()
194                .map(|value| resolve_theme_color(value, &document.vars, ""))
195                .filter(|value| !value.is_empty()),
196            info_background: document
197                .export
198                .info
199                .as_ref()
200                .map(|value| resolve_theme_color(value, &document.vars, ""))
201                .filter(|value| !value.is_empty()),
202        })
203    }
204
205    fn built_in(name: Option<&str>) -> Self {
206        let (source, light) = if name == Some("light") {
207            (LIGHT_THEME, true)
208        } else {
209            (DARK_THEME, false)
210        };
211        Self::from_json(source, light).unwrap_or_else(|_| Self {
212            colors: vec![("userMessageBg".to_owned(), "#343541".to_owned())],
213            page_background: Some("#18181e".to_owned()),
214            card_background: Some("#1e1e24".to_owned()),
215            info_background: Some("#3c3728".to_owned()),
216        })
217    }
218}
219
220fn resolve_theme_color(
221    color: &ThemeColor,
222    variables: &IndexMap<String, ThemeColor>,
223    default_text: &str,
224) -> String {
225    let mut current = color;
226    for _ in 0..variables.len().saturating_add(1) {
227        match current {
228            ThemeColor::Ansi(index) => return ansi_256_to_hex(*index),
229            ThemeColor::Text(text) if text.is_empty() => return default_text.to_owned(),
230            ThemeColor::Text(text) => match variables.get(text) {
231                Some(next) => current = next,
232                None => return text.clone(),
233            },
234        }
235    }
236    match current {
237        ThemeColor::Ansi(index) => ansi_256_to_hex(*index),
238        ThemeColor::Text(text) => text.clone(),
239    }
240}
241
242fn ansi_256_to_hex(index: u16) -> String {
243    const BASIC: [&str; 16] = [
244        "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
245        "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
246    ];
247    let index = index.min(255);
248    if index < 16 {
249        return BASIC[usize::from(index)].to_owned();
250    }
251    if index < 232 {
252        let cube = index - 16;
253        let part = |value: u16| if value == 0 { 0 } else { 55 + value * 40 };
254        return format!(
255            "#{:02x}{:02x}{:02x}",
256            part(cube / 36),
257            part((cube % 36) / 6),
258            part(cube % 6)
259        );
260    }
261    let gray = 8 + (index - 232) * 10;
262    format!("#{gray:02x}{gray:02x}{gray:02x}")
263}
264
265/// HTML export options.
266#[derive(Default)]
267pub struct ExportOptions<'a> {
268    /// Output path; a pi-compatible basename is generated when absent.
269    pub output_path: Option<PathBuf>,
270    /// Built-in theme name (`dark` or `light`). Unknown names use `dark`.
271    pub theme_name: Option<String>,
272    /// Resolved custom theme, taking precedence over `theme_name`.
273    pub theme: Option<ExportTheme>,
274    /// Optional custom-tool renderer.
275    pub tool_renderer: Option<&'a dyn ToolHtmlRenderer>,
276}
277
278impl ExportOptions<'_> {
279    /// Build options from an optional output-path string.
280    #[must_use]
281    pub fn with_output_path(output_path: Option<&str>) -> Self {
282        Self {
283            output_path: output_path.map(PathBuf::from),
284            ..Self::default()
285        }
286    }
287}
288
289/// HTML export failures.
290#[derive(Debug, Error)]
291pub enum ExportError {
292    /// A current in-memory session has no file to export.
293    #[error("Cannot export in-memory session to HTML")]
294    InMemory,
295    /// Deferred persistence has not created the session file yet.
296    #[error("Nothing to export yet - start a conversation first")]
297    Empty,
298    /// An arbitrary input path does not exist.
299    #[error("File not found: {0}")]
300    FileNotFound(String),
301    /// Session loading failed.
302    #[error(transparent)]
303    Session(#[from] SessionError),
304    /// JSON encoding failed.
305    #[error(transparent)]
306    Json(#[from] serde_json::Error),
307    /// Output could not be written.
308    #[error("Failed to write HTML export {path}: {source}")]
309    Write {
310        /// Target path.
311        path: String,
312        /// Underlying filesystem error.
313        source: std::io::Error,
314    },
315}
316
317#[derive(Serialize)]
318#[serde(rename_all = "camelCase")]
319struct SessionData<'a> {
320    #[serde(skip_serializing_if = "Option::is_none")]
321    header: Option<&'a SessionHeader>,
322    entries: Vec<&'a SessionEntry>,
323    leaf_id: Option<&'a str>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    system_prompt: Option<&'a str>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    tools: Option<&'a [ToolInfo]>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    rendered_tools: Option<BTreeMap<String, RenderedToolHtml>>,
330}
331
332fn is_template_rendered_tool(name: &str) -> bool {
333    TEMPLATE_RENDERED_TOOLS.contains(&name)
334}
335
336fn pre_render_custom_tools(
337    entries: &[&SessionEntry],
338    renderer: &dyn ToolHtmlRenderer,
339) -> BTreeMap<String, RenderedToolHtml> {
340    let mut rendered_custom_tools: BTreeMap<String, RenderedToolHtml> = BTreeMap::new();
341    for entry in entries {
342        let SessionEntry::Message(message_entry) = entry else {
343            continue;
344        };
345        let Some(message) = message_entry.message.as_llm() else {
346            continue;
347        };
348        match message {
349            Message::Assistant(message) => {
350                for block in &message.content {
351                    let AssistantContent::ToolCall(call) = block else {
352                        continue;
353                    };
354                    if is_template_rendered_tool(&call.name) {
355                        continue;
356                    }
357                    let arguments = Value::Object((*call.arguments).clone());
358                    if let Some(call_html) = renderer.render_call(&call.id, &call.name, &arguments)
359                    {
360                        rendered_custom_tools
361                            .entry(call.id.clone())
362                            .or_default()
363                            .call_html = Some(call_html);
364                    }
365                }
366            }
367            Message::ToolResult(result) => {
368                let existing = rendered_custom_tools.contains_key(&result.tool_call_id);
369                if !existing && is_template_rendered_tool(&result.tool_name) {
370                    continue;
371                }
372                if let Some(fragment) = renderer.render_result(
373                    &result.tool_call_id,
374                    &result.tool_name,
375                    &result.content,
376                    result.details.as_ref(),
377                    result.is_error,
378                ) {
379                    let item = rendered_custom_tools
380                        .entry(result.tool_call_id.clone())
381                        .or_default();
382                    item.result_html_collapsed = fragment.collapsed;
383                    item.result_html_expanded = fragment.expanded;
384                }
385            }
386            Message::User(_) => {}
387        }
388    }
389    rendered_custom_tools
390}
391
392fn parse_rgb(color: &str) -> Option<(u8, u8, u8)> {
393    if let Some(hex) = color.strip_prefix('#').filter(|value| value.len() == 6) {
394        return Some((
395            u8::from_str_radix(&hex[0..2], 16).ok()?,
396            u8::from_str_radix(&hex[2..4], 16).ok()?,
397            u8::from_str_radix(&hex[4..6], 16).ok()?,
398        ));
399    }
400    let raw = color.strip_prefix("rgb")?.trim();
401    let raw = raw.strip_prefix('(')?.strip_suffix(')')?;
402    let mut parts = raw.split(',').map(str::trim);
403    let red = parts.next()?.parse().ok()?;
404    let green = parts.next()?.parse().ok()?;
405    let blue = parts.next()?.parse().ok()?;
406    if parts.next().is_some() {
407        return None;
408    }
409    Some((red, green, blue))
410}
411
412fn relative_luminance(red: u8, green: u8, blue: u8) -> f64 {
413    let linear = |component: u8| {
414        let value = f64::from(component) / 255.0;
415        if value <= 0.039_28 {
416            value / 12.92
417        } else {
418            ((value + 0.055) / 1.055).powf(2.4)
419        }
420    };
421    0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
422}
423
424fn bounded_rounded_u8(value: f64) -> u8 {
425    let value = value.round().clamp(0.0, 255.0);
426    if value.is_nan() {
427        return 0;
428    }
429
430    let mut lower = 0_u8;
431    let mut upper = u8::MAX;
432    while lower < upper {
433        let midpoint = lower + (upper - lower) / 2;
434        if f64::from(midpoint) < value {
435            lower = midpoint + 1;
436        } else {
437            upper = midpoint;
438        }
439    }
440    lower
441}
442
443fn adjust_brightness(color: &str, factor: f64) -> String {
444    let Some((red, green, blue)) = parse_rgb(color) else {
445        return color.to_owned();
446    };
447    let adjust = |value: u8| bounded_rounded_u8(f64::from(value) * factor);
448    format!("rgb({}, {}, {})", adjust(red), adjust(green), adjust(blue))
449}
450
451fn derived_export_colors(base: &str) -> (String, String, String) {
452    let Some((red, green, blue)) = parse_rgb(base) else {
453        return (
454            "rgb(24, 24, 30)".to_owned(),
455            "rgb(30, 30, 36)".to_owned(),
456            "rgb(60, 55, 40)".to_owned(),
457        );
458    };
459    if relative_luminance(red, green, blue) > 0.5 {
460        (
461            adjust_brightness(base, 0.96),
462            base.to_owned(),
463            format!(
464                "rgb({}, {}, {})",
465                red.saturating_add(10),
466                green.saturating_add(5),
467                blue.saturating_sub(20)
468            ),
469        )
470    } else {
471        (
472            adjust_brightness(base, 0.7),
473            adjust_brightness(base, 0.85),
474            format!(
475                "rgb({}, {}, {})",
476                red.saturating_add(20),
477                green.saturating_add(15),
478                blue
479            ),
480        )
481    }
482}
483
484fn generate_html(data: &SessionData<'_>, theme: &ExportTheme) -> Result<String, ExportError> {
485    let user_background = theme
486        .colors
487        .iter()
488        .find(|(name, _)| name == "userMessageBg")
489        .map_or("#343541", |(_, value)| value.as_str());
490    let derived = derived_export_colors(user_background);
491    let page = theme.page_background.as_ref().unwrap_or(&derived.0);
492    let card = theme.card_background.as_ref().unwrap_or(&derived.1);
493    let info = theme.info_background.as_ref().unwrap_or(&derived.2);
494
495    let mut theme_variables = String::new();
496    for (index, (name, value)) in theme.colors.iter().enumerate() {
497        if index > 0 {
498            theme_variables.push_str("\n      ");
499        }
500        theme_variables.push_str("--");
501        theme_variables.push_str(name);
502        theme_variables.push_str(": ");
503        theme_variables.push_str(value);
504        theme_variables.push(';');
505    }
506    for (name, value) in [
507        ("exportPageBg", page),
508        ("exportCardBg", card),
509        ("exportInfoBg", info),
510    ] {
511        if !theme_variables.is_empty() {
512            theme_variables.push_str("\n      ");
513        }
514        theme_variables.push_str("--");
515        theme_variables.push_str(name);
516        theme_variables.push_str(": ");
517        theme_variables.push_str(value);
518        theme_variables.push(';');
519    }
520
521    let css = TEMPLATE_CSS
522        .replacen("{{THEME_VARS}}", &theme_variables, 1)
523        .replacen("{{BODY_BG}}", page, 1)
524        .replacen("{{CONTAINER_BG}}", card, 1)
525        .replacen("{{INFO_BG}}", info, 1);
526    let encoded = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(data)?);
527    Ok(TEMPLATE_HTML
528        .replacen("{{CSS}}", &css, 1)
529        .replacen("{{JS}}", TEMPLATE_JS, 1)
530        .replacen("{{SESSION_DATA}}", &encoded, 1)
531        .replacen("{{MARKED_JS}}", MARKED_JS, 1)
532        .replacen("{{HIGHLIGHT_JS}}", HIGHLIGHT_JS, 1))
533}
534
535fn default_output_path(session_file: &str) -> PathBuf {
536    let name = Path::new(session_file)
537        .file_name()
538        .and_then(|value| value.to_str())
539        .unwrap_or(session_file);
540    let stem = name.strip_suffix(".jsonl").unwrap_or(name);
541    PathBuf::from(format!("{APP_NAME}-session-{stem}.html"))
542}
543
544fn write_export(path: &Path, html: &str) -> Result<String, ExportError> {
545    fs::write(path, html).map_err(|source| ExportError::Write {
546        path: path.to_string_lossy().into_owned(),
547        source,
548    })?;
549    Ok(path.to_string_lossy().into_owned())
550}
551
552/// Export a current session, including live system-prompt and tool metadata.
553///
554/// # Errors
555///
556/// Returns exact compatibility errors for in-memory/deferred sessions, or an
557/// encoding, session, or output error.
558pub fn export_session_to_html(
559    session: &SessionManager,
560    state: Option<&SessionExportState>,
561    options: ExportOptions<'_>,
562) -> Result<String, ExportError> {
563    let session_file = session.get_session_file().ok_or(ExportError::InMemory)?;
564    if !Path::new(session_file).exists() {
565        return Err(ExportError::Empty);
566    }
567    let entries = session.get_entries();
568    let rendered_tools = options
569        .tool_renderer
570        .map(|renderer| pre_render_custom_tools(&entries, renderer))
571        .filter(|rendered| !rendered.is_empty());
572    let data = SessionData {
573        header: session.get_header(),
574        entries,
575        leaf_id: session.get_leaf_id(),
576        system_prompt: state.map(|value| value.system_prompt.as_str()),
577        tools: state.map(|value| value.tools.as_slice()),
578        rendered_tools,
579    };
580    let theme = options
581        .theme
582        .unwrap_or_else(|| ExportTheme::built_in(options.theme_name.as_deref()));
583    let html = generate_html(&data, &theme)?;
584    let output = options
585        .output_path
586        .unwrap_or_else(|| default_output_path(session_file));
587    let normalized = normalize_path(
588        &output.to_string_lossy(),
589        PathInputOptions::new().trim(false),
590    );
591    write_export(&normalized, &html)
592}
593
594/// Export an arbitrary session file without live agent state.
595///
596/// # Errors
597///
598/// Returns `File not found: <resolved path>` for a missing input, or a session,
599/// encoding, or output error.
600pub fn export_from_file(
601    input_path: &str,
602    options: ExportOptions<'_>,
603) -> Result<String, ExportError> {
604    let input = resolve_path(input_path);
605    if !input.exists() {
606        return Err(ExportError::FileNotFound(
607            input.to_string_lossy().into_owned(),
608        ));
609    }
610    let session = SessionManager::open(&input.to_string_lossy(), None, None)?;
611    let entries = session.get_entries();
612    let rendered_tools = options
613        .tool_renderer
614        .map(|renderer| pre_render_custom_tools(&entries, renderer))
615        .filter(|rendered| !rendered.is_empty());
616    let data = SessionData {
617        header: session.get_header(),
618        entries,
619        leaf_id: session.get_leaf_id(),
620        system_prompt: None,
621        tools: None,
622        rendered_tools,
623    };
624    let theme = options
625        .theme
626        .unwrap_or_else(|| ExportTheme::built_in(options.theme_name.as_deref()));
627    let html = generate_html(&data, &theme)?;
628    let output = options
629        .output_path
630        .unwrap_or_else(|| default_output_path(input.to_string_lossy().as_ref()));
631    let normalized = normalize_path(
632        &output.to_string_lossy(),
633        PathInputOptions::new().trim(false),
634    );
635    write_export(&normalized, &html)
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use base64::engine::general_purpose::STANDARD;
642    use serde_json::json;
643    use tempfile::tempdir;
644
645    fn fixture(root: &Path) -> Result<PathBuf, std::io::Error> {
646        let path = root.join("fixture.jsonl");
647        fs::write(
648            &path,
649            concat!(
650                "{\"type\":\"session\",\"version\":3,\"id\":\"session-id\",\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"cwd\":\"/tmp\"}\n",
651                "{\"type\":\"message\",\"id\":\"a1\",\"parentId\":null,\"timestamp\":\"2026-01-01T00:00:01.000Z\",\"message\":{\"role\":\"user\",\"content\":\"<hello>&\",\"timestamp\":1}}\n"
652            ),
653        )?;
654        Ok(path)
655    }
656
657    fn embedded_data(html: &str) -> Result<Value, Box<dyn std::error::Error>> {
658        let marker = "<script id=\"session-data\" type=\"application/json\">";
659        let start = html.find(marker).ok_or("session data marker missing")? + marker.len();
660        let end = html[start..]
661            .find("</script>")
662            .ok_or("session data terminator missing")?
663            + start;
664        let decoded = STANDARD.decode(&html[start..end])?;
665        Ok(serde_json::from_slice(&decoded)?)
666    }
667
668    #[test]
669    fn arbitrary_file_export_embeds_full_session_data_and_assets()
670    -> Result<(), Box<dyn std::error::Error>> {
671        let root = tempdir()?;
672        let input = fixture(root.path())?;
673        let output = root.path().join("out.html");
674        export_from_file(
675            &input.to_string_lossy(),
676            ExportOptions {
677                output_path: Some(output.clone()),
678                theme_name: Some("light".to_owned()),
679                ..ExportOptions::default()
680            },
681        )?;
682        let html = fs::read_to_string(output)?;
683        assert!(html.contains("marked v18.0.5"));
684        assert!(html.contains("Highlight.js v11.9.0"));
685        assert!(html.contains("--exportPageBg: #f8f8f8;"));
686        assert!(!html.contains("{{SESSION_DATA}}"));
687        let data = embedded_data(&html)?;
688        assert_eq!(data["header"]["version"], 3);
689        assert_eq!(data["entries"].as_array().map(Vec::len), Some(1));
690        assert_eq!(data["leafId"], "a1");
691        assert!(data.get("systemPrompt").is_none());
692        assert!(data.get("tools").is_none());
693        assert_eq!(data["entries"][0]["message"]["content"], "<hello>&");
694        Ok(())
695    }
696
697    #[test]
698    fn missing_file_in_memory_and_deferred_session_errors_are_exact()
699    -> Result<(), Box<dyn std::error::Error>> {
700        let root = tempdir()?;
701        let missing = root.path().join("missing.jsonl");
702        let error = export_from_file(&missing.to_string_lossy(), ExportOptions::default())
703            .err()
704            .ok_or("missing export error")?;
705        assert_eq!(
706            error.to_string(),
707            format!("File not found: {}", missing.display())
708        );
709
710        let memory = SessionManager::in_memory(Some(&root.path().to_string_lossy()), None)?;
711        let error = export_session_to_html(&memory, None, ExportOptions::default())
712            .err()
713            .ok_or("missing in-memory error")?;
714        assert_eq!(error.to_string(), "Cannot export in-memory session to HTML");
715
716        let persisted = SessionManager::create(
717            &root.path().to_string_lossy(),
718            Some(&root.path().join("sessions").to_string_lossy()),
719            None,
720        )?;
721        let error = export_session_to_html(&persisted, None, ExportOptions::default())
722            .err()
723            .ok_or("missing deferred-session error")?;
724        assert_eq!(
725            error.to_string(),
726            "Nothing to export yet - start a conversation first"
727        );
728        Ok(())
729    }
730
731    struct Renderer;
732
733    impl ToolHtmlRenderer for Renderer {
734        fn render_call(
735            &self,
736            tool_call_id: &str,
737            tool_name: &str,
738            _arguments: &Value,
739        ) -> Option<String> {
740            Some(format!("&lt;{tool_name}:{tool_call_id}&gt;"))
741        }
742
743        fn render_result(
744            &self,
745            _tool_call_id: &str,
746            _tool_name: &str,
747            _result: &[ToolResultContent],
748            _details: Option<&Value>,
749            _is_error: bool,
750        ) -> Option<RenderedResult> {
751            Some(RenderedResult {
752                collapsed: Some("collapsed".to_owned()),
753                expanded: Some("expanded".to_owned()),
754            })
755        }
756    }
757
758    #[test]
759    fn custom_tools_are_pre_rendered_but_builtins_stay_client_side()
760    -> Result<(), Box<dyn std::error::Error>> {
761        let root = tempdir()?;
762        let input = root.path().join("tools.jsonl");
763        fs::write(
764            &input,
765            concat!(
766                "{\"type\":\"session\",\"version\":3,\"id\":\"s\",\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"cwd\":\"/tmp\"}\n",
767                "{\"type\":\"message\",\"id\":\"a\",\"parentId\":null,\"timestamp\":\"2026-01-01T00:00:01.000Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"custom-id\",\"name\":\"custom\",\"arguments\":{}},{\"type\":\"toolCall\",\"id\":\"read-id\",\"name\":\"read\",\"arguments\":{}}],\"api\":\"x\",\"provider\":\"x\",\"model\":\"x\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"toolUse\",\"timestamp\":1}}\n"
768            ),
769        )?;
770        let output = root.path().join("tools.html");
771        export_from_file(
772            &input.to_string_lossy(),
773            ExportOptions {
774                output_path: Some(output.clone()),
775                tool_renderer: Some(&Renderer),
776                ..ExportOptions::default()
777            },
778        )?;
779        let data = embedded_data(&fs::read_to_string(output)?)?;
780        assert_eq!(
781            data["renderedTools"]["custom-id"]["callHtml"],
782            json!("&lt;custom:custom-id&gt;")
783        );
784        assert!(data["renderedTools"].get("read-id").is_none());
785        Ok(())
786    }
787}