Skip to main content

xurl_core/
render.rs

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}