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