1use std::path::Path;
6
7use rpi_harness::agent_harness::AgentHarness;
8use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum ExportFormat {
13 Markdown,
14 Html,
15 Jsonl,
16}
17
18pub fn export_file(input: &Path, output: &Path) -> Result<(), String> {
25 let format = if output
27 .extension()
28 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
29 {
30 ExportFormat::Html
31 } else if output
32 .extension()
33 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
34 {
35 ExportFormat::Jsonl
36 } else {
37 ExportFormat::Markdown
38 };
39
40 let entries = read_jsonl_session_entries(input)?;
41
42 let unsaved: &[rpi_harness::agent_harness::UnsavedMessage] = &[];
46
47 match format {
48 ExportFormat::Markdown => export_markdown(&entries, unsaved, output),
49 ExportFormat::Html => export_html(&entries, unsaved, output),
50 ExportFormat::Jsonl => export_jsonl(&entries, unsaved, output),
51 }
52}
53
54fn read_jsonl_session_entries(input: &Path) -> Result<Vec<Entry>, String> {
56 use rpi_harness::session::jsonl::parse_mutation;
57 use rpi_harness::session::types::SessionMutation;
58
59 let content = std::fs::read_to_string(input)
60 .map_err(|e| format!("Could not read input file {}: {e}", input.display()))?;
61
62 let mut entries = Vec::new();
63 let mut saw_header = false;
64 for (index, line) in content.lines().enumerate() {
65 let line = line.trim();
66 if line.is_empty() {
67 continue;
68 }
69 if !saw_header {
70 rpi_harness::session::jsonl::codec::parse_header(line).map_err(|e| {
73 format!(
74 "{} is not a JSONL v4 session file (line {}): {e}",
75 input.display(),
76 index + 1
77 )
78 })?;
79 saw_header = true;
80 continue;
81 }
82 let mutation = parse_mutation(line).map_err(|e| {
83 format!(
84 "could not parse mutation at {}:{}: {e}",
85 input.display(),
86 index + 1
87 )
88 })?;
89 if let SessionMutation::Entry { entry, .. } = mutation {
90 entries.push(entry);
91 }
92 }
93 if !saw_header {
94 return Err(format!("{} is empty", input.display()));
95 }
96 Ok(entries)
97}
98
99pub async fn export_session(
101 harness: &AgentHarness,
102 format: ExportFormat,
103 output_path: &Path,
104) -> Result<(), String> {
105 let tree = harness.session().view("main");
106 let entries = tree
107 .find_entries(&EntryQuery {
108 order: Some(EntryOrder::OldestFirst),
109 ..Default::default()
110 })
111 .await
112 .map_err(|e| format!("Could not read session: {e}"))?;
113
114 let unsaved = harness.unsaved_messages();
118
119 match format {
120 ExportFormat::Markdown => export_markdown(&entries, &unsaved, output_path),
121 ExportFormat::Html => export_html(&entries, &unsaved, output_path),
122 ExportFormat::Jsonl => export_jsonl(&entries, &unsaved, output_path),
123 }
124}
125
126fn export_markdown(
127 entries: &[Entry],
128 unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
129 output_path: &Path,
130) -> Result<(), String> {
131 let mut md = String::from("# Session\n\n");
132 for e in entries {
133 if let Entry::Message(me) = e {
134 match &me.message {
135 rpi_agent::AgentMessage::User(u) => {
136 let text = user_message_text(u);
137 md.push_str(&format!("## User\n\n{}\n\n", text));
138 }
139 rpi_agent::AgentMessage::Assistant(a) => {
140 let text = assistant_text(a);
141 if !text.is_empty() {
142 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
143 }
144 }
145 _ => {}
146 }
147 }
148 }
149 if !unsaved.is_empty() {
153 md.push_str("# Not saved\n\n");
154 md.push_str(
155 "These messages are missing from the session log because saving them was \
156 refused. They are reproduced here so this export is the complete \
157 conversation.\n\n",
158 );
159 for failure in unsaved {
160 append_unsaved_markdown(&mut md, &failure.message);
161 }
162 }
163 std::fs::write(output_path, md).map_err(|e| format!("Could not write markdown export: {e}"))
164}
165
166fn append_unsaved_markdown(md: &mut String, message: &rpi_agent::AgentMessage) {
168 match message {
169 rpi_agent::AgentMessage::User(u) => {
170 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
171 }
172 rpi_agent::AgentMessage::Assistant(a) => {
173 let text = assistant_text(a);
174 if !text.is_empty() {
175 md.push_str(&format!("## Assistant\n\n{text}\n\n"));
176 }
177 }
178 other => {
179 md.push_str(&format!("## Message\n\n```text\n{other:?}\n```\n\n"));
180 }
181 }
182}
183
184fn export_html(
185 entries: &[Entry],
186 unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
187 output_path: &Path,
188) -> Result<(), String> {
189 let mut html = String::from(
190 r#"<!DOCTYPE html>
191<html lang="en">
192<head>
193 <meta charset="UTF-8">
194 <meta name="viewport" content="width=device-width, initial-scale=1.0">
195 <title>Session Export</title>
196 <style>
197 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; line-height: 1.6; }
198 .message { margin-bottom: 2rem; padding: 1rem; border-radius: 8px; }
199 .user { background-color: #f0f9ff; border-left: 4px solid #3b82f6; }
200 .assistant { background-color: #f3f4f6; border-left: 4px solid #6b7280; }
201 .role { font-weight: bold; margin-bottom: 0.5rem; color: #1f2937; }
202 pre { background-color: #1e293b; color: #f8fafc; padding: 1rem; border-radius: 6px; overflow-x: auto; }
203 code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; }
204 </style>
205</head>
206<body>
207 <h1>Session Export</h1>
208"#,
209 );
210
211 for e in entries {
212 if let Entry::Message(me) = e {
213 match &me.message {
214 rpi_agent::AgentMessage::User(u) => {
215 let text = user_message_text(u);
216 html.push_str(&format!(
217 r#"<div class="message user">
218 <div class="role">User</div>
219 <div>{}</div>
220</div>"#,
221 escape_html(&text)
222 ));
223 }
224 rpi_agent::AgentMessage::Assistant(a) => {
225 let text = assistant_text(a);
226 if !text.is_empty() {
227 html.push_str(&format!(
228 r#"<div class="message assistant">
229 <div class="role">Assistant</div>
230 <div>{}</div>
231</div>"#,
232 escape_html(&text)
233 ));
234 }
235 }
236 _ => {}
237 }
238 }
239 }
240
241 if !unsaved.is_empty() {
242 html.push_str(
243 r#"<h2>Not saved</h2>
244<p>These messages are missing from the session log because saving them was refused. They are reproduced here so this export is the complete conversation.</p>
245"#,
246 );
247 for failure in unsaved {
248 match &failure.message {
249 rpi_agent::AgentMessage::User(u) => {
250 html.push_str(&format!(
251 r#"<div class="message user">
252 <div class="role">User</div>
253 <div>{}</div>
254</div>"#,
255 escape_html(&user_message_text(u))
256 ));
257 }
258 rpi_agent::AgentMessage::Assistant(a) => {
259 let text = assistant_text(a);
260 if !text.is_empty() {
261 html.push_str(&format!(
262 r#"<div class="message assistant">
263 <div class="role">Assistant</div>
264 <div>{}</div>
265</div>"#,
266 escape_html(&text)
267 ));
268 }
269 }
270 other => {
271 html.push_str(&format!(
272 r#"<div class="message assistant">
273 <div class="role">Message</div>
274 <pre>{}</pre>
275</div>"#,
276 escape_html(&format!("{other:?}"))
277 ));
278 }
279 }
280 }
281 }
282
283 html.push_str("\n</body>\n</html>");
284
285 std::fs::write(output_path, html).map_err(|e| format!("Could not write HTML export: {e}"))
286}
287
288fn export_jsonl(
289 entries: &[Entry],
290 unsaved: &[rpi_harness::agent_harness::UnsavedMessage],
291 output_path: &Path,
292) -> Result<(), String> {
293 let mut jsonl = String::new();
294 for e in entries {
295 let json = serde_json::to_string(e)
296 .map_err(|e| format!("Could not serialize entry to JSON: {e}"))?;
297 jsonl.push_str(&json);
298 jsonl.push('\n');
299 }
300 for failure in unsaved {
304 let json = serde_json::to_string(&serde_json::json!({
305 "type": "unsaved_message",
306 "reason": failure.what,
307 "message": failure.message,
308 }))
309 .map_err(|e| format!("Could not serialize unsaved message to JSON: {e}"))?;
310 jsonl.push_str(&json);
311 jsonl.push('\n');
312 }
313 std::fs::write(output_path, jsonl).map_err(|e| format!("Could not write JSONL export: {e}"))
314}
315
316fn user_message_text(user: &rpi_ai::types::UserMessage) -> String {
317 match &user.content {
318 rpi_ai::types::UserContent::Text(s) => s.clone(),
319 rpi_ai::types::UserContent::Blocks(blocks) => blocks
320 .iter()
321 .filter_map(|c| match c {
322 rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
323 rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
324 _ => None,
325 })
326 .collect::<Vec<_>>()
327 .join("\n"),
328 }
329}
330
331fn assistant_text(assistant: &rpi_ai::types::AssistantMessage) -> String {
332 assistant
333 .content
334 .iter()
335 .filter_map(|c| match c {
336 rpi_ai::types::Content::Text(t) => Some(t.text.clone()),
337 rpi_ai::types::Content::Thinking(t) => {
338 Some(format!("<thinking>{}</thinking>", t.thinking))
339 }
340 rpi_ai::types::Content::ToolCall(tc) => Some(format!("[Tool Call: {}]", tc.name)),
341 rpi_ai::types::Content::Image(_) => Some("[Image]".to_string()),
342 })
343 .collect::<Vec<_>>()
344 .join("\n\n")
345}
346
347fn escape_html(text: &str) -> String {
348 text.replace('&', "&")
349 .replace('<', "<")
350 .replace('>', ">")
351 .replace('"', """)
352 .replace('\'', "'")
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use rpi_harness::session::jsonl::codec::{encode_header, encode_mutation};
359 use rpi_harness::session::jsonl::{HeaderKind, JsonlV4Header};
360 use rpi_harness::session::types::{Entry, EntryBase, MessageEntry, SessionMutation};
361
362 #[test]
363 fn test_escape_html() {
364 assert_eq!(
365 escape_html("<script>alert('xss')</script>"),
366 "<script>alert('xss')</script>"
367 );
368 assert_eq!(escape_html("Hello & World"), "Hello & World");
369 }
370
371 #[test]
374 fn export_file_renders_a_real_jsonl_session() {
375 let dir = tempfile::tempdir().unwrap();
376 let session_path = dir.path().join("session.jsonl");
377
378 let header = JsonlV4Header {
379 kind: HeaderKind,
380 version: 4,
381 id: "s-export".to_string(),
382 progress_format: None,
383 created_at: 0,
384 cwd: "/tmp".to_string(),
385 parent_session_id: None,
386 legacy_parent_session_path: None,
387 metadata: None,
388 };
389
390 let mut lines = vec![encode_header(&header)];
391 for (seq, message) in [
392 (
393 1u64,
394 rpi_agent::message::AgentMessage::User(rpi_ai::types::UserMessage::new(
395 rpi_ai::types::UserContent::Text("hello <world>".to_string()),
396 0,
397 )),
398 ),
399 (
400 2u64,
401 rpi_agent::message::AgentMessage::Assistant(Box::new({
402 let mut a = rpi_ai::types::AssistantMessage::empty(
403 rpi_ai::Api::AnthropicMessages,
404 "anthropic",
405 "m",
406 0,
407 );
408 a.content = vec![rpi_ai::types::Content::Text(rpi_ai::types::TextContent {
409 kind: rpi_ai::types::TextContentType,
410 text: "hi there".to_string(),
411 text_signature: None,
412 })];
413 a
414 })),
415 ),
416 ] {
417 let entry = Entry::Message(MessageEntry {
418 base: EntryBase {
419 entry_type: "message".to_string(),
420 id: format!("e{seq}"),
421 seq,
422 parent_id: None,
423 timestamp: 0,
424 },
425 message,
426 terminate: None,
427 });
428 lines.push(encode_mutation(&SessionMutation::Entry {
429 seq,
430 timestamp: 0,
431 lane: Some("main".to_string()),
432 entry,
433 }));
434 }
435 std::fs::write(&session_path, lines.concat()).unwrap();
436
437 let md_path = dir.path().join("out.md");
439 export_file(&session_path, &md_path).unwrap();
440 let md = std::fs::read_to_string(&md_path).unwrap();
441 assert!(md.contains("hello <world>"), "{md}");
442 assert!(md.contains("hi there"), "{md}");
443
444 let html_path = dir.path().join("out.html");
446 export_file(&session_path, &html_path).unwrap();
447 let html = std::fs::read_to_string(&html_path).unwrap();
448 assert!(html.contains("<world>"), "{html}");
449 assert!(html.contains("hi there"), "{html}");
450 assert!(html.trim_end().ends_with("</html>"), "{html}");
451
452 let jsonl_path = dir.path().join("out.jsonl");
454 export_file(&session_path, &jsonl_path).unwrap();
455 let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
456 let parsed: Vec<Entry> = jsonl
457 .lines()
458 .filter(|l| !l.trim().is_empty())
459 .map(|l| serde_json::from_str(l).unwrap())
460 .collect();
461 assert_eq!(parsed.len(), 2);
462 }
463
464 #[test]
470 fn a_refused_write_reaches_the_export_through_the_recovery_copy() {
471 let dir = tempfile::tempdir().unwrap();
472 let message = rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage {
473 role: rpi_ai::types::UserRole,
474 content: rpi_ai::types::UserContent::Text("only on screen".into()),
475 timestamp: 0,
476 });
477 let unsaved = vec![rpi_harness::agent_harness::UnsavedMessage {
478 message,
479 what: "a message you sent".to_string(),
480 at: 0,
481 session_id: "sess".to_string(),
482 lane: "main".to_string(),
483 }];
484
485 let md_path = dir.path().join("out.md");
486 export_markdown(&[], &unsaved, &md_path).unwrap();
487 let md = std::fs::read_to_string(&md_path).unwrap();
488 assert!(md.contains("only on screen"), "{md}");
489 assert!(
490 md.contains("Not saved"),
491 "the export must say the message is not in the session log: {md}"
492 );
493
494 let html_path = dir.path().join("out.html");
495 export_html(&[], &unsaved, &html_path).unwrap();
496 let html = std::fs::read_to_string(&html_path).unwrap();
497 assert!(html.contains("only on screen"), "{html}");
498 assert!(html.trim_end().ends_with("</html>"), "{html}");
499
500 let jsonl_path = dir.path().join("out.jsonl");
501 export_jsonl(&[], &unsaved, &jsonl_path).unwrap();
502 let jsonl = std::fs::read_to_string(&jsonl_path).unwrap();
503 let line: serde_json::Value = serde_json::from_str(jsonl.lines().next().unwrap()).unwrap();
504 assert_eq!(line["type"], "unsaved_message", "{jsonl}");
505 assert_eq!(
506 line["message"]["content"], "only on screen",
507 "the machine-readable export must carry the message body: {jsonl}"
508 );
509 }
510
511 #[test]
512 fn export_file_rejects_a_non_session_file() {
513 let dir = tempfile::tempdir().unwrap();
514 let bogus = dir.path().join("not-a-session.jsonl");
515 std::fs::write(&bogus, "{\"type\":\"message\"}\n").unwrap();
516 let out = dir.path().join("out.md");
517 let err = export_file(&bogus, &out).unwrap_err();
518 assert!(err.contains("not a JSONL v4 session file"), "{err}");
519 }
520
521 #[tokio::test]
528 async fn review_session_switch_does_not_export_previous_session_recovery() {
529 let harness = AgentHarness::create(Default::default()).await.unwrap();
530 harness.keep_unsaved_message_for_test(
531 "old session message",
532 rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new("SESSION_A_SECRET", 1)),
533 );
534 harness
535 .set_session(crate::session::ephemeral_session())
536 .await
537 .unwrap();
538 let dir = tempfile::tempdir().unwrap();
539 let output = dir.path().join("session-b.md");
540 export_session(&harness, ExportFormat::Markdown, &output)
541 .await
542 .unwrap();
543 let text = std::fs::read_to_string(output).unwrap();
544 assert!(
545 !text.contains("SESSION_A_SECRET"),
546 "session B export leaked session A's recovery data: {text}"
547 );
548 }
549}