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