1pub 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#[derive(Clone, Debug, PartialEq, Serialize)]
31pub struct ToolInfo {
32 pub name: String,
34 pub description: String,
36 pub parameters: Value,
38}
39
40#[derive(Clone, Debug, Default, PartialEq)]
42pub struct SessionExportState {
43 pub system_prompt: String,
45 pub tools: Vec<ToolInfo>,
47}
48
49impl SessionExportState {
50 #[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 #[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct RenderedResult {
88 pub collapsed: Option<String>,
90 pub expanded: Option<String>,
92}
93
94pub trait ToolHtmlRenderer: Send + Sync {
96 fn render_call(&self, tool_call_id: &str, tool_name: &str, arguments: &Value)
98 -> Option<String>;
99
100 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#[derive(Clone, Debug, Default, PartialEq, Serialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RenderedToolHtml {
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub call_html: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub result_html_collapsed: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub result_html_expanded: Option<String>,
124}
125
126#[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 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#[derive(Default)]
267pub struct ExportOptions<'a> {
268 pub output_path: Option<PathBuf>,
270 pub theme_name: Option<String>,
272 pub theme: Option<ExportTheme>,
274 pub tool_renderer: Option<&'a dyn ToolHtmlRenderer>,
276}
277
278impl ExportOptions<'_> {
279 #[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#[derive(Debug, Error)]
291pub enum ExportError {
292 #[error("Cannot export in-memory session to HTML")]
294 InMemory,
295 #[error("Nothing to export yet - start a conversation first")]
297 Empty,
298 #[error("File not found: {0}")]
300 FileNotFound(String),
301 #[error(transparent)]
303 Session(#[from] SessionError),
304 #[error(transparent)]
306 Json(#[from] serde_json::Error),
307 #[error("Failed to write HTML export {path}: {source}")]
309 Write {
310 path: String,
312 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
552pub 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
594pub 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!("<{tool_name}:{tool_call_id}>"))
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!("<custom:custom-id>")
783 );
784 assert!(data["renderedTools"].get("read-id").is_none());
785 Ok(())
786 }
787}