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 let group_id = extra
310 .get("group_id")
311 .and_then(|v| v.as_str())
312 .map(|s| s.to_string());
313
314 let attributed_token_usage = extra
315 .get("attributed_token_usage")
316 .and_then(|v| serde_json::from_value::<TokenUsage>(v.clone()).ok());
317
318 Turn {
319 id: step.step.id.clone(),
320 parent_id,
321 group_id,
322 role,
323 timestamp: step.step.timestamp.clone(),
324 text,
325 thinking,
326 tool_uses,
327 model,
328 stop_reason,
329 token_usage,
330 attributed_token_usage,
331 environment,
332 delegations,
333 file_mutations: Vec::new(),
334 }
335}
336
337fn build_environment(extra: &HashMap<String, serde_json::Value>) -> Option<EnvironmentSnapshot> {
341 if let Some(v) = extra.get("environment")
342 && let Ok(env) = serde_json::from_value::<EnvironmentSnapshot>(v.clone())
343 {
344 return Some(env);
345 }
346 let cwd = extra
347 .get("cwd")
348 .and_then(|v| v.as_str())
349 .map(|s| s.to_string());
350 let branch = extra
351 .get("git_branch")
352 .and_then(|v| v.as_str())
353 .map(|s| s.to_string());
354 if cwd.is_some() || branch.is_some() {
355 Some(EnvironmentSnapshot {
356 working_dir: cwd,
357 vcs_branch: branch,
358 vcs_revision: None,
359 })
360 } else {
361 None
362 }
363}
364
365fn build_inline_tool_uses(extra: &HashMap<String, serde_json::Value>) -> Vec<ToolInvocation> {
369 let Some(arr) = extra.get("tool_uses").and_then(|v| v.as_array()) else {
370 return Vec::new();
371 };
372 arr.iter()
373 .filter_map(|entry| {
374 let obj = entry.as_object()?;
375 let id = obj.get("id")?.as_str()?.to_string();
376 let name = obj.get("name")?.as_str()?.to_string();
377 let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
378 let category = parse_category(obj.get("category"));
379 let result = obj
380 .get("result")
381 .and_then(|v| serde_json::from_value::<ToolResult>(v.clone()).ok());
382 Some(ToolInvocation {
383 id,
384 name,
385 input,
386 result,
387 category,
388 })
389 })
390 .collect()
391}
392
393fn build_delegations(extra: &HashMap<String, serde_json::Value>) -> Vec<DelegatedWork> {
395 extra
396 .get("delegations")
397 .and_then(|v| serde_json::from_value::<Vec<DelegatedWork>>(v.clone()).ok())
398 .unwrap_or_default()
399}
400
401fn build_token_usage(extra: &HashMap<String, serde_json::Value>) -> Option<TokenUsage> {
402 if let Some(v) = extra.get("token_usage")
404 && let Ok(usage) = serde_json::from_value::<TokenUsage>(v.clone())
405 {
406 return Some(usage);
407 }
408
409 let input = extra
411 .get("input_tokens")
412 .and_then(|v| v.as_u64())
413 .map(|n| n as u32);
414 let output = extra
415 .get("output_tokens")
416 .and_then(|v| v.as_u64())
417 .map(|n| n as u32);
418 let cache_read = extra
419 .get("cache_read_tokens")
420 .and_then(|v| v.as_u64())
421 .map(|n| n as u32);
422 let cache_write = extra
423 .get("cache_write_tokens")
424 .and_then(|v| v.as_u64())
425 .map(|n| n as u32);
426
427 if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() {
428 Some(TokenUsage {
429 input_tokens: input,
430 output_tokens: output,
431 cache_read_tokens: cache_read,
432 cache_write_tokens: cache_write,
433 ..Default::default()
434 })
435 } else {
436 None
437 }
438}
439
440fn build_tool_invocation(extra: &HashMap<String, serde_json::Value>) -> ToolInvocation {
441 let id = extra
442 .get("tool_use_id")
443 .and_then(|v| v.as_str())
444 .unwrap_or("")
445 .to_string();
446
447 let name = extra
448 .get("name")
449 .and_then(|v| v.as_str())
450 .unwrap_or("")
451 .to_string();
452
453 let input = extra
454 .get("input")
455 .cloned()
456 .unwrap_or(serde_json::Value::Null);
457
458 let is_error = extra
459 .get("is_error")
460 .and_then(|v| v.as_bool())
461 .unwrap_or(false);
462
463 let result_content = extra.get("result").and_then(|v| v.as_str());
464 let result = result_content.map(|content| ToolResult {
465 content: content.to_string(),
466 is_error,
467 });
468
469 let category = parse_category(extra.get("category"));
470
471 ToolInvocation {
472 id,
473 name,
474 input,
475 result,
476 category,
477 }
478}
479
480fn parse_category(value: Option<&serde_json::Value>) -> Option<ToolCategory> {
481 value
482 .and_then(|v| v.as_str())
483 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
484}
485
486fn parse_role(s: &str) -> Role {
487 match s {
488 "user" => Role::User,
489 "assistant" => Role::Assistant,
490 "system" => Role::System,
491 other => Role::Other(other.to_string()),
492 }
493}
494
495fn model_from_actor(actor: &str) -> Option<String> {
506 let rest = actor.strip_prefix("agent:")?;
507 let model = match rest.split_once('/') {
508 Some((m, _)) => m,
509 None => rest,
510 };
511 if model.is_empty() || model == "unknown" {
512 None
513 } else {
514 Some(model.to_string())
515 }
516}
517
518fn role_from_actor(actor: &str) -> Role {
519 if actor.contains("/tool:") {
520 Role::Other("tool".to_string())
522 } else if actor.starts_with("human:") {
523 Role::User
524 } else if actor.starts_with("agent:") {
525 Role::Assistant
526 } else if actor.starts_with("tool:") {
527 Role::System
528 } else {
529 Role::Other(actor.to_string())
530 }
531}
532
533fn add_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
534 match (a, b) {
535 (Some(x), Some(y)) => Some(x + y),
536 (Some(x), None) => Some(x),
537 (None, Some(y)) => Some(y),
538 (None, None) => None,
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use std::collections::HashMap;
546 use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange};
547
548 #[test]
549 fn test_model_from_actor_variants() {
550 assert_eq!(
551 model_from_actor("agent:claude-opus-4-7"),
552 Some("claude-opus-4-7".to_string())
553 );
554 assert_eq!(
555 model_from_actor("agent:gemini-3-flash-preview"),
556 Some("gemini-3-flash-preview".to_string())
557 );
558 assert_eq!(
560 model_from_actor("agent:claude-code/tool:Write"),
561 Some("claude-code".to_string())
562 );
563 assert_eq!(model_from_actor("agent:unknown"), None);
565 assert_eq!(model_from_actor("human:user"), None);
567 assert_eq!(model_from_actor("system:gemini-cli"), None);
568 assert_eq!(model_from_actor("tool:rustfmt"), None);
569 assert_eq!(model_from_actor(""), None);
571 assert_eq!(model_from_actor("agent:"), None);
572 }
573
574 fn make_path(steps: Vec<Step>) -> Path {
575 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
576 Path {
577 path: PathIdentity {
578 id: "test-path".into(),
579 base: None,
580 head,
581 graph_ref: None,
582 },
583 steps,
584 meta: None,
585 }
586 }
587
588 fn make_step(
589 id: &str,
590 actor: &str,
591 timestamp: &str,
592 parents: Vec<&str>,
593 changes: Vec<(&str, &str, HashMap<String, serde_json::Value>)>,
594 ) -> Step {
595 let mut change = HashMap::new();
596 for (key, change_type, extra) in changes {
597 change.insert(
598 key.to_string(),
599 ArtifactChange {
600 raw: None,
601 structural: Some(StructuralChange {
602 change_type: change_type.to_string(),
603 extra,
604 }),
605 },
606 );
607 }
608 Step {
609 step: toolpath::v1::StepIdentity {
610 id: id.to_string(),
611 parents: parents.into_iter().map(String::from).collect(),
612 actor: actor.to_string(),
613 timestamp: timestamp.to_string(),
614 },
615 change,
616 meta: None,
617 }
618 }
619
620 fn extras(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
621 pairs
622 .iter()
623 .map(|(k, v)| (k.to_string(), v.clone()))
624 .collect()
625 }
626
627 #[test]
628 fn test_empty_path() {
629 let path = make_path(vec![]);
630 let view = extract_conversation(&path);
631 assert!(view.id.is_empty());
632 assert!(view.turns.is_empty());
633 assert!(view.total_usage.is_none());
634 assert!(view.started_at.is_none());
635 assert!(view.last_activity.is_none());
636 assert!(view.files_changed.is_empty());
637 }
638
639 #[test]
640 fn test_init_sets_metadata() {
641 let path = make_path(vec![make_step(
642 "step-001",
643 "tool:claude-code",
644 "2026-01-01T00:00:00Z",
645 vec![],
646 vec![(
647 "agent://claude-code/sess-abc",
648 "conversation.init",
649 extras(&[("version", serde_json::json!("1.0"))]),
650 )],
651 )]);
652
653 let view = extract_conversation(&path);
654 assert_eq!(view.id, "sess-abc");
655 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
656 }
657
658 #[test]
659 fn test_simple_conversation() {
660 let path = make_path(vec![
661 make_step(
662 "step-001",
663 "tool:claude-code",
664 "2026-01-01T00:00:00Z",
665 vec![],
666 vec![(
667 "agent://claude-code/sess-1",
668 "conversation.init",
669 HashMap::new(),
670 )],
671 ),
672 make_step(
673 "step-002",
674 "human:alex",
675 "2026-01-01T00:00:01Z",
676 vec!["step-001"],
677 vec![(
678 "agent://claude-code/sess-1",
679 "conversation.append",
680 extras(&[
681 ("role", serde_json::json!("user")),
682 ("text", serde_json::json!("Fix the bug")),
683 ]),
684 )],
685 ),
686 make_step(
687 "step-003",
688 "agent:claude-opus-4-6",
689 "2026-01-01T00:00:02Z",
690 vec!["step-002"],
691 vec![(
692 "agent://claude-code/sess-1",
693 "conversation.append",
694 extras(&[
695 ("role", serde_json::json!("assistant")),
696 ("text", serde_json::json!("I'll fix that.")),
697 ]),
698 )],
699 ),
700 ]);
701
702 let view = extract_conversation(&path);
703 assert_eq!(view.turns.len(), 2);
704 assert_eq!(view.turns[0].role, Role::User);
705 assert_eq!(view.turns[0].text, "Fix the bug");
706 assert_eq!(view.turns[0].id, "step-002");
707 assert_eq!(view.turns[1].role, Role::Assistant);
708 assert_eq!(view.turns[1].text, "I'll fix that.");
709 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
710 }
711
712 #[test]
713 fn test_group_id_round_trips_through_extraction() {
714 let path = make_path(vec![make_step(
715 "step-001",
716 "agent:claude-opus-4-6",
717 "2026-01-01T00:00:00Z",
718 vec![],
719 vec![(
720 "claude-code://sess-1",
721 "conversation.append",
722 extras(&[
723 ("role", serde_json::json!("assistant")),
724 ("text", serde_json::json!("")),
725 ("group_id", serde_json::json!("msg_01abc")),
726 ]),
727 )],
728 )]);
729
730 let view = extract_conversation(&path);
731 assert_eq!(view.turns[0].group_id.as_deref(), Some("msg_01abc"));
732 }
733
734 #[test]
735 fn test_tool_invocations_attached_to_parent() {
736 let path = make_path(vec![
737 make_step(
738 "step-001",
739 "agent:claude-opus-4-6",
740 "2026-01-01T00:00:00Z",
741 vec![],
742 vec![(
743 "agent://claude-code/sess-1",
744 "conversation.append",
745 extras(&[
746 ("role", serde_json::json!("assistant")),
747 ("text", serde_json::json!("Let me read the file.")),
748 ]),
749 )],
750 ),
751 make_step(
752 "step-002",
753 "agent:claude-opus-4-6/tool:Read",
754 "2026-01-01T00:00:01Z",
755 vec!["step-001"],
756 vec![(
757 "src/main.rs",
758 "tool.invoke",
759 extras(&[
760 ("tool_use_id", serde_json::json!("tu-001")),
761 ("name", serde_json::json!("Read")),
762 ("input", serde_json::json!({"file_path": "src/main.rs"})),
763 ("result", serde_json::json!("fn main() {}")),
764 ("is_error", serde_json::json!(false)),
765 ("category", serde_json::json!("file_read")),
766 ]),
767 )],
768 ),
769 ]);
770
771 let view = extract_conversation(&path);
772 assert_eq!(view.turns.len(), 1);
773 assert_eq!(view.turns[0].tool_uses.len(), 1);
774 assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
775 assert_eq!(view.turns[0].tool_uses[0].name, "Read");
776 assert_eq!(
777 view.turns[0].tool_uses[0].category,
778 Some(ToolCategory::FileRead)
779 );
780 assert!(view.turns[0].tool_uses[0].result.is_some());
781 assert!(!view.turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
782 }
783
784 #[test]
785 fn test_token_usage_extracted_and_totaled() {
786 let path = make_path(vec![
787 make_step(
788 "step-001",
789 "human:alex",
790 "2026-01-01T00:00:00Z",
791 vec![],
792 vec![(
793 "agent://claude-code/sess-1",
794 "conversation.append",
795 extras(&[
796 ("role", serde_json::json!("user")),
797 ("text", serde_json::json!("hello")),
798 ]),
799 )],
800 ),
801 make_step(
802 "step-002",
803 "agent:claude-opus-4-6",
804 "2026-01-01T00:00:01Z",
805 vec!["step-001"],
806 vec![(
807 "agent://claude-code/sess-1",
808 "conversation.append",
809 extras(&[
810 ("role", serde_json::json!("assistant")),
811 ("text", serde_json::json!("hi")),
812 ("input_tokens", serde_json::json!(100)),
813 ("output_tokens", serde_json::json!(50)),
814 ("cache_read_tokens", serde_json::json!(80)),
815 ]),
816 )],
817 ),
818 make_step(
819 "step-003",
820 "agent:claude-opus-4-6",
821 "2026-01-01T00:00:02Z",
822 vec!["step-002"],
823 vec![(
824 "agent://claude-code/sess-1",
825 "conversation.append",
826 extras(&[
827 ("role", serde_json::json!("assistant")),
828 ("text", serde_json::json!("more")),
829 ("input_tokens", serde_json::json!(200)),
830 ("output_tokens", serde_json::json!(100)),
831 ]),
832 )],
833 ),
834 ]);
835
836 let view = extract_conversation(&path);
837 let total = view.total_usage.as_ref().unwrap();
838 assert_eq!(total.input_tokens, Some(300));
839 assert_eq!(total.output_tokens, Some(150));
840 assert_eq!(total.cache_read_tokens, Some(80));
841 assert!(total.cache_write_tokens.is_none());
842 }
843
844 #[test]
845 fn test_thinking_blocks_extracted() {
846 let path = make_path(vec![make_step(
847 "step-001",
848 "agent:claude-opus-4-6",
849 "2026-01-01T00:00:00Z",
850 vec![],
851 vec![(
852 "agent://claude-code/sess-1",
853 "conversation.append",
854 extras(&[
855 ("role", serde_json::json!("assistant")),
856 ("text", serde_json::json!("The answer is 42.")),
857 (
858 "thinking",
859 serde_json::json!("Let me think about this carefully..."),
860 ),
861 ]),
862 )],
863 )]);
864
865 let view = extract_conversation(&path);
866 assert_eq!(view.turns.len(), 1);
867 assert_eq!(
868 view.turns[0].thinking.as_deref(),
869 Some("Let me think about this carefully...")
870 );
871 }
872
873 #[test]
874 fn test_parent_chain_preserved() {
875 let path = make_path(vec![
876 make_step(
877 "step-001",
878 "human:alex",
879 "2026-01-01T00:00:00Z",
880 vec![],
881 vec![(
882 "agent://claude-code/sess-1",
883 "conversation.append",
884 extras(&[
885 ("role", serde_json::json!("user")),
886 ("text", serde_json::json!("first")),
887 ]),
888 )],
889 ),
890 make_step(
891 "step-002",
892 "agent:claude-opus-4-6",
893 "2026-01-01T00:00:01Z",
894 vec!["step-001"],
895 vec![(
896 "agent://claude-code/sess-1",
897 "conversation.append",
898 extras(&[
899 ("role", serde_json::json!("assistant")),
900 ("text", serde_json::json!("second")),
901 ]),
902 )],
903 ),
904 ]);
905
906 let view = extract_conversation(&path);
907 assert!(view.turns[0].parent_id.is_none());
908 assert_eq!(view.turns[1].parent_id.as_deref(), Some("step-001"));
909 }
910
911 #[test]
912 fn test_unknown_structural_change_skipped() {
913 let path = make_path(vec![
914 make_step(
915 "step-001",
916 "human:alex",
917 "2026-01-01T00:00:00Z",
918 vec![],
919 vec![(
920 "agent://claude-code/sess-1",
921 "conversation.append",
922 extras(&[
923 ("role", serde_json::json!("user")),
924 ("text", serde_json::json!("hello")),
925 ]),
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 "some.future.type",
936 extras(&[("data", serde_json::json!("whatever"))]),
937 )],
938 ),
939 ]);
940
941 let view = extract_conversation(&path);
942 assert_eq!(view.turns.len(), 1);
944 assert_eq!(view.turns[0].text, "hello");
945 }
946
947 #[test]
948 fn test_role_fallback_from_actor() {
949 let path = make_path(vec![
951 make_step(
952 "step-001",
953 "human:alex",
954 "2026-01-01T00:00:00Z",
955 vec![],
956 vec![(
957 "agent://claude-code/sess-1",
958 "conversation.append",
959 extras(&[("text", serde_json::json!("hello"))]),
960 )],
961 ),
962 make_step(
963 "step-002",
964 "agent:claude-opus-4-6",
965 "2026-01-01T00:00:01Z",
966 vec!["step-001"],
967 vec![(
968 "agent://claude-code/sess-1",
969 "conversation.append",
970 extras(&[("text", serde_json::json!("hi back"))]),
971 )],
972 ),
973 make_step(
974 "step-003",
975 "tool:system-prompt",
976 "2026-01-01T00:00:02Z",
977 vec!["step-002"],
978 vec![(
979 "agent://claude-code/sess-1",
980 "conversation.append",
981 extras(&[("text", serde_json::json!("system message"))]),
982 )],
983 ),
984 ]);
985
986 let view = extract_conversation(&path);
987 assert_eq!(view.turns[0].role, Role::User);
988 assert_eq!(view.turns[1].role, Role::Assistant);
989 assert_eq!(view.turns[2].role, Role::System);
990 }
991
992 #[test]
993 fn test_multiple_tool_invocations_same_turn() {
994 let path = make_path(vec![
995 make_step(
996 "step-001",
997 "agent:claude-opus-4-6",
998 "2026-01-01T00:00:00Z",
999 vec![],
1000 vec![(
1001 "agent://claude-code/sess-1",
1002 "conversation.append",
1003 extras(&[
1004 ("role", serde_json::json!("assistant")),
1005 ("text", serde_json::json!("Let me check two files.")),
1006 ]),
1007 )],
1008 ),
1009 make_step(
1010 "step-002",
1011 "agent:claude-opus-4-6/tool:Read",
1012 "2026-01-01T00:00:01Z",
1013 vec!["step-001"],
1014 vec![(
1015 "src/main.rs",
1016 "tool.invoke",
1017 extras(&[
1018 ("tool_use_id", serde_json::json!("tu-001")),
1019 ("name", serde_json::json!("Read")),
1020 ("input", serde_json::json!({"file_path": "src/main.rs"})),
1021 ("result", serde_json::json!("fn main() {}")),
1022 ("category", serde_json::json!("file_read")),
1023 ]),
1024 )],
1025 ),
1026 make_step(
1027 "step-003",
1028 "agent:claude-opus-4-6/tool:Read",
1029 "2026-01-01T00:00:02Z",
1030 vec!["step-001"],
1031 vec![(
1032 "src/lib.rs",
1033 "tool.invoke",
1034 extras(&[
1035 ("tool_use_id", serde_json::json!("tu-002")),
1036 ("name", serde_json::json!("Read")),
1037 ("input", serde_json::json!({"file_path": "src/lib.rs"})),
1038 ("result", serde_json::json!("pub mod foo;")),
1039 ("category", serde_json::json!("file_read")),
1040 ]),
1041 )],
1042 ),
1043 ]);
1044
1045 let view = extract_conversation(&path);
1046 assert_eq!(view.turns.len(), 1);
1047 assert_eq!(view.turns[0].tool_uses.len(), 2);
1048 assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
1049 assert_eq!(view.turns[0].tool_uses[1].id, "tu-002");
1050 }
1051
1052 #[test]
1053 fn test_files_changed_from_file_write_tools() {
1054 let path = make_path(vec![
1055 make_step(
1056 "step-001",
1057 "agent:claude-opus-4-6",
1058 "2026-01-01T00:00:00Z",
1059 vec![],
1060 vec![(
1061 "agent://claude-code/sess-1",
1062 "conversation.append",
1063 extras(&[
1064 ("role", serde_json::json!("assistant")),
1065 ("text", serde_json::json!("Writing files.")),
1066 ]),
1067 )],
1068 ),
1069 make_step(
1070 "step-002",
1071 "agent:claude-opus-4-6/tool:Edit",
1072 "2026-01-01T00:00:01Z",
1073 vec!["step-001"],
1074 vec![(
1075 "src/main.rs",
1076 "tool.invoke",
1077 extras(&[
1078 ("tool_use_id", serde_json::json!("tu-001")),
1079 ("name", serde_json::json!("Edit")),
1080 ("input", serde_json::json!({})),
1081 ("category", serde_json::json!("file_write")),
1082 ]),
1083 )],
1084 ),
1085 make_step(
1086 "step-003",
1087 "agent:claude-opus-4-6/tool:Edit",
1088 "2026-01-01T00:00:02Z",
1089 vec!["step-001"],
1090 vec![(
1091 "src/main.rs",
1092 "tool.invoke",
1093 extras(&[
1094 ("tool_use_id", serde_json::json!("tu-002")),
1095 ("name", serde_json::json!("Edit")),
1096 ("input", serde_json::json!({})),
1097 ("category", serde_json::json!("file_write")),
1098 ]),
1099 )],
1100 ),
1101 ]);
1102
1103 let view = extract_conversation(&path);
1104 assert_eq!(view.files_changed, vec!["src/main.rs"]);
1106 }
1107
1108 #[test]
1109 fn test_timestamps_parsed() {
1110 let path = make_path(vec![
1111 make_step(
1112 "step-001",
1113 "human:alex",
1114 "2026-01-01T10:00:00Z",
1115 vec![],
1116 vec![(
1117 "agent://claude-code/sess-1",
1118 "conversation.append",
1119 extras(&[
1120 ("role", serde_json::json!("user")),
1121 ("text", serde_json::json!("hello")),
1122 ]),
1123 )],
1124 ),
1125 make_step(
1126 "step-002",
1127 "agent:claude-opus-4-6",
1128 "2026-01-01T10:05:00Z",
1129 vec!["step-001"],
1130 vec![(
1131 "agent://claude-code/sess-1",
1132 "conversation.append",
1133 extras(&[
1134 ("role", serde_json::json!("assistant")),
1135 ("text", serde_json::json!("hi")),
1136 ]),
1137 )],
1138 ),
1139 ]);
1140
1141 let view = extract_conversation(&path);
1142 assert!(view.started_at.is_some());
1143 assert!(view.last_activity.is_some());
1144 assert!(view.last_activity.unwrap() > view.started_at.unwrap());
1145 }
1146
1147 #[test]
1148 fn test_steps_without_structural_changes_skipped() {
1149 let path = make_path(vec![make_step(
1150 "step-001",
1151 "human:alex",
1152 "2026-01-01T00:00:00Z",
1153 vec![],
1154 vec![], )]);
1156
1157 let view = extract_conversation(&path);
1158 assert!(view.turns.is_empty());
1159 }
1160
1161 #[test]
1162 fn test_environment_from_cwd_and_git_branch() {
1163 let path = make_path(vec![make_step(
1164 "step-001",
1165 "human:alex",
1166 "2026-01-01T00:00:00Z",
1167 vec![],
1168 vec![(
1169 "agent://claude-code/sess-1",
1170 "conversation.append",
1171 extras(&[
1172 ("role", serde_json::json!("user")),
1173 ("text", serde_json::json!("hello")),
1174 ("cwd", serde_json::json!("/home/alex/project")),
1175 ("git_branch", serde_json::json!("feature/cool")),
1176 ]),
1177 )],
1178 )]);
1179
1180 let view = extract_conversation(&path);
1181 let env = view.turns[0].environment.as_ref().unwrap();
1182 assert_eq!(env.working_dir.as_deref(), Some("/home/alex/project"));
1183 assert_eq!(env.vcs_branch.as_deref(), Some("feature/cool"));
1184 assert!(env.vcs_revision.is_none());
1185 }
1186
1187 #[test]
1188 fn test_environment_none_when_absent() {
1189 let path = make_path(vec![make_step(
1190 "step-001",
1191 "human:alex",
1192 "2026-01-01T00:00:00Z",
1193 vec![],
1194 vec![(
1195 "agent://claude-code/sess-1",
1196 "conversation.append",
1197 extras(&[
1198 ("role", serde_json::json!("user")),
1199 ("text", serde_json::json!("hello")),
1200 ]),
1201 )],
1202 )]);
1203
1204 let view = extract_conversation(&path);
1205 assert!(view.turns[0].environment.is_none());
1206 }
1207
1208 #[test]
1209 fn test_agent_url_tool_not_in_files_changed() {
1210 let path = make_path(vec![
1211 make_step(
1212 "step-001",
1213 "agent:claude-opus-4-6",
1214 "2026-01-01T00:00:00Z",
1215 vec![],
1216 vec![(
1217 "agent://claude-code/sess-1",
1218 "conversation.append",
1219 extras(&[
1220 ("role", serde_json::json!("assistant")),
1221 ("text", serde_json::json!("Searching...")),
1222 ]),
1223 )],
1224 ),
1225 make_step(
1226 "step-002",
1227 "agent:claude-opus-4-6/tool:WebSearch",
1228 "2026-01-01T00:00:01Z",
1229 vec!["step-001"],
1230 vec![(
1231 "agent://claude-code/sess-1/tool/network/tu-001",
1232 "tool.invoke",
1233 extras(&[
1234 ("tool_use_id", serde_json::json!("tu-001")),
1235 ("name", serde_json::json!("WebSearch")),
1236 ("input", serde_json::json!({"query": "rust async"})),
1237 ("category", serde_json::json!("file_write")),
1238 ]),
1239 )],
1240 ),
1241 ]);
1242
1243 let view = extract_conversation(&path);
1244 assert!(view.files_changed.is_empty());
1246 }
1247
1248 #[test]
1249 fn test_conversation_event_extracted() {
1250 let path = make_path(vec![
1251 make_step(
1252 "step-001",
1253 "tool:claude-code",
1254 "2026-01-01T00:00:00Z",
1255 vec![],
1256 vec![(
1257 "agent://claude-code/sess-1",
1258 "conversation.event",
1259 extras(&[
1260 ("entry_type", serde_json::json!("attachment")),
1261 ("cwd", serde_json::json!("/home/alex/project")),
1262 ("version", serde_json::json!("1.0.30")),
1263 (
1264 "entry_extra",
1265 serde_json::json!({"attachment": {"fileName": "test.png"}}),
1266 ),
1267 ]),
1268 )],
1269 ),
1270 make_step(
1271 "step-002",
1272 "tool:claude-code",
1273 "2026-01-01T00:00:01Z",
1274 vec!["step-001"],
1275 vec![(
1276 "agent://claude-code/sess-1",
1277 "conversation.event",
1278 extras(&[
1279 ("entry_type", serde_json::json!("file-history-snapshot")),
1280 ("snapshot", serde_json::json!({"files": []})),
1281 ]),
1282 )],
1283 ),
1284 ]);
1285
1286 let view = extract_conversation(&path);
1287 assert!(view.turns.is_empty());
1288 assert_eq!(view.events.len(), 2);
1289
1290 assert_eq!(view.events[0].id, "step-001");
1291 assert_eq!(view.events[0].event_type, "attachment");
1292 assert_eq!(
1293 view.events[0].data["cwd"],
1294 serde_json::json!("/home/alex/project")
1295 );
1296 assert_eq!(view.events[0].data["version"], serde_json::json!("1.0.30"));
1297 assert!(view.events[0].parent_id.is_none());
1298
1299 assert_eq!(view.events[1].id, "step-002");
1300 assert_eq!(view.events[1].event_type, "file-history-snapshot");
1301 assert_eq!(view.events[1].parent_id.as_deref(), Some("step-001"));
1302 assert!(view.events[1].data.contains_key("snapshot"));
1303 }
1304
1305 #[test]
1306 fn test_conversation_event_with_unknown_type() {
1307 let path = make_path(vec![make_step(
1308 "step-001",
1309 "tool:claude-code",
1310 "2026-01-01T00:00:00Z",
1311 vec![],
1312 vec![(
1313 "agent://claude-code/sess-1",
1314 "conversation.event",
1315 extras(&[("cwd", serde_json::json!("/tmp"))]),
1316 )],
1317 )]);
1318
1319 let view = extract_conversation(&path);
1320 assert_eq!(view.events.len(), 1);
1321 assert_eq!(view.events[0].event_type, "unknown");
1322 }
1323
1324 #[test]
1325 fn test_conversation_event_mixed_with_turns() {
1326 let path = make_path(vec![
1327 make_step(
1328 "step-001",
1329 "tool:claude-code",
1330 "2026-01-01T00:00:00Z",
1331 vec![],
1332 vec![(
1333 "agent://claude-code/sess-1",
1334 "conversation.event",
1335 extras(&[("entry_type", serde_json::json!("system"))]),
1336 )],
1337 ),
1338 make_step(
1339 "step-002",
1340 "human:alex",
1341 "2026-01-01T00:00:01Z",
1342 vec!["step-001"],
1343 vec![(
1344 "agent://claude-code/sess-1",
1345 "conversation.append",
1346 extras(&[
1347 ("role", serde_json::json!("user")),
1348 ("text", serde_json::json!("hello")),
1349 ]),
1350 )],
1351 ),
1352 ]);
1353
1354 let view = extract_conversation(&path);
1355 assert_eq!(view.turns.len(), 1);
1356 assert_eq!(view.events.len(), 1);
1357 assert_eq!(view.turns[0].text, "hello");
1358 assert_eq!(view.events[0].event_type, "system");
1359 }
1360}