1use std::collections::{HashMap, HashSet};
11
12use chrono::DateTime;
13use toolpath::v1::{Path, Step};
14
15use crate::{
16 ConversationEvent, ConversationView, DelegatedWork, EnvironmentSnapshot, Role, TokenUsage,
17 ToolCategory, ToolInvocation, ToolResult, Turn,
18};
19
20pub fn extract_conversation(path: &Path) -> ConversationView {
27 let mut view = ConversationView {
28 id: String::new(),
29 started_at: None,
30 last_activity: None,
31 turns: Vec::new(),
32 total_usage: None,
33 provider_id: None,
34 files_changed: Vec::new(),
35 session_ids: Vec::new(),
36 events: Vec::new(),
37 };
38
39 let mut step_to_turn: HashMap<&str, usize> = HashMap::new();
41 let mut files_seen: HashSet<String> = HashSet::new();
43
44 for step in &path.steps {
45 for (artifact_key, artifact_change) in &step.change {
46 let structural = match &artifact_change.structural {
47 Some(s) => s,
48 None => continue,
49 };
50
51 match structural.change_type.as_str() {
52 "conversation.init" => {
53 handle_init(&mut view, artifact_key, &structural.extra);
54 }
55 "conversation.append" => {
56 if view.id.is_empty()
61 && let Some((provider, session)) = artifact_key.split_once("://")
62 && !provider.is_empty()
63 && !session.is_empty()
64 {
65 view.provider_id = Some(provider.to_string());
66 view.id = session.to_string();
67 }
68
69 let turn = build_turn(step, &structural.extra);
70 let idx = view.turns.len();
71 step_to_turn.insert(&step.step.id, idx);
72 view.turns.push(turn);
73 }
74 "conversation.event" => {
75 let event_type = structural
76 .extra
77 .get("entry_type")
78 .and_then(|v| v.as_str())
79 .unwrap_or("unknown")
80 .to_string();
81
82 let event = ConversationEvent {
83 id: step.step.id.clone(),
84 timestamp: step.step.timestamp.clone(),
85 parent_id: step.step.parents.first().cloned(),
86 event_type,
87 data: structural.extra.clone(),
88 };
89 view.events.push(event);
90 }
91 "tool.invoke" => {
92 let invocation = build_tool_invocation(&structural.extra);
93
94 let category = parse_category(structural.extra.get("category"));
96 if category == Some(ToolCategory::FileWrite)
97 && !artifact_key.starts_with("agent://")
98 && files_seen.insert(artifact_key.clone())
99 {
100 view.files_changed.push(artifact_key.clone());
101 }
102
103 if let Some(parent_id) = step.step.parents.first()
105 && let Some(&turn_idx) = step_to_turn.get(parent_id.as_str())
106 {
107 view.turns[turn_idx].tool_uses.push(invocation);
108 }
109 }
110 _ => {
111 }
113 }
114 }
115 }
116
117 let mut has_any_usage = false;
119 let mut total = TokenUsage::default();
120 for turn in &view.turns {
121 if let Some(usage) = &turn.token_usage {
122 has_any_usage = true;
123 total.input_tokens = add_opt(total.input_tokens, usage.input_tokens);
124 total.output_tokens = add_opt(total.output_tokens, usage.output_tokens);
125 total.cache_read_tokens = add_opt(total.cache_read_tokens, usage.cache_read_tokens);
126 total.cache_write_tokens = add_opt(total.cache_write_tokens, usage.cache_write_tokens);
127 }
128 }
129 if has_any_usage {
130 view.total_usage = Some(total);
131 }
132
133 if let Some(first) = view.turns.first() {
135 view.started_at = DateTime::parse_from_rfc3339(&first.timestamp)
136 .ok()
137 .map(|dt| dt.with_timezone(&chrono::Utc));
138 }
139 if let Some(last) = view.turns.last() {
140 view.last_activity = DateTime::parse_from_rfc3339(&last.timestamp)
141 .ok()
142 .map(|dt| dt.with_timezone(&chrono::Utc));
143 }
144
145 view
146}
147
148fn handle_init(
149 view: &mut ConversationView,
150 artifact_key: &str,
151 extra: &HashMap<String, serde_json::Value>,
152) {
153 if let Some(rest) = artifact_key.strip_prefix("agent://") {
155 let parts: Vec<&str> = rest.splitn(2, '/').collect();
156 if parts.len() == 2 {
157 view.provider_id = Some(parts[0].to_string());
158 view.id = parts[1].to_string();
159 }
160 }
161
162 if let Some(serde_json::Value::String(v)) = extra.get("version") {
164 let _ = v;
167 }
168}
169
170fn build_turn(step: &Step, extra: &HashMap<String, serde_json::Value>) -> Turn {
171 let role = if let Some(serde_json::Value::String(r)) = extra.get("role") {
172 parse_role(r)
173 } else {
174 role_from_actor(&step.step.actor)
175 };
176
177 let text = extra
178 .get("text")
179 .and_then(|v| v.as_str())
180 .unwrap_or("")
181 .to_string();
182
183 let thinking = extra
184 .get("thinking")
185 .and_then(|v| v.as_str())
186 .map(|s| s.to_string());
187
188 let model = model_from_actor(&step.step.actor);
190
191 let stop_reason = extra
192 .get("stop_reason")
193 .and_then(|v| v.as_str())
194 .map(|s| s.to_string());
195
196 let token_usage = build_token_usage(extra);
197
198 let environment = build_environment(extra);
199
200 let tool_uses = build_inline_tool_uses(extra);
201
202 let delegations = build_delegations(extra);
203
204 let turn_extra = build_turn_extra(extra);
205
206 let parent_id = step.step.parents.first().cloned();
207
208 Turn {
209 id: step.step.id.clone(),
210 parent_id,
211 role,
212 timestamp: step.step.timestamp.clone(),
213 text,
214 thinking,
215 tool_uses,
216 model,
217 stop_reason,
218 token_usage,
219 environment,
220 delegations,
221 extra: turn_extra,
222 }
223}
224
225fn build_environment(extra: &HashMap<String, serde_json::Value>) -> Option<EnvironmentSnapshot> {
229 if let Some(v) = extra.get("environment")
230 && let Ok(env) = serde_json::from_value::<EnvironmentSnapshot>(v.clone())
231 {
232 return Some(env);
233 }
234 let cwd = extra
235 .get("cwd")
236 .and_then(|v| v.as_str())
237 .map(|s| s.to_string());
238 let branch = extra
239 .get("git_branch")
240 .and_then(|v| v.as_str())
241 .map(|s| s.to_string());
242 if cwd.is_some() || branch.is_some() {
243 Some(EnvironmentSnapshot {
244 working_dir: cwd,
245 vcs_branch: branch,
246 vcs_revision: None,
247 })
248 } else {
249 None
250 }
251}
252
253fn build_inline_tool_uses(extra: &HashMap<String, serde_json::Value>) -> Vec<ToolInvocation> {
257 let Some(arr) = extra.get("tool_uses").and_then(|v| v.as_array()) else {
258 return Vec::new();
259 };
260 arr.iter()
261 .filter_map(|entry| {
262 let obj = entry.as_object()?;
263 let id = obj.get("id")?.as_str()?.to_string();
264 let name = obj.get("name")?.as_str()?.to_string();
265 let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
266 let category = parse_category(obj.get("category"));
267 let result = obj
268 .get("result")
269 .and_then(|v| serde_json::from_value::<ToolResult>(v.clone()).ok());
270 Some(ToolInvocation {
271 id,
272 name,
273 input,
274 result,
275 category,
276 })
277 })
278 .collect()
279}
280
281fn build_delegations(extra: &HashMap<String, serde_json::Value>) -> Vec<DelegatedWork> {
283 extra
284 .get("delegations")
285 .and_then(|v| serde_json::from_value::<Vec<DelegatedWork>>(v.clone()).ok())
286 .unwrap_or_default()
287}
288
289fn build_turn_extra(
295 extra: &HashMap<String, serde_json::Value>,
296) -> HashMap<String, serde_json::Value> {
297 let mut out: HashMap<String, serde_json::Value> = HashMap::new();
298
299 if let Some(obj) = extra.get("turn_extra").and_then(|v| v.as_object()) {
301 for (k, v) in obj {
302 out.insert(k.clone(), v.clone());
303 }
304 }
305
306 let mut claude_data = serde_json::Map::new();
308 if let Some(v) = extra.get("version") {
309 claude_data.insert("version".to_string(), v.clone());
310 }
311 if let Some(v) = extra.get("user_type") {
312 claude_data.insert("user_type".to_string(), v.clone());
313 }
314 if let Some(v) = extra.get("request_id") {
315 claude_data.insert("request_id".to_string(), v.clone());
316 }
317 if let Some(entry_extra) = extra.get("entry_extra").and_then(|v| v.as_object()) {
318 for (k, v) in entry_extra {
319 claude_data.insert(k.clone(), v.clone());
320 }
321 }
322 if !claude_data.is_empty() {
323 let merged = match out.remove("claude") {
326 Some(serde_json::Value::Object(existing)) => {
327 let mut m = existing;
328 for (k, v) in claude_data {
329 m.entry(k).or_insert(v);
330 }
331 serde_json::Value::Object(m)
332 }
333 _ => serde_json::Value::Object(claude_data),
334 };
335 out.insert("claude".to_string(), merged);
336 }
337
338 out
339}
340
341fn build_token_usage(extra: &HashMap<String, serde_json::Value>) -> Option<TokenUsage> {
342 if let Some(v) = extra.get("token_usage")
344 && let Ok(usage) = serde_json::from_value::<TokenUsage>(v.clone())
345 {
346 return Some(usage);
347 }
348
349 let input = extra
351 .get("input_tokens")
352 .and_then(|v| v.as_u64())
353 .map(|n| n as u32);
354 let output = extra
355 .get("output_tokens")
356 .and_then(|v| v.as_u64())
357 .map(|n| n as u32);
358 let cache_read = extra
359 .get("cache_read_tokens")
360 .and_then(|v| v.as_u64())
361 .map(|n| n as u32);
362 let cache_write = extra
363 .get("cache_write_tokens")
364 .and_then(|v| v.as_u64())
365 .map(|n| n as u32);
366
367 if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() {
368 Some(TokenUsage {
369 input_tokens: input,
370 output_tokens: output,
371 cache_read_tokens: cache_read,
372 cache_write_tokens: cache_write,
373 })
374 } else {
375 None
376 }
377}
378
379fn build_tool_invocation(extra: &HashMap<String, serde_json::Value>) -> ToolInvocation {
380 let id = extra
381 .get("tool_use_id")
382 .and_then(|v| v.as_str())
383 .unwrap_or("")
384 .to_string();
385
386 let name = extra
387 .get("name")
388 .and_then(|v| v.as_str())
389 .unwrap_or("")
390 .to_string();
391
392 let input = extra
393 .get("input")
394 .cloned()
395 .unwrap_or(serde_json::Value::Null);
396
397 let is_error = extra
398 .get("is_error")
399 .and_then(|v| v.as_bool())
400 .unwrap_or(false);
401
402 let result_content = extra.get("result").and_then(|v| v.as_str());
403 let result = result_content.map(|content| ToolResult {
404 content: content.to_string(),
405 is_error,
406 });
407
408 let category = parse_category(extra.get("category"));
409
410 ToolInvocation {
411 id,
412 name,
413 input,
414 result,
415 category,
416 }
417}
418
419fn parse_category(value: Option<&serde_json::Value>) -> Option<ToolCategory> {
420 value
421 .and_then(|v| v.as_str())
422 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
423}
424
425fn parse_role(s: &str) -> Role {
426 match s {
427 "user" => Role::User,
428 "assistant" => Role::Assistant,
429 "system" => Role::System,
430 other => Role::Other(other.to_string()),
431 }
432}
433
434fn model_from_actor(actor: &str) -> Option<String> {
445 let rest = actor.strip_prefix("agent:")?;
446 let model = match rest.split_once('/') {
447 Some((m, _)) => m,
448 None => rest,
449 };
450 if model.is_empty() || model == "unknown" {
451 None
452 } else {
453 Some(model.to_string())
454 }
455}
456
457fn role_from_actor(actor: &str) -> Role {
458 if actor.contains("/tool:") {
459 Role::Other("tool".to_string())
461 } else if actor.starts_with("human:") {
462 Role::User
463 } else if actor.starts_with("agent:") {
464 Role::Assistant
465 } else if actor.starts_with("tool:") {
466 Role::System
467 } else {
468 Role::Other(actor.to_string())
469 }
470}
471
472fn add_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
473 match (a, b) {
474 (Some(x), Some(y)) => Some(x + y),
475 (Some(x), None) => Some(x),
476 (None, Some(y)) => Some(y),
477 (None, None) => None,
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use std::collections::HashMap;
485 use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange};
486
487 #[test]
488 fn test_model_from_actor_variants() {
489 assert_eq!(
490 model_from_actor("agent:claude-opus-4-7"),
491 Some("claude-opus-4-7".to_string())
492 );
493 assert_eq!(
494 model_from_actor("agent:gemini-3-flash-preview"),
495 Some("gemini-3-flash-preview".to_string())
496 );
497 assert_eq!(
499 model_from_actor("agent:claude-code/tool:Write"),
500 Some("claude-code".to_string())
501 );
502 assert_eq!(model_from_actor("agent:unknown"), None);
504 assert_eq!(model_from_actor("human:user"), None);
506 assert_eq!(model_from_actor("system:gemini-cli"), None);
507 assert_eq!(model_from_actor("tool:rustfmt"), None);
508 assert_eq!(model_from_actor(""), None);
510 assert_eq!(model_from_actor("agent:"), None);
511 }
512
513 fn make_path(steps: Vec<Step>) -> Path {
514 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
515 Path {
516 path: PathIdentity {
517 id: "test-path".into(),
518 base: None,
519 head,
520 graph_ref: None,
521 },
522 steps,
523 meta: None,
524 }
525 }
526
527 fn make_step(
528 id: &str,
529 actor: &str,
530 timestamp: &str,
531 parents: Vec<&str>,
532 changes: Vec<(&str, &str, HashMap<String, serde_json::Value>)>,
533 ) -> Step {
534 let mut change = HashMap::new();
535 for (key, change_type, extra) in changes {
536 change.insert(
537 key.to_string(),
538 ArtifactChange {
539 raw: None,
540 structural: Some(StructuralChange {
541 change_type: change_type.to_string(),
542 extra,
543 }),
544 },
545 );
546 }
547 Step {
548 step: toolpath::v1::StepIdentity {
549 id: id.to_string(),
550 parents: parents.into_iter().map(String::from).collect(),
551 actor: actor.to_string(),
552 timestamp: timestamp.to_string(),
553 },
554 change,
555 meta: None,
556 }
557 }
558
559 fn extras(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
560 pairs
561 .iter()
562 .map(|(k, v)| (k.to_string(), v.clone()))
563 .collect()
564 }
565
566 #[test]
567 fn test_empty_path() {
568 let path = make_path(vec![]);
569 let view = extract_conversation(&path);
570 assert!(view.id.is_empty());
571 assert!(view.turns.is_empty());
572 assert!(view.total_usage.is_none());
573 assert!(view.started_at.is_none());
574 assert!(view.last_activity.is_none());
575 assert!(view.files_changed.is_empty());
576 }
577
578 #[test]
579 fn test_init_sets_metadata() {
580 let path = make_path(vec![make_step(
581 "step-001",
582 "tool:claude-code",
583 "2026-01-01T00:00:00Z",
584 vec![],
585 vec![(
586 "agent://claude-code/sess-abc",
587 "conversation.init",
588 extras(&[("version", serde_json::json!("1.0"))]),
589 )],
590 )]);
591
592 let view = extract_conversation(&path);
593 assert_eq!(view.id, "sess-abc");
594 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
595 }
596
597 #[test]
598 fn test_simple_conversation() {
599 let path = make_path(vec![
600 make_step(
601 "step-001",
602 "tool:claude-code",
603 "2026-01-01T00:00:00Z",
604 vec![],
605 vec![(
606 "agent://claude-code/sess-1",
607 "conversation.init",
608 HashMap::new(),
609 )],
610 ),
611 make_step(
612 "step-002",
613 "human:alex",
614 "2026-01-01T00:00:01Z",
615 vec!["step-001"],
616 vec![(
617 "agent://claude-code/sess-1",
618 "conversation.append",
619 extras(&[
620 ("role", serde_json::json!("user")),
621 ("text", serde_json::json!("Fix the bug")),
622 ]),
623 )],
624 ),
625 make_step(
626 "step-003",
627 "agent:claude-opus-4-6",
628 "2026-01-01T00:00:02Z",
629 vec!["step-002"],
630 vec![(
631 "agent://claude-code/sess-1",
632 "conversation.append",
633 extras(&[
634 ("role", serde_json::json!("assistant")),
635 ("text", serde_json::json!("I'll fix that.")),
636 ]),
637 )],
638 ),
639 ]);
640
641 let view = extract_conversation(&path);
642 assert_eq!(view.turns.len(), 2);
643 assert_eq!(view.turns[0].role, Role::User);
644 assert_eq!(view.turns[0].text, "Fix the bug");
645 assert_eq!(view.turns[0].id, "step-002");
646 assert_eq!(view.turns[1].role, Role::Assistant);
647 assert_eq!(view.turns[1].text, "I'll fix that.");
648 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
649 }
650
651 #[test]
652 fn test_tool_invocations_attached_to_parent() {
653 let path = make_path(vec![
654 make_step(
655 "step-001",
656 "agent:claude-opus-4-6",
657 "2026-01-01T00:00:00Z",
658 vec![],
659 vec![(
660 "agent://claude-code/sess-1",
661 "conversation.append",
662 extras(&[
663 ("role", serde_json::json!("assistant")),
664 ("text", serde_json::json!("Let me read the file.")),
665 ]),
666 )],
667 ),
668 make_step(
669 "step-002",
670 "agent:claude-opus-4-6/tool:Read",
671 "2026-01-01T00:00:01Z",
672 vec!["step-001"],
673 vec![(
674 "src/main.rs",
675 "tool.invoke",
676 extras(&[
677 ("tool_use_id", serde_json::json!("tu-001")),
678 ("name", serde_json::json!("Read")),
679 ("input", serde_json::json!({"file_path": "src/main.rs"})),
680 ("result", serde_json::json!("fn main() {}")),
681 ("is_error", serde_json::json!(false)),
682 ("category", serde_json::json!("file_read")),
683 ]),
684 )],
685 ),
686 ]);
687
688 let view = extract_conversation(&path);
689 assert_eq!(view.turns.len(), 1);
690 assert_eq!(view.turns[0].tool_uses.len(), 1);
691 assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
692 assert_eq!(view.turns[0].tool_uses[0].name, "Read");
693 assert_eq!(
694 view.turns[0].tool_uses[0].category,
695 Some(ToolCategory::FileRead)
696 );
697 assert!(view.turns[0].tool_uses[0].result.is_some());
698 assert!(!view.turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
699 }
700
701 #[test]
702 fn test_token_usage_extracted_and_totaled() {
703 let path = make_path(vec![
704 make_step(
705 "step-001",
706 "human:alex",
707 "2026-01-01T00:00:00Z",
708 vec![],
709 vec![(
710 "agent://claude-code/sess-1",
711 "conversation.append",
712 extras(&[
713 ("role", serde_json::json!("user")),
714 ("text", serde_json::json!("hello")),
715 ]),
716 )],
717 ),
718 make_step(
719 "step-002",
720 "agent:claude-opus-4-6",
721 "2026-01-01T00:00:01Z",
722 vec!["step-001"],
723 vec![(
724 "agent://claude-code/sess-1",
725 "conversation.append",
726 extras(&[
727 ("role", serde_json::json!("assistant")),
728 ("text", serde_json::json!("hi")),
729 ("input_tokens", serde_json::json!(100)),
730 ("output_tokens", serde_json::json!(50)),
731 ("cache_read_tokens", serde_json::json!(80)),
732 ]),
733 )],
734 ),
735 make_step(
736 "step-003",
737 "agent:claude-opus-4-6",
738 "2026-01-01T00:00:02Z",
739 vec!["step-002"],
740 vec![(
741 "agent://claude-code/sess-1",
742 "conversation.append",
743 extras(&[
744 ("role", serde_json::json!("assistant")),
745 ("text", serde_json::json!("more")),
746 ("input_tokens", serde_json::json!(200)),
747 ("output_tokens", serde_json::json!(100)),
748 ]),
749 )],
750 ),
751 ]);
752
753 let view = extract_conversation(&path);
754 let total = view.total_usage.as_ref().unwrap();
755 assert_eq!(total.input_tokens, Some(300));
756 assert_eq!(total.output_tokens, Some(150));
757 assert_eq!(total.cache_read_tokens, Some(80));
758 assert!(total.cache_write_tokens.is_none());
759 }
760
761 #[test]
762 fn test_thinking_blocks_extracted() {
763 let path = make_path(vec![make_step(
764 "step-001",
765 "agent:claude-opus-4-6",
766 "2026-01-01T00:00:00Z",
767 vec![],
768 vec![(
769 "agent://claude-code/sess-1",
770 "conversation.append",
771 extras(&[
772 ("role", serde_json::json!("assistant")),
773 ("text", serde_json::json!("The answer is 42.")),
774 (
775 "thinking",
776 serde_json::json!("Let me think about this carefully..."),
777 ),
778 ]),
779 )],
780 )]);
781
782 let view = extract_conversation(&path);
783 assert_eq!(view.turns.len(), 1);
784 assert_eq!(
785 view.turns[0].thinking.as_deref(),
786 Some("Let me think about this carefully...")
787 );
788 }
789
790 #[test]
791 fn test_parent_chain_preserved() {
792 let path = make_path(vec![
793 make_step(
794 "step-001",
795 "human:alex",
796 "2026-01-01T00:00:00Z",
797 vec![],
798 vec![(
799 "agent://claude-code/sess-1",
800 "conversation.append",
801 extras(&[
802 ("role", serde_json::json!("user")),
803 ("text", serde_json::json!("first")),
804 ]),
805 )],
806 ),
807 make_step(
808 "step-002",
809 "agent:claude-opus-4-6",
810 "2026-01-01T00:00:01Z",
811 vec!["step-001"],
812 vec![(
813 "agent://claude-code/sess-1",
814 "conversation.append",
815 extras(&[
816 ("role", serde_json::json!("assistant")),
817 ("text", serde_json::json!("second")),
818 ]),
819 )],
820 ),
821 ]);
822
823 let view = extract_conversation(&path);
824 assert!(view.turns[0].parent_id.is_none());
825 assert_eq!(view.turns[1].parent_id.as_deref(), Some("step-001"));
826 }
827
828 #[test]
829 fn test_unknown_structural_change_skipped() {
830 let path = make_path(vec![
831 make_step(
832 "step-001",
833 "human:alex",
834 "2026-01-01T00:00:00Z",
835 vec![],
836 vec![(
837 "agent://claude-code/sess-1",
838 "conversation.append",
839 extras(&[
840 ("role", serde_json::json!("user")),
841 ("text", serde_json::json!("hello")),
842 ]),
843 )],
844 ),
845 make_step(
846 "step-002",
847 "agent:claude-opus-4-6",
848 "2026-01-01T00:00:01Z",
849 vec!["step-001"],
850 vec![(
851 "agent://claude-code/sess-1",
852 "some.future.type",
853 extras(&[("data", serde_json::json!("whatever"))]),
854 )],
855 ),
856 ]);
857
858 let view = extract_conversation(&path);
859 assert_eq!(view.turns.len(), 1);
861 assert_eq!(view.turns[0].text, "hello");
862 }
863
864 #[test]
865 fn test_role_fallback_from_actor() {
866 let path = make_path(vec![
868 make_step(
869 "step-001",
870 "human:alex",
871 "2026-01-01T00:00:00Z",
872 vec![],
873 vec![(
874 "agent://claude-code/sess-1",
875 "conversation.append",
876 extras(&[("text", serde_json::json!("hello"))]),
877 )],
878 ),
879 make_step(
880 "step-002",
881 "agent:claude-opus-4-6",
882 "2026-01-01T00:00:01Z",
883 vec!["step-001"],
884 vec![(
885 "agent://claude-code/sess-1",
886 "conversation.append",
887 extras(&[("text", serde_json::json!("hi back"))]),
888 )],
889 ),
890 make_step(
891 "step-003",
892 "tool:system-prompt",
893 "2026-01-01T00:00:02Z",
894 vec!["step-002"],
895 vec![(
896 "agent://claude-code/sess-1",
897 "conversation.append",
898 extras(&[("text", serde_json::json!("system message"))]),
899 )],
900 ),
901 ]);
902
903 let view = extract_conversation(&path);
904 assert_eq!(view.turns[0].role, Role::User);
905 assert_eq!(view.turns[1].role, Role::Assistant);
906 assert_eq!(view.turns[2].role, Role::System);
907 }
908
909 #[test]
910 fn test_multiple_tool_invocations_same_turn() {
911 let path = make_path(vec![
912 make_step(
913 "step-001",
914 "agent:claude-opus-4-6",
915 "2026-01-01T00:00:00Z",
916 vec![],
917 vec![(
918 "agent://claude-code/sess-1",
919 "conversation.append",
920 extras(&[
921 ("role", serde_json::json!("assistant")),
922 ("text", serde_json::json!("Let me check two files.")),
923 ]),
924 )],
925 ),
926 make_step(
927 "step-002",
928 "agent:claude-opus-4-6/tool:Read",
929 "2026-01-01T00:00:01Z",
930 vec!["step-001"],
931 vec![(
932 "src/main.rs",
933 "tool.invoke",
934 extras(&[
935 ("tool_use_id", serde_json::json!("tu-001")),
936 ("name", serde_json::json!("Read")),
937 ("input", serde_json::json!({"file_path": "src/main.rs"})),
938 ("result", serde_json::json!("fn main() {}")),
939 ("category", serde_json::json!("file_read")),
940 ]),
941 )],
942 ),
943 make_step(
944 "step-003",
945 "agent:claude-opus-4-6/tool:Read",
946 "2026-01-01T00:00:02Z",
947 vec!["step-001"],
948 vec![(
949 "src/lib.rs",
950 "tool.invoke",
951 extras(&[
952 ("tool_use_id", serde_json::json!("tu-002")),
953 ("name", serde_json::json!("Read")),
954 ("input", serde_json::json!({"file_path": "src/lib.rs"})),
955 ("result", serde_json::json!("pub mod foo;")),
956 ("category", serde_json::json!("file_read")),
957 ]),
958 )],
959 ),
960 ]);
961
962 let view = extract_conversation(&path);
963 assert_eq!(view.turns.len(), 1);
964 assert_eq!(view.turns[0].tool_uses.len(), 2);
965 assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
966 assert_eq!(view.turns[0].tool_uses[1].id, "tu-002");
967 }
968
969 #[test]
970 fn test_files_changed_from_file_write_tools() {
971 let path = make_path(vec![
972 make_step(
973 "step-001",
974 "agent:claude-opus-4-6",
975 "2026-01-01T00:00:00Z",
976 vec![],
977 vec![(
978 "agent://claude-code/sess-1",
979 "conversation.append",
980 extras(&[
981 ("role", serde_json::json!("assistant")),
982 ("text", serde_json::json!("Writing files.")),
983 ]),
984 )],
985 ),
986 make_step(
987 "step-002",
988 "agent:claude-opus-4-6/tool:Edit",
989 "2026-01-01T00:00:01Z",
990 vec!["step-001"],
991 vec![(
992 "src/main.rs",
993 "tool.invoke",
994 extras(&[
995 ("tool_use_id", serde_json::json!("tu-001")),
996 ("name", serde_json::json!("Edit")),
997 ("input", serde_json::json!({})),
998 ("category", serde_json::json!("file_write")),
999 ]),
1000 )],
1001 ),
1002 make_step(
1003 "step-003",
1004 "agent:claude-opus-4-6/tool:Edit",
1005 "2026-01-01T00:00:02Z",
1006 vec!["step-001"],
1007 vec![(
1008 "src/main.rs",
1009 "tool.invoke",
1010 extras(&[
1011 ("tool_use_id", serde_json::json!("tu-002")),
1012 ("name", serde_json::json!("Edit")),
1013 ("input", serde_json::json!({})),
1014 ("category", serde_json::json!("file_write")),
1015 ]),
1016 )],
1017 ),
1018 ]);
1019
1020 let view = extract_conversation(&path);
1021 assert_eq!(view.files_changed, vec!["src/main.rs"]);
1023 }
1024
1025 #[test]
1026 fn test_timestamps_parsed() {
1027 let path = make_path(vec![
1028 make_step(
1029 "step-001",
1030 "human:alex",
1031 "2026-01-01T10:00:00Z",
1032 vec![],
1033 vec![(
1034 "agent://claude-code/sess-1",
1035 "conversation.append",
1036 extras(&[
1037 ("role", serde_json::json!("user")),
1038 ("text", serde_json::json!("hello")),
1039 ]),
1040 )],
1041 ),
1042 make_step(
1043 "step-002",
1044 "agent:claude-opus-4-6",
1045 "2026-01-01T10:05:00Z",
1046 vec!["step-001"],
1047 vec![(
1048 "agent://claude-code/sess-1",
1049 "conversation.append",
1050 extras(&[
1051 ("role", serde_json::json!("assistant")),
1052 ("text", serde_json::json!("hi")),
1053 ]),
1054 )],
1055 ),
1056 ]);
1057
1058 let view = extract_conversation(&path);
1059 assert!(view.started_at.is_some());
1060 assert!(view.last_activity.is_some());
1061 assert!(view.last_activity.unwrap() > view.started_at.unwrap());
1062 }
1063
1064 #[test]
1065 fn test_steps_without_structural_changes_skipped() {
1066 let path = make_path(vec![make_step(
1067 "step-001",
1068 "human:alex",
1069 "2026-01-01T00:00:00Z",
1070 vec![],
1071 vec![], )]);
1073
1074 let view = extract_conversation(&path);
1075 assert!(view.turns.is_empty());
1076 }
1077
1078 #[test]
1079 fn test_environment_from_cwd_and_git_branch() {
1080 let path = make_path(vec![make_step(
1081 "step-001",
1082 "human:alex",
1083 "2026-01-01T00:00:00Z",
1084 vec![],
1085 vec![(
1086 "agent://claude-code/sess-1",
1087 "conversation.append",
1088 extras(&[
1089 ("role", serde_json::json!("user")),
1090 ("text", serde_json::json!("hello")),
1091 ("cwd", serde_json::json!("/home/alex/project")),
1092 ("git_branch", serde_json::json!("feature/cool")),
1093 ]),
1094 )],
1095 )]);
1096
1097 let view = extract_conversation(&path);
1098 let env = view.turns[0].environment.as_ref().unwrap();
1099 assert_eq!(env.working_dir.as_deref(), Some("/home/alex/project"));
1100 assert_eq!(env.vcs_branch.as_deref(), Some("feature/cool"));
1101 assert!(env.vcs_revision.is_none());
1102 }
1103
1104 #[test]
1105 fn test_environment_none_when_absent() {
1106 let path = make_path(vec![make_step(
1107 "step-001",
1108 "human:alex",
1109 "2026-01-01T00:00:00Z",
1110 vec![],
1111 vec![(
1112 "agent://claude-code/sess-1",
1113 "conversation.append",
1114 extras(&[
1115 ("role", serde_json::json!("user")),
1116 ("text", serde_json::json!("hello")),
1117 ]),
1118 )],
1119 )]);
1120
1121 let view = extract_conversation(&path);
1122 assert!(view.turns[0].environment.is_none());
1123 }
1124
1125 #[test]
1126 fn test_extra_claude_metadata() {
1127 let path = make_path(vec![make_step(
1128 "step-001",
1129 "agent:claude-opus-4-6",
1130 "2026-01-01T00:00:00Z",
1131 vec![],
1132 vec![(
1133 "agent://claude-code/sess-1",
1134 "conversation.append",
1135 extras(&[
1136 ("role", serde_json::json!("assistant")),
1137 ("text", serde_json::json!("hi")),
1138 ("version", serde_json::json!("1.0.30")),
1139 ("user_type", serde_json::json!("pro")),
1140 ("request_id", serde_json::json!("req-abc-123")),
1141 ]),
1142 )],
1143 )]);
1144
1145 let view = extract_conversation(&path);
1146 let claude = view.turns[0].extra.get("claude").unwrap();
1147 assert_eq!(claude["version"], serde_json::json!("1.0.30"));
1148 assert_eq!(claude["user_type"], serde_json::json!("pro"));
1149 assert_eq!(claude["request_id"], serde_json::json!("req-abc-123"));
1150 }
1151
1152 #[test]
1153 fn test_entry_extra_merged_into_claude() {
1154 let path = make_path(vec![make_step(
1155 "step-001",
1156 "agent:claude-opus-4-6",
1157 "2026-01-01T00:00:00Z",
1158 vec![],
1159 vec![(
1160 "agent://claude-code/sess-1",
1161 "conversation.append",
1162 extras(&[
1163 ("role", serde_json::json!("assistant")),
1164 ("text", serde_json::json!("hi")),
1165 (
1166 "entry_extra",
1167 serde_json::json!({
1168 "entrypoint": "cli",
1169 "isMeta": true,
1170 "slug": "my-project"
1171 }),
1172 ),
1173 ]),
1174 )],
1175 )]);
1176
1177 let view = extract_conversation(&path);
1178 let claude = view.turns[0].extra.get("claude").unwrap();
1179 assert_eq!(claude["entrypoint"], serde_json::json!("cli"));
1180 assert_eq!(claude["isMeta"], serde_json::json!(true));
1181 assert_eq!(claude["slug"], serde_json::json!("my-project"));
1182 }
1183
1184 #[test]
1185 fn test_extra_empty_when_no_metadata() {
1186 let path = make_path(vec![make_step(
1187 "step-001",
1188 "human:alex",
1189 "2026-01-01T00:00:00Z",
1190 vec![],
1191 vec![(
1192 "agent://claude-code/sess-1",
1193 "conversation.append",
1194 extras(&[
1195 ("role", serde_json::json!("user")),
1196 ("text", serde_json::json!("hello")),
1197 ]),
1198 )],
1199 )]);
1200
1201 let view = extract_conversation(&path);
1202 assert!(view.turns[0].extra.is_empty());
1203 }
1204
1205 #[test]
1206 fn test_agent_url_tool_not_in_files_changed() {
1207 let path = make_path(vec![
1208 make_step(
1209 "step-001",
1210 "agent:claude-opus-4-6",
1211 "2026-01-01T00:00:00Z",
1212 vec![],
1213 vec![(
1214 "agent://claude-code/sess-1",
1215 "conversation.append",
1216 extras(&[
1217 ("role", serde_json::json!("assistant")),
1218 ("text", serde_json::json!("Searching...")),
1219 ]),
1220 )],
1221 ),
1222 make_step(
1223 "step-002",
1224 "agent:claude-opus-4-6/tool:WebSearch",
1225 "2026-01-01T00:00:01Z",
1226 vec!["step-001"],
1227 vec![(
1228 "agent://claude-code/sess-1/tool/network/tu-001",
1229 "tool.invoke",
1230 extras(&[
1231 ("tool_use_id", serde_json::json!("tu-001")),
1232 ("name", serde_json::json!("WebSearch")),
1233 ("input", serde_json::json!({"query": "rust async"})),
1234 ("category", serde_json::json!("file_write")),
1235 ]),
1236 )],
1237 ),
1238 ]);
1239
1240 let view = extract_conversation(&path);
1241 assert!(view.files_changed.is_empty());
1243 }
1244
1245 #[test]
1246 fn test_conversation_event_extracted() {
1247 let path = make_path(vec![
1248 make_step(
1249 "step-001",
1250 "tool:claude-code",
1251 "2026-01-01T00:00:00Z",
1252 vec![],
1253 vec![(
1254 "agent://claude-code/sess-1",
1255 "conversation.event",
1256 extras(&[
1257 ("entry_type", serde_json::json!("attachment")),
1258 ("cwd", serde_json::json!("/home/alex/project")),
1259 ("version", serde_json::json!("1.0.30")),
1260 (
1261 "entry_extra",
1262 serde_json::json!({"attachment": {"fileName": "test.png"}}),
1263 ),
1264 ]),
1265 )],
1266 ),
1267 make_step(
1268 "step-002",
1269 "tool:claude-code",
1270 "2026-01-01T00:00:01Z",
1271 vec!["step-001"],
1272 vec![(
1273 "agent://claude-code/sess-1",
1274 "conversation.event",
1275 extras(&[
1276 ("entry_type", serde_json::json!("file-history-snapshot")),
1277 ("snapshot", serde_json::json!({"files": []})),
1278 ]),
1279 )],
1280 ),
1281 ]);
1282
1283 let view = extract_conversation(&path);
1284 assert!(view.turns.is_empty());
1285 assert_eq!(view.events.len(), 2);
1286
1287 assert_eq!(view.events[0].id, "step-001");
1288 assert_eq!(view.events[0].event_type, "attachment");
1289 assert_eq!(
1290 view.events[0].data["cwd"],
1291 serde_json::json!("/home/alex/project")
1292 );
1293 assert_eq!(view.events[0].data["version"], serde_json::json!("1.0.30"));
1294 assert!(view.events[0].parent_id.is_none());
1295
1296 assert_eq!(view.events[1].id, "step-002");
1297 assert_eq!(view.events[1].event_type, "file-history-snapshot");
1298 assert_eq!(view.events[1].parent_id.as_deref(), Some("step-001"));
1299 assert!(view.events[1].data.contains_key("snapshot"));
1300 }
1301
1302 #[test]
1303 fn test_conversation_event_with_unknown_type() {
1304 let path = make_path(vec![make_step(
1305 "step-001",
1306 "tool:claude-code",
1307 "2026-01-01T00:00:00Z",
1308 vec![],
1309 vec![(
1310 "agent://claude-code/sess-1",
1311 "conversation.event",
1312 extras(&[("cwd", serde_json::json!("/tmp"))]),
1313 )],
1314 )]);
1315
1316 let view = extract_conversation(&path);
1317 assert_eq!(view.events.len(), 1);
1318 assert_eq!(view.events[0].event_type, "unknown");
1319 }
1320
1321 #[test]
1322 fn test_conversation_event_mixed_with_turns() {
1323 let path = make_path(vec![
1324 make_step(
1325 "step-001",
1326 "tool:claude-code",
1327 "2026-01-01T00:00:00Z",
1328 vec![],
1329 vec![(
1330 "agent://claude-code/sess-1",
1331 "conversation.event",
1332 extras(&[("entry_type", serde_json::json!("system"))]),
1333 )],
1334 ),
1335 make_step(
1336 "step-002",
1337 "human:alex",
1338 "2026-01-01T00:00:01Z",
1339 vec!["step-001"],
1340 vec![(
1341 "agent://claude-code/sess-1",
1342 "conversation.append",
1343 extras(&[
1344 ("role", serde_json::json!("user")),
1345 ("text", serde_json::json!("hello")),
1346 ]),
1347 )],
1348 ),
1349 ]);
1350
1351 let view = extract_conversation(&path);
1352 assert_eq!(view.turns.len(), 1);
1353 assert_eq!(view.events.len(), 1);
1354 assert_eq!(view.turns[0].text, "hello");
1355 assert_eq!(view.events[0].event_type, "system");
1356 }
1357}