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::Copilot {
107        return Ok(messages_to_entries(extract_copilot_messages(
108            path, raw_jsonl,
109        )?));
110    }
111    if provider == ProviderKind::Gemini {
112        return Ok(messages_to_entries(extract_gemini_messages(
113            path, raw_jsonl,
114        )?));
115    }
116    if provider == ProviderKind::Kimi {
117        return Ok(messages_to_entries(extract_kimi_messages(raw_jsonl)));
118    }
119    if provider == ProviderKind::Pi {
120        return extract_pi_entries(path, raw_jsonl, session_id, target_entry_id);
121    }
122
123    let mut entries = Vec::new();
124
125    for (line_idx, line) in raw_jsonl.lines().enumerate() {
126        let line_no = line_idx + 1;
127        let trimmed = line.trim();
128        if trimmed.is_empty() {
129            continue;
130        }
131
132        let Some(value) = jsonl::parse_json_line(path, line_no, trimmed)? else {
133            continue;
134        };
135
136        let extracted = match provider {
137            ProviderKind::Amp => None,
138            ProviderKind::Copilot => extract_copilot_entry(&value),
139            ProviderKind::Codex => extract_codex_entry(&value),
140            ProviderKind::Claude => extract_claude_entry(&value),
141            ProviderKind::Cursor => extract_cursor_message(&value).map(TimelineEntry::Message),
142            ProviderKind::Gemini => None,
143            ProviderKind::Kimi => None,
144            ProviderKind::Pi => None,
145            ProviderKind::Opencode => extract_opencode_message(&value).map(TimelineEntry::Message),
146        };
147
148        if let Some(entry) = extracted {
149            entries.push(entry);
150        }
151    }
152
153    Ok(entries)
154}
155
156fn messages_to_entries(messages: Vec<ThreadMessage>) -> Vec<TimelineEntry> {
157    messages.into_iter().map(TimelineEntry::Message).collect()
158}
159
160fn extract_pi_entries(
161    path: &Path,
162    raw_jsonl: &str,
163    session_id: &str,
164    target_entry_id: Option<&str>,
165) -> Result<Vec<TimelineEntry>> {
166    let mut entries_by_id = HashMap::<String, Value>::new();
167    let mut last_entry_id = None::<String>;
168
169    for (line_idx, line) in raw_jsonl.lines().enumerate() {
170        let line_no = line_idx + 1;
171        let trimmed = line.trim();
172        if trimmed.is_empty() {
173            continue;
174        }
175
176        let Some(value) = jsonl::parse_json_line(path, line_no, trimmed)? else {
177            continue;
178        };
179
180        if value.get("type").and_then(Value::as_str) == Some("session") {
181            continue;
182        }
183
184        let Some(id) = value
185            .get("id")
186            .and_then(Value::as_str)
187            .map(str::to_ascii_lowercase)
188        else {
189            continue;
190        };
191
192        last_entry_id = Some(id.clone());
193        entries_by_id.insert(id, value);
194    }
195
196    if entries_by_id.is_empty() {
197        return Ok(Vec::new());
198    }
199
200    let leaf_id = target_entry_id
201        .map(str::to_ascii_lowercase)
202        .or(last_entry_id)
203        .unwrap_or_default();
204
205    if !entries_by_id.contains_key(&leaf_id) {
206        return Err(XurlError::EntryNotFound {
207            provider: ProviderKind::Pi.to_string(),
208            session_id: session_id.to_string(),
209            entry_id: leaf_id,
210        });
211    }
212
213    let mut path_ids = Vec::new();
214    let mut seen = HashSet::new();
215    let mut current = Some(leaf_id);
216
217    while let Some(entry_id) = current {
218        if !seen.insert(entry_id.clone()) {
219            break;
220        }
221
222        let Some(entry) = entries_by_id.get(&entry_id) else {
223            break;
224        };
225        path_ids.push(entry_id);
226
227        current = entry
228            .get("parentId")
229            .and_then(Value::as_str)
230            .map(str::to_ascii_lowercase);
231    }
232
233    path_ids.reverse();
234
235    let mut entries = Vec::new();
236    for entry_id in path_ids {
237        let Some(entry) = entries_by_id.get(&entry_id) else {
238            continue;
239        };
240        if let Some(timeline_entry) = extract_pi_entry(entry) {
241            entries.push(timeline_entry);
242        }
243    }
244
245    Ok(entries)
246}
247
248fn extract_pi_entry(value: &Value) -> Option<TimelineEntry> {
249    let entry_type = value.get("type").and_then(Value::as_str)?;
250
251    if entry_type == "message" {
252        let message = value.get("message")?;
253        let role = message
254            .get("role")
255            .and_then(Value::as_str)
256            .and_then(parse_role)?;
257        let text = extract_text(message.get("content"));
258        if text.trim().is_empty() {
259            return None;
260        }
261
262        return Some(TimelineEntry::Message(ThreadMessage { role, text }));
263    }
264
265    if entry_type == "compaction" || entry_type == "branch_summary" {
266        let summary = value
267            .get("summary")
268            .and_then(Value::as_str)
269            .map(ToString::to_string);
270        return Some(TimelineEntry::Compact { summary });
271    }
272
273    None
274}
275
276fn extract_amp_messages(path: &Path, raw_json: &str) -> Result<Vec<ThreadMessage>> {
277    let value =
278        serde_json::from_str::<Value>(raw_json).map_err(|source| XurlError::InvalidJsonLine {
279            path: path.to_path_buf(),
280            line: 1,
281            source,
282        })?;
283
284    let mut messages = Vec::new();
285    for message in value
286        .get("messages")
287        .and_then(Value::as_array)
288        .into_iter()
289        .flatten()
290    {
291        let Some(role) = message
292            .get("role")
293            .and_then(Value::as_str)
294            .and_then(parse_role)
295        else {
296            continue;
297        };
298
299        let text = extract_amp_text(message.get("content"));
300        if text.trim().is_empty() {
301            continue;
302        }
303
304        messages.push(ThreadMessage { role, text });
305    }
306
307    Ok(messages)
308}
309
310fn extract_copilot_messages(path: &Path, raw_jsonl: &str) -> Result<Vec<ThreadMessage>> {
311    let mut messages = Vec::new();
312
313    for (line_idx, line) in raw_jsonl.lines().enumerate() {
314        let line_no = line_idx + 1;
315        let trimmed = line.trim();
316        if trimmed.is_empty() {
317            continue;
318        }
319
320        let Some(value) = jsonl::parse_json_line(path, line_no, trimmed)? else {
321            continue;
322        };
323
324        if let Some(message) = extract_copilot_message(&value) {
325            messages.push(message);
326        }
327    }
328
329    Ok(messages)
330}
331
332fn extract_gemini_messages(path: &Path, raw_json: &str) -> Result<Vec<ThreadMessage>> {
333    let value =
334        serde_json::from_str::<Value>(raw_json).map_err(|source| XurlError::InvalidJsonLine {
335            path: path.to_path_buf(),
336            line: 1,
337            source,
338        })?;
339
340    let mut messages = Vec::new();
341    for message in value
342        .get("messages")
343        .and_then(Value::as_array)
344        .into_iter()
345        .flatten()
346    {
347        let Some(role) = message
348            .get("type")
349            .and_then(Value::as_str)
350            .and_then(parse_gemini_role)
351        else {
352            continue;
353        };
354
355        let text = extract_text(message.get("displayContent"));
356        let text = if text.trim().is_empty() {
357            extract_text(message.get("content"))
358        } else {
359            text
360        };
361
362        if text.trim().is_empty() {
363            continue;
364        }
365
366        messages.push(ThreadMessage { role, text });
367    }
368
369    Ok(messages)
370}
371
372fn extract_codex_message(value: &Value) -> Option<ThreadMessage> {
373    let record_type = value.get("type").and_then(Value::as_str)?;
374
375    if record_type == "response_item" {
376        let payload = value.get("payload")?;
377        let payload_type = payload.get("type").and_then(Value::as_str)?;
378        if payload_type != "message" {
379            return None;
380        }
381
382        let role = payload.get("role").and_then(Value::as_str)?;
383        let role = parse_role(role)?;
384        let text = extract_text(payload.get("content"));
385        if text.trim().is_empty() {
386            return None;
387        }
388
389        return Some(ThreadMessage { role, text });
390    }
391
392    if record_type == "event_msg"
393        && value
394            .get("payload")
395            .and_then(|payload| payload.get("type"))
396            .and_then(Value::as_str)
397            .is_some_and(|t| t == "agent_message")
398    {
399        let text = value
400            .get("payload")
401            .and_then(|payload| payload.get("message"))
402            .and_then(Value::as_str)
403            .unwrap_or_default()
404            .to_string();
405
406        if text.trim().is_empty() {
407            return None;
408        }
409
410        return Some(ThreadMessage {
411            role: MessageRole::Assistant,
412            text,
413        });
414    }
415
416    None
417}
418
419fn extract_copilot_message(value: &Value) -> Option<ThreadMessage> {
420    match value.get("type").and_then(Value::as_str)? {
421        "user.message" => {
422            let text = value
423                .get("data")
424                .and_then(|data| data.get("content"))
425                .and_then(Value::as_str)?;
426            if text.trim().is_empty() {
427                return None;
428            }
429            Some(ThreadMessage {
430                role: MessageRole::User,
431                text: text.to_string(),
432            })
433        }
434        "assistant.message" => {
435            let text = value
436                .get("data")
437                .and_then(|data| data.get("content"))
438                .and_then(Value::as_str)?;
439            if text.trim().is_empty() {
440                return None;
441            }
442            Some(ThreadMessage {
443                role: MessageRole::Assistant,
444                text: text.to_string(),
445            })
446        }
447        _ => None,
448    }
449}
450
451fn extract_copilot_entry(value: &Value) -> Option<TimelineEntry> {
452    extract_copilot_message(value).map(TimelineEntry::Message)
453}
454
455fn extract_codex_entry(value: &Value) -> Option<TimelineEntry> {
456    if let Some(message) = extract_codex_message(value) {
457        return Some(TimelineEntry::Message(message));
458    }
459
460    if is_codex_compact_event(value) {
461        return Some(TimelineEntry::Compact { summary: None });
462    }
463
464    None
465}
466
467fn is_codex_compact_event(value: &Value) -> bool {
468    let record_type = value.get("type").and_then(Value::as_str);
469
470    if record_type == Some("compacted") {
471        return true;
472    }
473
474    record_type == Some("event_msg")
475        && value
476            .get("payload")
477            .and_then(|payload| payload.get("type"))
478            .and_then(Value::as_str)
479            .is_some_and(|payload_type| payload_type == "context_compacted")
480}
481
482fn extract_claude_message(value: &Value) -> Option<ThreadMessage> {
483    let record_type = value.get("type").and_then(Value::as_str)?;
484    if record_type != "user" && record_type != "assistant" {
485        return None;
486    }
487
488    let message = value.get("message")?;
489    let role = message
490        .get("role")
491        .and_then(Value::as_str)
492        .or(Some(record_type))?;
493    let role = parse_role(role)?;
494
495    let text = extract_text(message.get("content"));
496    if text.trim().is_empty() {
497        return None;
498    }
499
500    Some(ThreadMessage { role, text })
501}
502
503fn extract_claude_entry(value: &Value) -> Option<TimelineEntry> {
504    if is_claude_compact_boundary(value) {
505        return Some(TimelineEntry::Compact { summary: None });
506    }
507
508    if is_claude_compact_summary(value) {
509        let summary = extract_claude_message(value).map(|message| message.text);
510        return Some(TimelineEntry::Compact { summary });
511    }
512
513    extract_claude_message(value).map(TimelineEntry::Message)
514}
515
516fn is_claude_compact_boundary(value: &Value) -> bool {
517    value.get("type").and_then(Value::as_str) == Some("system")
518        && value.get("subtype").and_then(Value::as_str) == Some("compact_boundary")
519}
520
521fn is_claude_compact_summary(value: &Value) -> bool {
522    value.get("type").and_then(Value::as_str) == Some("user")
523        && value
524            .get("isCompactSummary")
525            .and_then(Value::as_bool)
526            .unwrap_or(false)
527}
528
529fn extract_opencode_message(value: &Value) -> Option<ThreadMessage> {
530    let record_type = value.get("type").and_then(Value::as_str)?;
531    if record_type != "message" {
532        return None;
533    }
534
535    let message = value.get("message")?;
536    let role = message.get("role").and_then(Value::as_str)?;
537    let role = parse_role(role)?;
538
539    let mut chunks = Vec::new();
540    for part in value
541        .get("parts")
542        .and_then(Value::as_array)
543        .into_iter()
544        .flatten()
545    {
546        let Some(part_type) = part.get("type").and_then(Value::as_str) else {
547            continue;
548        };
549
550        if part_type != "text" && part_type != "reasoning" {
551            continue;
552        }
553
554        if let Some(text) = part.get("text").and_then(Value::as_str)
555            && !text.trim().is_empty()
556        {
557            chunks.push(text.trim().to_string());
558        }
559    }
560
561    if chunks.is_empty() {
562        return None;
563    }
564
565    Some(ThreadMessage {
566        role,
567        text: chunks.join("\n\n"),
568    })
569}
570
571fn extract_cursor_message(value: &Value) -> Option<ThreadMessage> {
572    extract_opencode_message(value)
573}
574
575fn extract_amp_text(content: Option<&Value>) -> String {
576    let Some(items) = content.and_then(Value::as_array) else {
577        return String::new();
578    };
579
580    let mut chunks = Vec::new();
581    for item in items {
582        let Some(item_type) = item.get("type").and_then(Value::as_str) else {
583            continue;
584        };
585
586        match item_type {
587            "text" => {
588                if let Some(text) = item.get("text").and_then(Value::as_str)
589                    && !text.trim().is_empty()
590                {
591                    chunks.push(text.trim().to_string());
592                }
593            }
594            "thinking" => {
595                if let Some(thinking) = item.get("thinking").and_then(Value::as_str)
596                    && !thinking.trim().is_empty()
597                {
598                    chunks.push(thinking.trim().to_string());
599                }
600            }
601            _ => {}
602        }
603    }
604
605    chunks.join("\n\n")
606}
607
608fn parse_role(role: &str) -> Option<MessageRole> {
609    match role {
610        "user" => Some(MessageRole::User),
611        "assistant" => Some(MessageRole::Assistant),
612        _ => None,
613    }
614}
615
616fn parse_gemini_role(role: &str) -> Option<MessageRole> {
617    match role {
618        "user" => Some(MessageRole::User),
619        "gemini" => Some(MessageRole::Assistant),
620        _ => None,
621    }
622}
623
624fn extract_text(content: Option<&Value>) -> String {
625    let Some(content) = content else {
626        return String::new();
627    };
628
629    if let Some(text) = content.as_str() {
630        return text.to_string();
631    }
632
633    let Some(items) = content.as_array() else {
634        return String::new();
635    };
636
637    let mut chunks = Vec::new();
638
639    for item in items {
640        if let Some(text) = item.as_str()
641            && !text.trim().is_empty()
642        {
643            chunks.push(text.trim().to_string());
644            continue;
645        }
646
647        if let Some(item_type) = item.get("type").and_then(Value::as_str)
648            && TOOL_TYPES.contains(&item_type)
649        {
650            continue;
651        }
652
653        if let Some(text) = item.get("text").and_then(Value::as_str)
654            && !text.trim().is_empty()
655        {
656            chunks.push(text.trim().to_string());
657            continue;
658        }
659
660        if let Some(text) = item.get("input_text").and_then(Value::as_str)
661            && !text.trim().is_empty()
662        {
663            chunks.push(text.trim().to_string());
664            continue;
665        }
666
667        if let Some(text) = item.get("output_text").and_then(Value::as_str)
668            && !text.trim().is_empty()
669        {
670            chunks.push(text.trim().to_string());
671        }
672    }
673
674    chunks.join("\n\n")
675}
676
677fn extract_kimi_messages(raw_jsonl: &str) -> Vec<ThreadMessage> {
678    let mut messages = Vec::new();
679
680    for line in raw_jsonl.lines() {
681        let trimmed = line.trim();
682        if trimmed.is_empty() {
683            continue;
684        }
685
686        let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
687            continue;
688        };
689
690        let Some(role) = value
691            .get("role")
692            .and_then(Value::as_str)
693            .and_then(parse_role)
694        else {
695            continue;
696        };
697
698        let text = extract_kimi_text(&value);
699        if text.trim().is_empty() {
700            continue;
701        }
702
703        messages.push(ThreadMessage { role, text });
704    }
705
706    messages
707}
708
709fn extract_kimi_text(value: &Value) -> String {
710    if let Some(text) = value.get("content").and_then(Value::as_str) {
711        if !text.trim().is_empty() {
712            return text.to_string();
713        }
714    }
715
716    let Some(items) = value.get("content").and_then(Value::as_array) else {
717        return String::new();
718    };
719
720    let mut chunks = Vec::new();
721    for item in items {
722        let Some(item_type) = item.get("type").and_then(Value::as_str) else {
723            continue;
724        };
725
726        match item_type {
727            "think" | "text" => {
728                if let Some(text) = item.get("text").and_then(Value::as_str) {
729                    if !text.trim().is_empty() {
730                        chunks.push(text.trim().to_string());
731                    }
732                }
733            }
734            _ => {}
735        }
736    }
737
738    chunks.join("\n\n")
739}
740
741#[cfg(test)]
742mod tests {
743    use std::path::Path;
744
745    use crate::model::ProviderKind;
746    use crate::render::{extract_messages, render_markdown};
747    use crate::uri::AgentsUri;
748
749    #[test]
750    fn render_outputs_frontmatter() {
751        let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#;
752        let uri =
753            AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592").expect("parse uri");
754        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
755
756        assert!(output.starts_with("---\n"));
757        assert!(output.contains("uri: 'agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592'"));
758        assert!(output.contains("thread_source: '/tmp/mock'"));
759        assert!(output.contains("## Timeline"));
760    }
761
762    #[test]
763    fn codex_filters_function_calls() {
764        let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
765{"type":"response_item","payload":{"type":"function_call","name":"ls"}}
766{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"world"}]}}"#;
767
768        let messages =
769            extract_messages(ProviderKind::Codex, Path::new("/tmp/mock"), raw).expect("extract");
770        assert_eq!(messages.len(), 2);
771        assert_eq!(messages[0].text, "hello");
772        assert_eq!(messages[1].text, "world");
773    }
774
775    #[test]
776    fn claude_filters_tool_use() {
777        let raw = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
778{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"search"},{"type":"text","text":"done"}]}}"#;
779
780        let messages =
781            extract_messages(ProviderKind::Claude, Path::new("/tmp/mock"), raw).expect("extract");
782        assert_eq!(messages.len(), 2);
783        assert_eq!(messages[1].text, "done");
784    }
785
786    #[test]
787    fn opencode_extracts_text_and_reasoning_parts() {
788        let raw = r#"{"type":"session","sessionId":"ses_43a90e3adffejRgrTdlJa48CtE"}
789{"type":"message","id":"msg_1","sessionId":"ses_43a90e3adffejRgrTdlJa48CtE","message":{"role":"user","time":{"created":1}},"parts":[{"type":"text","text":"hello"}]}
790{"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"}]}"#;
791
792        let messages =
793            extract_messages(ProviderKind::Opencode, Path::new("/tmp/mock"), raw).expect("extract");
794        assert_eq!(messages.len(), 2);
795        assert_eq!(messages[0].text, "hello");
796        assert_eq!(messages[1].text, "thinking\n\nworld");
797    }
798
799    #[test]
800    fn amp_extracts_text_and_thinking_content() {
801        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"}}]}]}"#;
802
803        let messages =
804            extract_messages(ProviderKind::Amp, Path::new("/tmp/mock"), raw).expect("extract");
805        assert_eq!(messages.len(), 2);
806        assert_eq!(messages[0].text, "hello");
807        assert_eq!(messages[1].text, "step by step\n\ndone");
808    }
809
810    #[test]
811    fn gemini_extracts_user_and_assistant_messages() {
812        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"}]}]}"#;
813
814        let messages =
815            extract_messages(ProviderKind::Gemini, Path::new("/tmp/mock"), raw).expect("extract");
816        assert_eq!(messages.len(), 3);
817        assert_eq!(messages[0].text, "hello");
818        assert_eq!(messages[1].text, "world");
819        assert_eq!(messages[2].text, "step by step\n\ndone");
820    }
821
822    #[test]
823    fn pi_default_leaf_renders_latest_branch() {
824        let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
825{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
826{"type":"message","id":"b1b2c3d4","parentId":"a1b2c3d4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
827{"type":"message","id":"c1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
828{"type":"message","id":"d1b2c3d4","parentId":"c1b2c3d4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
829{"type":"message","id":"e1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
830{"type":"compaction","id":"f1b2c3d4","parentId":"e1b2c3d4","timestamp":"2026-02-23T13:00:18.000Z","summary":"compact summary","firstKeptEntryId":"b1b2c3d4","tokensBefore":128}
831{"type":"message","id":"g1b2c3d4","parentId":"f1b2c3d4","timestamp":"2026-02-23T13:00:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
832
833        let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f").expect("parse uri");
834        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
835
836        assert!(output.contains("root"));
837        assert!(output.contains("branch two"));
838        assert!(output.contains("compact summary"));
839        assert!(!output.contains("branch one done"));
840    }
841
842    #[test]
843    fn pi_entry_leaf_renders_requested_branch() {
844        let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
845{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
846{"type":"message","id":"b1b2c3d4","parentId":"a1b2c3d4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
847{"type":"message","id":"c1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
848{"type":"message","id":"d1b2c3d4","parentId":"c1b2c3d4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
849{"type":"message","id":"e1b2c3d4","parentId":"b1b2c3d4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
850{"type":"message","id":"f1b2c3d4","parentId":"e1b2c3d4","timestamp":"2026-02-23T13:00:18.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
851
852        let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/d1b2c3d4")
853            .expect("parse uri");
854        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
855
856        assert!(output.contains("branch one done"));
857        assert!(!output.contains("branch two done"));
858    }
859
860    #[test]
861    fn pi_entry_leaf_renders_requested_branch_with_uppercase_ids() {
862        let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
863{"type":"message","id":"A1B2C3D4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}
864{"type":"message","id":"B1B2C3D4","parentId":"A1B2C3D4","timestamp":"2026-02-23T13:00:14.000Z","message":{"role":"assistant","content":[{"type":"text","text":"root done"}]}}
865{"type":"message","id":"C1B2C3D4","parentId":"B1B2C3D4","timestamp":"2026-02-23T13:00:15.000Z","message":{"role":"user","content":[{"type":"text","text":"branch one"}]}}
866{"type":"message","id":"D1B2C3D4","parentId":"C1B2C3D4","timestamp":"2026-02-23T13:00:16.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch one done"}]}}
867{"type":"message","id":"E1B2C3D4","parentId":"B1B2C3D4","timestamp":"2026-02-23T13:00:17.000Z","message":{"role":"user","content":[{"type":"text","text":"branch two"}]}}
868{"type":"message","id":"F1B2C3D4","parentId":"E1B2C3D4","timestamp":"2026-02-23T13:00:18.000Z","message":{"role":"assistant","content":[{"type":"text","text":"branch two done"}]}}"#;
869
870        let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/d1b2c3d4")
871            .expect("parse uri");
872        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
873
874        assert!(output.contains("branch one done"));
875        assert!(!output.contains("branch two done"));
876    }
877
878    #[test]
879    fn pi_entry_leaf_reports_not_found() {
880        let raw = r#"{"type":"session","version":3,"id":"12cb4c19-2774-4de4-a0d0-9fa32fbae29f","timestamp":"2026-02-23T13:00:12.780Z","cwd":"/tmp/project"}
881{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-02-23T13:00:13.000Z","message":{"role":"user","content":[{"type":"text","text":"root"}]}}"#;
882
883        let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/deadbeef")
884            .expect("parse uri");
885        let err = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect_err("must fail");
886        assert!(format!("{err}").contains("entry not found"));
887    }
888
889    #[test]
890    fn codex_renders_compact_events_in_timeline() {
891        let raw = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
892{"type":"event_msg","payload":{"type":"context_compacted"}}
893{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"world"}]}}"#;
894
895        let uri =
896            AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592").expect("parse uri");
897        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
898
899        assert!(output.contains("## 1. User"));
900        assert!(output.contains("## 2. Context Compacted"));
901        assert!(output.contains("Context was compacted."));
902        assert!(output.contains("## 3. Assistant"));
903    }
904
905    #[test]
906    fn claude_compact_summary_renders_as_compact_entry() {
907        let raw = r#"{"type":"user","isCompactSummary":true,"message":{"role":"user","content":[{"type":"text","text":"Summary: old conversation"}]}}
908{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"New answer"}]}}"#;
909
910        let uri =
911            AgentsUri::parse("claude://2823d1df-720a-4c31-ac55-ae8ba726721f").expect("parse uri");
912        let output = render_markdown(&uri, Path::new("/tmp/mock"), raw).expect("render");
913
914        assert!(output.contains("## 1. Context Compacted"));
915        assert!(output.contains("Summary: old conversation"));
916        assert!(!output.contains("## 1. User"));
917        assert!(output.contains("## 2. Assistant"));
918    }
919
920    #[test]
921    fn kimi_extracts_string_content_messages() {
922        let raw = r#"{"role":"user","content":"hello"}
923{"role":"assistant","content":"world"}"#;
924
925        let messages =
926            extract_messages(ProviderKind::Kimi, Path::new("/tmp/mock"), raw).expect("extract");
927        assert_eq!(messages.len(), 2);
928        assert_eq!(messages[0].text, "hello");
929        assert_eq!(messages[1].text, "world");
930    }
931
932    #[test]
933    fn kimi_extracts_think_and_text_content_types() {
934        let raw = r#"{"role":"user","content":[{"type":"text","text":"hello"}]}
935{"role":"assistant","content":[{"type":"think","text":"reasoning"},{"type":"tool_call","name":"read_file"},{"type":"text","text":"done"}]}"#;
936
937        let messages =
938            extract_messages(ProviderKind::Kimi, Path::new("/tmp/mock"), raw).expect("extract");
939        assert_eq!(messages.len(), 2);
940        assert_eq!(messages[0].text, "hello");
941        assert_eq!(messages[1].text, "reasoning\n\ndone");
942    }
943}