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}