1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use serde_json::Value;
5
6use crate::error::{Result, XurlError};
7use crate::jsonl;
8use crate::model::{MessageRole, ProviderKind, ThreadMessage};
9use crate::uri::AgentsUri;
10
11const TOOL_TYPES: &[&str] = &[
12 "tool_call",
13 "tool_result",
14 "tool_use",
15 "function_call",
16 "function_result",
17 "function_response",
18];
19const COMPACT_PLACEHOLDER: &str = "Context was compacted.";
20
21enum TimelineEntry {
22 Message(ThreadMessage),
23 Compact { summary: Option<String> },
24}
25
26pub fn render_markdown(uri: &AgentsUri, source_path: &Path, raw_jsonl: &str) -> Result<String> {
27 let entries = extract_timeline_entries(
28 uri.provider,
29 source_path,
30 raw_jsonl,
31 &uri.session_id,
32 uri.agent_id.as_deref(),
33 )?;
34
35 let mut output = String::new();
36 let thread_uri = uri.as_agents_string();
37 let source = source_path.to_string_lossy();
38 output.push_str("---\n");
39 output.push_str(&format!("uri: '{}'\n", yaml_single_quoted(&thread_uri)));
40 output.push_str(&format!(
41 "thread_source: '{}'\n",
42 yaml_single_quoted(source.as_ref())
43 ));
44 output.push_str("---\n\n");
45 output.push_str("# Thread\n\n");
46 output.push_str("## Timeline\n\n");
47
48 if entries.is_empty() {
49 output.push_str("_No user/assistant messages or compact events found._\n");
50 return Ok(output);
51 }
52
53 for (idx, entry) in entries.iter().enumerate() {
54 let title = match entry {
55 TimelineEntry::Message(message) => match message.role {
56 MessageRole::User => "User",
57 MessageRole::Assistant => "Assistant",
58 },
59 TimelineEntry::Compact { .. } => "Context Compacted",
60 };
61
62 output.push_str(&format!("## {}. {}\n\n", idx + 1, title));
63 match entry {
64 TimelineEntry::Message(message) => output.push_str(message.text.trim()),
65 TimelineEntry::Compact { summary } => {
66 let summary = summary.as_deref().unwrap_or(COMPACT_PLACEHOLDER);
67 output.push_str(summary.trim());
68 }
69 }
70 output.push_str("\n\n");
71 }
72
73 Ok(output)
74}
75
76fn yaml_single_quoted(value: &str) -> String {
77 value.replace('\'', "''")
78}
79
80pub fn extract_messages(
81 provider: ProviderKind,
82 path: &Path,
83 raw_jsonl: &str,
84) -> Result<Vec<ThreadMessage>> {
85 Ok(
86 extract_timeline_entries(provider, path, raw_jsonl, "", None)?
87 .into_iter()
88 .filter_map(|entry| match entry {
89 TimelineEntry::Message(message) => Some(message),
90 TimelineEntry::Compact { .. } => None,
91 })
92 .collect(),
93 )
94}
95
96fn extract_timeline_entries(
97 provider: ProviderKind,
98 path: &Path,
99 raw_jsonl: &str,
100 session_id: &str,
101 target_entry_id: Option<&str>,
102) -> Result<Vec<TimelineEntry>> {
103 if provider == ProviderKind::Amp {
104 return Ok(messages_to_entries(extract_amp_messages(path, raw_jsonl)?));
105 }
106 if provider == ProviderKind::Gemini {
107 return Ok(messages_to_entries(extract_gemini_messages(
108 path, raw_jsonl,
109 )?));
110 }
111 if provider == ProviderKind::Pi {
112 return extract_pi_entries(path, raw_jsonl, session_id, target_entry_id);
113 }
114
115 let mut entries = Vec::new();
116
117 for (line_idx, line) in raw_jsonl.lines().enumerate() {
118 let line_no = line_idx + 1;
119 let trimmed = line.trim();
120 if trimmed.is_empty() {
121 continue;
122 }
123
124 let Some(value) = jsonl::parse_json_line(path, line_no, trimmed)? else {
125 continue;
126 };
127
128 let extracted = match provider {
129 ProviderKind::Amp => None,
130 ProviderKind::Codex => extract_codex_entry(&value),
131 ProviderKind::Claude => extract_claude_entry(&value),
132 ProviderKind::Gemini => None,
133 ProviderKind::Pi => None,
134 ProviderKind::Opencode => extract_opencode_message(&value).map(TimelineEntry::Message),
135 };
136
137 if let Some(entry) = extracted {
138 entries.push(entry);
139 }
140 }
141
142 Ok(entries)
143}
144
145fn messages_to_entries(messages: Vec<ThreadMessage>) -> Vec<TimelineEntry> {
146 messages.into_iter().map(TimelineEntry::Message).collect()
147}
148
149fn extract_pi_entries(
150 path: &Path,
151 raw_jsonl: &str,
152 session_id: &str,
153 target_entry_id: Option<&str>,
154) -> Result<Vec<TimelineEntry>> {
155 let mut entries_by_id = HashMap::<String, Value>::new();
156 let mut last_entry_id = None::<String>;
157
158 for (line_idx, line) in raw_jsonl.lines().enumerate() {
159 let line_no = line_idx + 1;
160 let trimmed = line.trim();
161 if trimmed.is_empty() {
162 continue;
163 }
164
165 let Some(value) = jsonl::parse_json_line(path, line_no, trimmed)? else {
166 continue;
167 };
168
169 if value.get("type").and_then(Value::as_str) == Some("session") {
170 continue;
171 }
172
173 let Some(id) = value
174 .get("id")
175 .and_then(Value::as_str)
176 .map(str::to_ascii_lowercase)
177 else {
178 continue;
179 };
180
181 last_entry_id = Some(id.clone());
182 entries_by_id.insert(id, value);
183 }
184
185 if entries_by_id.is_empty() {
186 return Ok(Vec::new());
187 }
188
189 let leaf_id = target_entry_id
190 .map(str::to_ascii_lowercase)
191 .or(last_entry_id)
192 .unwrap_or_default();
193
194 if !entries_by_id.contains_key(&leaf_id) {
195 return Err(XurlError::EntryNotFound {
196 provider: ProviderKind::Pi.to_string(),
197 session_id: session_id.to_string(),
198 entry_id: leaf_id,
199 });
200 }
201
202 let mut path_ids = Vec::new();
203 let mut seen = HashSet::new();
204 let mut current = Some(leaf_id);
205
206 while let Some(entry_id) = current {
207 if !seen.insert(entry_id.clone()) {
208 break;
209 }
210
211 let Some(entry) = entries_by_id.get(&entry_id) else {
212 break;
213 };
214 path_ids.push(entry_id);
215
216 current = entry
217 .get("parentId")
218 .and_then(Value::as_str)
219 .map(str::to_ascii_lowercase);
220 }
221
222 path_ids.reverse();
223
224 let mut entries = Vec::new();
225 for entry_id in path_ids {
226 let Some(entry) = entries_by_id.get(&entry_id) else {
227 continue;
228 };
229 if let Some(timeline_entry) = extract_pi_entry(entry) {
230 entries.push(timeline_entry);
231 }
232 }
233
234 Ok(entries)
235}
236
237fn extract_pi_entry(value: &Value) -> Option<TimelineEntry> {
238 let entry_type = value.get("type").and_then(Value::as_str)?;
239
240 if entry_type == "message" {
241 let message = value.get("message")?;
242 let role = message
243 .get("role")
244 .and_then(Value::as_str)
245 .and_then(parse_role)?;
246 let text = extract_text(message.get("content"));
247 if text.trim().is_empty() {
248 return None;
249 }
250
251 return Some(TimelineEntry::Message(ThreadMessage { role, text }));
252 }
253
254 if entry_type == "compaction" || entry_type == "branch_summary" {
255 let summary = value
256 .get("summary")
257 .and_then(Value::as_str)
258 .map(ToString::to_string);
259 return Some(TimelineEntry::Compact { summary });
260 }
261
262 None
263}
264
265fn extract_amp_messages(path: &Path, raw_json: &str) -> Result<Vec<ThreadMessage>> {
266 let value =
267 serde_json::from_str::<Value>(raw_json).map_err(|source| XurlError::InvalidJsonLine {
268 path: path.to_path_buf(),
269 line: 1,
270 source,
271 })?;
272
273 let mut messages = Vec::new();
274 for message in value
275 .get("messages")
276 .and_then(Value::as_array)
277 .into_iter()
278 .flatten()
279 {
280 let Some(role) = message
281 .get("role")
282 .and_then(Value::as_str)
283 .and_then(parse_role)
284 else {
285 continue;
286 };
287
288 let text = extract_amp_text(message.get("content"));
289 if text.trim().is_empty() {
290 continue;
291 }
292
293 messages.push(ThreadMessage { role, text });
294 }
295
296 Ok(messages)
297}
298
299fn extract_gemini_messages(path: &Path, raw_json: &str) -> Result<Vec<ThreadMessage>> {
300 let value =
301 serde_json::from_str::<Value>(raw_json).map_err(|source| XurlError::InvalidJsonLine {
302 path: path.to_path_buf(),
303 line: 1,
304 source,
305 })?;
306
307 let mut messages = Vec::new();
308 for message in value
309 .get("messages")
310 .and_then(Value::as_array)
311 .into_iter()
312 .flatten()
313 {
314 let Some(role) = message
315 .get("type")
316 .and_then(Value::as_str)
317 .and_then(parse_gemini_role)
318 else {
319 continue;
320 };
321
322 let text = extract_text(message.get("displayContent"));
323 let text = if text.trim().is_empty() {
324 extract_text(message.get("content"))
325 } else {
326 text
327 };
328
329 if text.trim().is_empty() {
330 continue;
331 }
332
333 messages.push(ThreadMessage { role, text });
334 }
335
336 Ok(messages)
337}
338
339fn extract_codex_message(value: &Value) -> Option<ThreadMessage> {
340 let record_type = value.get("type").and_then(Value::as_str)?;
341
342 if record_type == "response_item" {
343 let payload = value.get("payload")?;
344 let payload_type = payload.get("type").and_then(Value::as_str)?;
345 if payload_type != "message" {
346 return None;
347 }
348
349 let role = payload.get("role").and_then(Value::as_str)?;
350 let role = parse_role(role)?;
351 let text = extract_text(payload.get("content"));
352 if text.trim().is_empty() {
353 return None;
354 }
355
356 return Some(ThreadMessage { role, text });
357 }
358
359 if record_type == "event_msg"
360 && value
361 .get("payload")
362 .and_then(|payload| payload.get("type"))
363 .and_then(Value::as_str)
364 .is_some_and(|t| t == "agent_message")
365 {
366 let text = value
367 .get("payload")
368 .and_then(|payload| payload.get("message"))
369 .and_then(Value::as_str)
370 .unwrap_or_default()
371 .to_string();
372
373 if text.trim().is_empty() {
374 return None;
375 }
376
377 return Some(ThreadMessage {
378 role: MessageRole::Assistant,
379 text,
380 });
381 }
382
383 None
384}
385
386fn extract_codex_entry(value: &Value) -> Option<TimelineEntry> {
387 if let Some(message) = extract_codex_message(value) {
388 return Some(TimelineEntry::Message(message));
389 }
390
391 if is_codex_compact_event(value) {
392 return Some(TimelineEntry::Compact { summary: None });
393 }
394
395 None
396}
397
398fn is_codex_compact_event(value: &Value) -> bool {
399 let record_type = value.get("type").and_then(Value::as_str);
400
401 if record_type == Some("compacted") {
402 return true;
403 }
404
405 record_type == Some("event_msg")
406 && value
407 .get("payload")
408 .and_then(|payload| payload.get("type"))
409 .and_then(Value::as_str)
410 .is_some_and(|payload_type| payload_type == "context_compacted")
411}
412
413fn extract_claude_message(value: &Value) -> Option<ThreadMessage> {
414 let record_type = value.get("type").and_then(Value::as_str)?;
415 if record_type != "user" && record_type != "assistant" {
416 return None;
417 }
418
419 let message = value.get("message")?;
420 let role = message
421 .get("role")
422 .and_then(Value::as_str)
423 .or(Some(record_type))?;
424 let role = parse_role(role)?;
425
426 let text = extract_text(message.get("content"));
427 if text.trim().is_empty() {
428 return None;
429 }
430
431 Some(ThreadMessage { role, text })
432}
433
434fn extract_claude_entry(value: &Value) -> Option<TimelineEntry> {
435 if is_claude_compact_boundary(value) {
436 return Some(TimelineEntry::Compact { summary: None });
437 }
438
439 if is_claude_compact_summary(value) {
440 let summary = extract_claude_message(value).map(|message| message.text);
441 return Some(TimelineEntry::Compact { summary });
442 }
443
444 extract_claude_message(value).map(TimelineEntry::Message)
445}
446
447fn is_claude_compact_boundary(value: &Value) -> bool {
448 value.get("type").and_then(Value::as_str) == Some("system")
449 && value.get("subtype").and_then(Value::as_str) == Some("compact_boundary")
450}
451
452fn is_claude_compact_summary(value: &Value) -> bool {
453 value.get("type").and_then(Value::as_str) == Some("user")
454 && value
455 .get("isCompactSummary")
456 .and_then(Value::as_bool)
457 .unwrap_or(false)
458}
459
460fn extract_opencode_message(value: &Value) -> Option<ThreadMessage> {
461 let record_type = value.get("type").and_then(Value::as_str)?;
462 if record_type != "message" {
463 return None;
464 }
465
466 let message = value.get("message")?;
467 let role = message.get("role").and_then(Value::as_str)?;
468 let role = parse_role(role)?;
469
470 let mut chunks = Vec::new();
471 for part in value
472 .get("parts")
473 .and_then(Value::as_array)
474 .into_iter()
475 .flatten()
476 {
477 let Some(part_type) = part.get("type").and_then(Value::as_str) else {
478 continue;
479 };
480
481 if part_type != "text" && part_type != "reasoning" {
482 continue;
483 }
484
485 if let Some(text) = part.get("text").and_then(Value::as_str)
486 && !text.trim().is_empty()
487 {
488 chunks.push(text.trim().to_string());
489 }
490 }
491
492 if chunks.is_empty() {
493 return None;
494 }
495
496 Some(ThreadMessage {
497 role,
498 text: chunks.join("\n\n"),
499 })
500}
501
502fn extract_amp_text(content: Option<&Value>) -> String {
503 let Some(items) = content.and_then(Value::as_array) else {
504 return String::new();
505 };
506
507 let mut chunks = Vec::new();
508 for item in items {
509 let Some(item_type) = item.get("type").and_then(Value::as_str) else {
510 continue;
511 };
512
513 match item_type {
514 "text" => {
515 if let Some(text) = item.get("text").and_then(Value::as_str)
516 && !text.trim().is_empty()
517 {
518 chunks.push(text.trim().to_string());
519 }
520 }
521 "thinking" => {
522 if let Some(thinking) = item.get("thinking").and_then(Value::as_str)
523 && !thinking.trim().is_empty()
524 {
525 chunks.push(thinking.trim().to_string());
526 }
527 }
528 _ => {}
529 }
530 }
531
532 chunks.join("\n\n")
533}
534
535fn parse_role(role: &str) -> Option<MessageRole> {
536 match role {
537 "user" => Some(MessageRole::User),
538 "assistant" => Some(MessageRole::Assistant),
539 _ => None,
540 }
541}
542
543fn parse_gemini_role(role: &str) -> Option<MessageRole> {
544 match role {
545 "user" => Some(MessageRole::User),
546 "gemini" => Some(MessageRole::Assistant),
547 _ => None,
548 }
549}
550
551fn extract_text(content: Option<&Value>) -> String {
552 let Some(content) = content else {
553 return String::new();
554 };
555
556 if let Some(text) = content.as_str() {
557 return text.to_string();
558 }
559
560 let Some(items) = content.as_array() else {
561 return String::new();
562 };
563
564 let mut chunks = Vec::new();
565
566 for item in items {
567 if let Some(text) = item.as_str()
568 && !text.trim().is_empty()
569 {
570 chunks.push(text.trim().to_string());
571 continue;
572 }
573
574 if let Some(item_type) = item.get("type").and_then(Value::as_str)
575 && TOOL_TYPES.contains(&item_type)
576 {
577 continue;
578 }
579
580 if let Some(text) = item.get("text").and_then(Value::as_str)
581 && !text.trim().is_empty()
582 {
583 chunks.push(text.trim().to_string());
584 continue;
585 }
586
587 if let Some(text) = item.get("input_text").and_then(Value::as_str)
588 && !text.trim().is_empty()
589 {
590 chunks.push(text.trim().to_string());
591 continue;
592 }
593
594 if let Some(text) = item.get("output_text").and_then(Value::as_str)
595 && !text.trim().is_empty()
596 {
597 chunks.push(text.trim().to_string());
598 }
599 }
600
601 chunks.join("\n\n")
602}
603
604#[cfg(test)]
605mod tests {
606 use std::path::Path;
607
608 use crate::model::ProviderKind;
609 use crate::render::{extract_messages, render_markdown};
610 use crate::uri::AgentsUri;
611
612 #[test]
613 fn render_outputs_frontmatter() {
614 let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#;
615 let uri =
616 AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592").expect("parse uri");
617 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
618
619 assert!(output.starts_with("---\n"));
620 assert!(output.contains("uri: 'agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592'"));
621 assert!(output.contains("thread_source: '/tmp/mock'"));
622 assert!(output.contains("## Timeline"));
623 }
624
625 #[test]
626 fn codex_filters_function_calls() {
627 let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
628{"type":"response_item","payload":{"type":"function_call","name":"ls"}}
629{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"world"}]}}"#;
630
631 let messages =
632 extract_messages(ProviderKind::Codex, Path::new("/tmp/mock"), raw).expect("extract");
633 assert_eq!(messages.len(), 2);
634 assert_eq!(messages[0].text, "hello");
635 assert_eq!(messages[1].text, "world");
636 }
637
638 #[test]
639 fn claude_filters_tool_use() {
640 let raw = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
641{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"search"},{"type":"text","text":"done"}]}}"#;
642
643 let messages =
644 extract_messages(ProviderKind::Claude, Path::new("/tmp/mock"), raw).expect("extract");
645 assert_eq!(messages.len(), 2);
646 assert_eq!(messages[1].text, "done");
647 }
648
649 #[test]
650 fn opencode_extracts_text_and_reasoning_parts() {
651 let raw = r#"{"type":"session","sessionId":"ses_43a90e3adffejRgrTdlJa48CtE"}
652{"type":"message","id":"msg_1","sessionId":"ses_43a90e3adffejRgrTdlJa48CtE","message":{"role":"user","time":{"created":1}},"parts":[{"type":"text","text":"hello"}]}
653{"type":"message","id":"msg_2","sessionId":"ses_43a90e3adffejRgrTdlJa48CtE","message":{"role":"assistant","time":{"created":2}},"parts":[{"type":"reasoning","text":"thinking"},{"type":"tool","tool":"read"},{"type":"text","text":"world"}]}"#;
654
655 let messages =
656 extract_messages(ProviderKind::Opencode, Path::new("/tmp/mock"), raw).expect("extract");
657 assert_eq!(messages.len(), 2);
658 assert_eq!(messages[0].text, "hello");
659 assert_eq!(messages[1].text, "thinking\n\nworld");
660 }
661
662 #[test]
663 fn amp_extracts_text_and_thinking_content() {
664 let raw = r#"{"id":"T-019c0797-c402-7389-bd80-d785c98df295","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"step by step"},{"type":"tool_use","name":"finder"},{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"tool_result","toolUseID":"tool_1","run":{"status":"done","result":"ignored"}}]}]}"#;
665
666 let messages =
667 extract_messages(ProviderKind::Amp, Path::new("/tmp/mock"), raw).expect("extract");
668 assert_eq!(messages.len(), 2);
669 assert_eq!(messages[0].text, "hello");
670 assert_eq!(messages[1].text, "step by step\n\ndone");
671 }
672
673 #[test]
674 fn gemini_extracts_user_and_assistant_messages() {
675 let raw = r#"{"sessionId":"29d207db-ca7e-40ba-87f7-e14c9de60613","messages":[{"type":"info","content":"ignored"},{"type":"user","content":"hello"},{"type":"gemini","content":"world"},{"type":"gemini","content":[{"type":"thinking","text":"step by step"},{"type":"tool_call","name":"list_directory"},{"type":"text","text":"done"}]}]}"#;
676
677 let messages =
678 extract_messages(ProviderKind::Gemini, Path::new("/tmp/mock"), raw).expect("extract");
679 assert_eq!(messages.len(), 3);
680 assert_eq!(messages[0].text, "hello");
681 assert_eq!(messages[1].text, "world");
682 assert_eq!(messages[2].text, "step by step\n\ndone");
683 }
684
685 #[test]
686 fn pi_default_leaf_renders_latest_branch() {
687 let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
688{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
689{"type":"message","id":"b1b2c3d4","parentId":"a1b2c3d4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
690{"type":"message","id":"c1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
691{"type":"message","id":"d1b2c3d4","parentId":"c1b2c3d4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
692{"type":"message","id":"e1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
693{"type":"compaction","id":"f1b2c3d4","parentId":"e1b2c3d4","timestamp":"2026-02-23T13:00:18.000Z","summary":"compact summary","firstKeptEntryId":"b1b2c3d4","tokensBefore":128}
694{"type":"message","id":"g1b2c3d4","parentId":"f1b2c3d4","timestamp":"2026-02-23T13:00:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
695
696 let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f").expect("parse uri");
697 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
698
699 assert!(output.contains("root"));
700 assert!(output.contains("branch two"));
701 assert!(output.contains("compact summary"));
702 assert!(!output.contains("branch one done"));
703 }
704
705 #[test]
706 fn pi_entry_leaf_renders_requested_branch() {
707 let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
708{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
709{"type":"message","id":"b1b2c3d4","parentId":"a1b2c3d4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
710{"type":"message","id":"c1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
711{"type":"message","id":"d1b2c3d4","parentId":"c1b2c3d4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
712{"type":"message","id":"e1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
713{"type":"message","id":"f1b2c3d4","parentId":"e1b2c3d4","timestamp":"2026-02-23T13:00:18.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
714
715 let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/d1b2c3d4")
716 .expect("parse uri");
717 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
718
719 assert!(output.contains("branch one done"));
720 assert!(!output.contains("branch two done"));
721 }
722
723 #[test]
724 fn pi_entry_leaf_renders_requested_branch_with_uppercase_ids() {
725 let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
726{"type":"message","id":"A1B2C3D4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
727{"type":"message","id":"B1B2C3D4","parentId":"A1B2C3D4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
728{"type":"message","id":"C1B2C3D4","parentId":"B1B2C3D4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
729{"type":"message","id":"D1B2C3D4","parentId":"C1B2C3D4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
730{"type":"message","id":"E1B2C3D4","parentId":"B1B2C3D4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
731{"type":"message","id":"F1B2C3D4","parentId":"E1B2C3D4","timestamp":"2026-02-23T13:00:18.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
732
733 let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/d1b2c3d4")
734 .expect("parse uri");
735 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
736
737 assert!(output.contains("branch one done"));
738 assert!(!output.contains("branch two done"));
739 }
740
741 #[test]
742 fn pi_entry_leaf_reports_not_found() {
743 let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
744{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}"#;
745
746 let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/deadbeef")
747 .expect("parse uri");
748 let err = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect_err("must fail");
749 assert!(format!("{err}").contains("entry not found"));
750 }
751
752 #[test]
753 fn codex_renders_compact_events_in_timeline() {
754 let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
755{"type":"event_msg","payload":{"type":"context_compacted"}}
756{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"world"}]}}"#;
757
758 let uri =
759 AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592").expect("parse uri");
760 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
761
762 assert!(output.contains("## 1. User"));
763 assert!(output.contains("## 2. Context Compacted"));
764 assert!(output.contains("Context was compacted."));
765 assert!(output.contains("## 3. Assistant"));
766 }
767
768 #[test]
769 fn claude_compact_summary_renders_as_compact_entry() {
770 let raw = r#"{"type":"user","isCompactSummary":true,"message":{"role":"user","content":[{"type":"text","text":"Summary: old conversation"}]}}
771{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"New answer"}]}}"#;
772
773 let uri =
774 AgentsUri::parse("claude://2823d1df-720a-4c31-ac55-ae8ba726721f").expect("parse uri");
775 let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
776
777 assert!(output.contains("## 1. Context Compacted"));
778 assert!(output.contains("Summary: old conversation"));
779 assert!(!output.contains("## 1. User"));
780 assert!(output.contains("## 2. Assistant"));
781 }
782}