1use crate::provider::to_view;
9use crate::types::{ContentPart, Conversation, MessageContent, MessageRole};
10use serde_json::json;
11use std::collections::HashMap;
12use std::path::Path as FsPath;
13use std::process::Command;
14use toolpath::v1::{
15 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
16 StepIdentity, StructuralChange,
17};
18use toolpath_convo::file_write_diff;
19
20fn git_head_content(repo_dir: &str, path: &str) -> Option<String> {
33 let repo = FsPath::new(repo_dir);
34 let file = FsPath::new(path);
35 let rel = if file.is_absolute() {
36 file.strip_prefix(repo).ok()?.to_path_buf()
37 } else {
38 file.to_path_buf()
39 };
40 let rel_str = rel.to_string_lossy().replace('\\', "/");
42 let output = Command::new("git")
43 .arg("-C")
44 .arg(repo)
45 .arg("show")
46 .arg(format!("HEAD:{rel_str}"))
47 .output()
48 .ok()?;
49 if !output.status.success() {
50 return None;
51 }
52 String::from_utf8(output.stdout).ok()
53}
54
55fn resolve_local_dir<'a>(
60 config_project: Option<&'a str>,
61 conversation_project: Option<&'a str>,
62 entry_cwd: Option<&'a str>,
63) -> Option<String> {
64 let raw = entry_cwd.or(config_project).or(conversation_project)?;
65 let stripped = raw.strip_prefix("file://").unwrap_or(raw);
66 Some(stripped.to_string())
67}
68
69#[derive(Default)]
71pub struct DeriveConfig {
72 pub project_path: Option<String>,
74 pub include_thinking: bool,
76}
77
78fn tool_category_str(name: &str) -> &'static str {
84 match name {
85 "Read" => "file_read",
86 "Glob" | "Grep" => "file_search",
87 "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => "file_write",
88 "Bash" => "shell",
89 "WebFetch" | "WebSearch" => "network",
90 "Task" | "Agent" => "delegation",
91 _ => "unknown",
92 }
93}
94
95fn is_file_tool(name: &str) -> bool {
97 matches!(
98 name,
99 "Read" | "Write" | "Edit" | "Glob" | "Grep" | "NotebookEdit"
100 )
101}
102
103struct ToolUseInfo {
105 id: String,
106 name: String,
107 input: serde_json::Value,
108}
109
110pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
118 let session_short = safe_prefix(&conversation.session_id, 8);
119 let convo_artifact = format!("agent://claude/{}", conversation.session_id);
120
121 let view = to_view(conversation);
123 let turn_by_id: HashMap<&str, &toolpath_convo::Turn> =
124 view.turns.iter().map(|t| (t.id.as_str(), t)).collect();
125
126 let mut steps = Vec::new();
127 let mut last_step_id: Option<String> = None;
128 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
129
130 let init_step = {
132 let mut init_extra = HashMap::new();
133 for entry in &conversation.entries {
134 if let Some(cwd) = &entry.cwd {
135 init_extra.insert("working_dir".to_string(), json!(cwd));
136 }
137 if let Some(branch) = &entry.git_branch {
138 init_extra.insert("vcs_branch".to_string(), json!(branch));
139 }
140 if let Some(version) = &entry.version {
141 init_extra.insert("version".to_string(), json!(version));
142 }
143 if !init_extra.is_empty() {
144 break;
145 }
146 }
147
148 if !init_extra.is_empty() {
149 let mut changes = HashMap::new();
150 changes.insert(
151 convo_artifact.clone(),
152 ArtifactChange {
153 raw: None,
154 structural: Some(StructuralChange {
155 change_type: "conversation.init".to_string(),
156 extra: init_extra,
157 }),
158 },
159 );
160
161 let step = Step {
162 step: StepIdentity {
163 id: format!("{}-init", conversation.session_id),
164 parents: vec![],
165 actor: "tool:claude-code".into(),
166 timestamp: conversation
167 .entries
168 .first()
169 .map(|e| e.timestamp.clone())
170 .unwrap_or_default(),
171 },
172 change: changes,
173 meta: None,
174 };
175 last_step_id = Some(step.step.id.clone());
176 Some(step)
177 } else {
178 None
179 }
180 };
181
182 if let Some(init) = init_step {
183 actors
184 .entry("tool:claude-code".to_string())
185 .or_insert_with(|| ActorDefinition {
186 name: Some("Claude Code".to_string()),
187 ..Default::default()
188 });
189 steps.push(init);
190 }
191
192 for (entry_idx, entry) in conversation.entries.iter().enumerate() {
193 let message = entry.message.as_ref();
196 let is_conversational =
197 message.is_some_and(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant));
198
199 if !is_conversational {
200 let step_id = if entry.uuid.is_empty() {
202 format!("{}-event-{}", conversation.session_id, entry_idx)
203 } else {
204 entry.uuid.clone()
205 };
206
207 let parents = if let Some(parent) = &entry.parent_uuid {
208 vec![parent.clone()]
209 } else if let Some(ref last) = last_step_id {
210 vec![last.clone()]
211 } else {
212 vec![]
213 };
214
215 actors
217 .entry("tool:claude-code".to_string())
218 .or_insert_with(|| ActorDefinition {
219 name: Some("Claude Code".to_string()),
220 ..Default::default()
221 });
222
223 let mut event_extra = HashMap::new();
224 event_extra.insert("entry_type".to_string(), json!(entry.entry_type));
225
226 if let Some(cwd) = &entry.cwd {
227 event_extra.insert("cwd".to_string(), json!(cwd));
228 }
229 if let Some(version) = &entry.version {
230 event_extra.insert("version".to_string(), json!(version));
231 }
232 if let Some(git_branch) = &entry.git_branch {
233 event_extra.insert("git_branch".to_string(), json!(git_branch));
234 }
235 if let Some(user_type) = &entry.user_type {
236 event_extra.insert("user_type".to_string(), json!(user_type));
237 }
238 if let Some(snapshot) = &entry.snapshot {
239 event_extra.insert("snapshot".to_string(), snapshot.clone());
240 }
241 if let Some(tool_use_result) = &entry.tool_use_result {
242 event_extra.insert("tool_use_result".to_string(), tool_use_result.clone());
243 }
244 if let Some(message_id) = &entry.message_id {
245 event_extra.insert("message_id".to_string(), json!(message_id));
246 }
247 if let Some(msg) = message {
249 let text = msg.text();
250 if !text.is_empty() {
251 event_extra.insert("text".to_string(), json!(text));
252 }
253 }
254 if !entry.extra.is_empty() {
256 event_extra.insert("entry_extra".to_string(), json!(entry.extra));
257 }
258
259 let event_step = Step {
260 step: StepIdentity {
261 id: step_id,
262 parents,
263 actor: "tool:claude-code".into(),
264 timestamp: entry.timestamp.clone(),
265 },
266 change: {
267 let mut m = HashMap::new();
268 m.insert(
269 convo_artifact.clone(),
270 ArtifactChange {
271 raw: None,
272 structural: Some(StructuralChange {
273 change_type: "conversation.event".to_string(),
274 extra: event_extra,
275 }),
276 },
277 );
278 m
279 },
280 meta: None,
281 };
282
283 steps.push(event_step);
285 continue;
286 }
287
288 let message = message.unwrap();
289
290 let (actor, role_str) = match message.role {
291 MessageRole::User => {
292 actors
293 .entry("human:user".to_string())
294 .or_insert_with(|| ActorDefinition {
295 name: Some("User".to_string()),
296 ..Default::default()
297 });
298 ("human:user".to_string(), "user")
299 }
300 MessageRole::Assistant => {
301 let (actor_key, model_str) = if let Some(model) = &message.model {
302 (format!("agent:{}", model), model.clone())
303 } else {
304 ("agent:claude-code".to_string(), "claude-code".to_string())
305 };
306 actors.entry(actor_key.clone()).or_insert_with(|| {
307 let mut identities = vec![Identity {
308 system: "anthropic".to_string(),
309 id: model_str.clone(),
310 }];
311 if let Some(version) = &entry.version {
312 identities.push(Identity {
313 system: "claude-code".to_string(),
314 id: version.clone(),
315 });
316 }
317 ActorDefinition {
318 name: Some("Claude Code".to_string()),
319 provider: Some("anthropic".to_string()),
320 model: Some(model_str),
321 identities,
322 ..Default::default()
323 }
324 });
325 (actor_key, "assistant")
326 }
327 MessageRole::System => unreachable!(),
329 };
330
331 let mut text_parts: Vec<String> = Vec::new();
333 let mut thinking_parts: Vec<String> = Vec::new();
334 let mut tool_use_infos: Vec<ToolUseInfo> = Vec::new();
335
336 match &message.content {
337 Some(MessageContent::Parts(parts)) => {
338 for part in parts {
339 match part {
340 ContentPart::Text { text } if !text.trim().is_empty() => {
341 text_parts.push(text.clone());
342 }
343 ContentPart::Thinking { thinking, .. } => {
344 if config.include_thinking && !thinking.trim().is_empty() {
345 thinking_parts.push(thinking.clone());
346 }
347 }
348 ContentPart::ToolUse { id, name, input } => {
349 tool_use_infos.push(ToolUseInfo {
350 id: id.clone(),
351 name: name.clone(),
352 input: input.clone(),
353 });
354 }
355 _ => {}
356 }
357 }
358 }
359 Some(MessageContent::Text(text)) if !text.trim().is_empty() => {
360 text_parts.push(text.clone());
361 }
362 _ => {}
363 }
364
365 let tool_names: Vec<String> = tool_use_infos.iter().map(|t| t.name.clone()).collect();
367
368 if text_parts.is_empty() && thinking_parts.is_empty() && tool_use_infos.is_empty() {
370 continue;
371 }
372
373 let mut convo_extra = HashMap::new();
375 convo_extra.insert("role".to_string(), json!(role_str));
376 if !text_parts.is_empty() {
377 let combined = text_parts.join("\n\n");
378 convo_extra.insert("text".to_string(), json!(combined));
379 }
380 if !thinking_parts.is_empty() {
381 let combined_thinking = thinking_parts.join("\n\n");
382 convo_extra.insert("thinking".to_string(), json!(combined_thinking));
383 }
384 if !tool_names.is_empty() {
385 convo_extra.insert("tool_uses".to_string(), json!(tool_names));
386 }
387
388 if let Some(model) = &message.model {
390 convo_extra.insert("model".to_string(), json!(model));
391 }
392 if let Some(stop_reason) = &message.stop_reason {
393 convo_extra.insert("stop_reason".to_string(), json!(stop_reason));
394 }
395 if let Some(usage) = &message.usage {
396 if let Some(input_tokens) = usage.input_tokens {
397 convo_extra.insert("input_tokens".to_string(), json!(input_tokens));
398 }
399 if let Some(output_tokens) = usage.output_tokens {
400 convo_extra.insert("output_tokens".to_string(), json!(output_tokens));
401 }
402 if let Some(cache_read) = usage.cache_read_input_tokens {
403 convo_extra.insert("cache_read_tokens".to_string(), json!(cache_read));
404 }
405 if let Some(cache_write) = usage.cache_creation_input_tokens {
406 convo_extra.insert("cache_write_tokens".to_string(), json!(cache_write));
407 }
408 }
409
410 if let Some(cwd) = &entry.cwd {
412 convo_extra.insert("cwd".to_string(), json!(cwd));
413 }
414 if let Some(version) = &entry.version {
415 convo_extra.insert("version".to_string(), json!(version));
416 }
417 if let Some(git_branch) = &entry.git_branch {
418 convo_extra.insert("git_branch".to_string(), json!(git_branch));
419 }
420 if let Some(user_type) = &entry.user_type {
421 convo_extra.insert("user_type".to_string(), json!(user_type));
422 }
423 if let Some(request_id) = &entry.request_id {
424 convo_extra.insert("request_id".to_string(), json!(request_id));
425 }
426 if !entry.extra.is_empty() {
428 convo_extra.insert("entry_extra".to_string(), json!(entry.extra));
429 }
430
431 let convo_change = ArtifactChange {
432 raw: None,
433 structural: Some(StructuralChange {
434 change_type: "conversation.append".to_string(),
435 extra: convo_extra,
436 }),
437 };
438
439 let mut changes = HashMap::new();
440 changes.insert(convo_artifact.clone(), convo_change);
441
442 let step_id = entry.uuid.clone();
444 let parents = if entry.is_sidechain {
445 entry.parent_uuid.as_ref().cloned().into_iter().collect()
446 } else {
447 last_step_id.iter().cloned().collect()
448 };
449
450 let step = Step {
451 step: StepIdentity {
452 id: step_id.clone(),
453 parents,
454 actor,
455 timestamp: entry.timestamp.clone(),
456 },
457 change: changes,
458 meta: None,
459 };
460
461 if !entry.is_sidechain {
462 last_step_id = Some(step_id.clone());
463 }
464 steps.push(step);
465
466 if !tool_use_infos.is_empty() {
468 let mut tool_groups: Vec<(String, Vec<&ToolUseInfo>)> = Vec::new();
470 let mut group_index: HashMap<String, usize> = HashMap::new();
471
472 for tool_use in &tool_use_infos {
473 if let Some(&idx) = group_index.get(&tool_use.name) {
474 tool_groups[idx].1.push(tool_use);
475 } else {
476 let idx = tool_groups.len();
477 group_index.insert(tool_use.name.clone(), idx);
478 tool_groups.push((tool_use.name.clone(), vec![tool_use]));
479 }
480 }
481
482 for (tool_name, uses) in &tool_groups {
483 let tool_step_id = format!("{}-tool-{}", entry.uuid, tool_name);
484 let tool_actor = format!("agent:claude-code/tool:{}", tool_name);
485
486 actors
488 .entry(tool_actor.clone())
489 .or_insert_with(|| ActorDefinition {
490 name: Some(format!("Claude Code / {}", tool_name)),
491 ..Default::default()
492 });
493
494 let mut tool_changes = HashMap::new();
495 let category = tool_category_str(tool_name);
496
497 for tool_use in uses {
498 let artifact_key = if is_file_tool(tool_name) {
500 tool_use
501 .input
502 .get("file_path")
503 .and_then(|v| v.as_str())
504 .map(|s| s.to_string())
505 .unwrap_or_else(|| {
506 format!(
507 "agent://claude/{}/tool/{}/{}",
508 conversation.session_id, category, tool_use.id
509 )
510 })
511 } else {
512 format!(
513 "agent://claude/{}/tool/{}/{}",
514 conversation.session_id, category, tool_use.id
515 )
516 };
517
518 let mut extra = HashMap::new();
519 extra.insert("tool_use_id".to_string(), json!(tool_use.id));
520 extra.insert("name".to_string(), json!(tool_use.name));
521 extra.insert("input".to_string(), tool_use.input.clone());
522 extra.insert("category".to_string(), json!(category));
523
524 if let Some(turn) = turn_by_id.get(entry.uuid.as_str())
526 && let Some(invocation) =
527 turn.tool_uses.iter().find(|tu| tu.id == tool_use.id)
528 && let Some(result) = &invocation.result
529 {
530 extra.insert("result".to_string(), json!(result.content));
531 extra.insert("is_error".to_string(), json!(result.is_error));
532 }
533
534 let raw = if category == "file_write" {
544 let before_state = if tool_name == "Write" {
545 resolve_local_dir(
546 config.project_path.as_deref(),
547 conversation.project_path.as_deref(),
548 entry.cwd.as_deref(),
549 )
550 .and_then(|dir| git_head_content(&dir, &artifact_key))
551 } else {
552 None
553 };
554 file_write_diff(
555 tool_name,
556 &tool_use.input,
557 &artifact_key,
558 before_state.as_deref(),
559 )
560 } else {
561 None
562 };
563
564 tool_changes.insert(
565 artifact_key,
566 ArtifactChange {
567 raw,
568 structural: Some(StructuralChange {
569 change_type: "tool.invoke".to_string(),
570 extra,
571 }),
572 },
573 );
574 }
575
576 let tool_step = Step {
577 step: StepIdentity {
578 id: tool_step_id,
579 parents: vec![step_id.clone()],
580 actor: tool_actor,
581 timestamp: entry.timestamp.clone(),
582 },
583 change: tool_changes,
584 meta: None,
585 };
586
587 steps.push(tool_step);
589 }
590 }
591 }
592
593 let head = last_step_id.unwrap_or_else(|| "empty".to_string());
594 let base_uri = config
595 .project_path
596 .as_deref()
597 .or(conversation.project_path.as_deref())
598 .map(|p| format!("file://{}", p));
599
600 Path {
601 path: PathIdentity {
602 id: format!("path-claude-{}", session_short),
603 base: base_uri.map(|uri| Base { uri, ref_str: None }),
604 head,
605 graph_ref: None,
606 },
607 steps,
608 meta: Some(PathMeta {
609 title: Some(format!("Claude session: {}", session_short)),
610 source: Some("claude-code".to_string()),
611 actors: if actors.is_empty() {
612 None
613 } else {
614 Some(actors)
615 },
616 ..Default::default()
617 }),
618 }
619}
620
621pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
623 conversations
624 .iter()
625 .map(|c| derive_path(c, config))
626 .collect()
627}
628
629fn safe_prefix(s: &str, n: usize) -> String {
631 s.chars().take(n).collect()
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::types::{ContentPart, ConversationEntry, Message, MessageContent, Usage};
638
639 fn make_entry(
640 uuid: &str,
641 role: MessageRole,
642 content: &str,
643 timestamp: &str,
644 ) -> ConversationEntry {
645 ConversationEntry {
646 parent_uuid: None,
647 is_sidechain: false,
648 entry_type: match role {
649 MessageRole::User => "user",
650 MessageRole::Assistant => "assistant",
651 MessageRole::System => "system",
652 }
653 .to_string(),
654 uuid: uuid.to_string(),
655 timestamp: timestamp.to_string(),
656 session_id: Some("test-session".to_string()),
657 cwd: None,
658 git_branch: None,
659 version: None,
660 message: Some(Message {
661 role,
662 content: Some(MessageContent::Text(content.to_string())),
663 model: None,
664 id: None,
665 message_type: None,
666 stop_reason: None,
667 stop_sequence: None,
668 usage: None,
669 }),
670 user_type: None,
671 request_id: None,
672 tool_use_result: None,
673 snapshot: None,
674 message_id: None,
675 extra: Default::default(),
676 }
677 }
678
679 fn make_conversation(entries: Vec<ConversationEntry>) -> Conversation {
680 let mut convo = Conversation::new("test-session-12345678".to_string());
681 for entry in entries {
682 convo.add_entry(entry);
683 }
684 convo
685 }
686
687 #[test]
690 fn test_safe_prefix_normal() {
691 assert_eq!(safe_prefix("abcdef1234", 8), "abcdef12");
692 }
693
694 #[test]
695 fn test_safe_prefix_short() {
696 assert_eq!(safe_prefix("abc", 8), "abc");
697 }
698
699 #[test]
700 fn test_safe_prefix_unicode() {
701 assert_eq!(
702 safe_prefix("\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30B9}\u{30C8}", 3),
703 "\u{65E5}\u{672C}\u{8A9E}"
704 );
705 }
706
707 #[test]
710 fn test_tool_category_str() {
711 assert_eq!(tool_category_str("Read"), "file_read");
712 assert_eq!(tool_category_str("Write"), "file_write");
713 assert_eq!(tool_category_str("Edit"), "file_write");
714 assert_eq!(tool_category_str("Glob"), "file_search");
715 assert_eq!(tool_category_str("Grep"), "file_search");
716 assert_eq!(tool_category_str("Bash"), "shell");
717 assert_eq!(tool_category_str("WebFetch"), "network");
718 assert_eq!(tool_category_str("Task"), "delegation");
719 assert_eq!(tool_category_str("SomethingElse"), "unknown");
720 }
721
722 #[test]
723 fn test_is_file_tool() {
724 assert!(is_file_tool("Read"));
725 assert!(is_file_tool("Write"));
726 assert!(is_file_tool("Edit"));
727 assert!(is_file_tool("Glob"));
728 assert!(is_file_tool("Grep"));
729 assert!(is_file_tool("NotebookEdit"));
730 assert!(!is_file_tool("Bash"));
731 assert!(!is_file_tool("WebFetch"));
732 assert!(!is_file_tool("Task"));
733 }
734
735 #[test]
738 fn test_derive_path_basic() {
739 let entries = vec![
740 make_entry(
741 "uuid-1111-aaaa",
742 MessageRole::User,
743 "Hello",
744 "2024-01-01T00:00:00Z",
745 ),
746 make_entry(
747 "uuid-2222-bbbb",
748 MessageRole::Assistant,
749 "Hi there",
750 "2024-01-01T00:00:01Z",
751 ),
752 ];
753 let convo = make_conversation(entries);
754 let config = DeriveConfig::default();
755
756 let path = derive_path(&convo, &config);
757
758 assert!(path.path.id.starts_with("path-claude-"));
759 assert_eq!(path.steps.len(), 2);
760 assert_eq!(path.steps[0].step.id, "uuid-1111-aaaa");
762 assert_eq!(path.steps[1].step.id, "uuid-2222-bbbb");
763 assert_eq!(path.steps[0].step.actor, "human:user");
764 assert!(path.steps[1].step.actor.starts_with("agent:"));
765 }
766
767 #[test]
768 fn test_derive_path_step_parents() {
769 let entries = vec![
770 make_entry(
771 "uuid-1111",
772 MessageRole::User,
773 "Hello",
774 "2024-01-01T00:00:00Z",
775 ),
776 make_entry(
777 "uuid-2222",
778 MessageRole::Assistant,
779 "Hi",
780 "2024-01-01T00:00:01Z",
781 ),
782 make_entry(
783 "uuid-3333",
784 MessageRole::User,
785 "More",
786 "2024-01-01T00:00:02Z",
787 ),
788 ];
789 let convo = make_conversation(entries);
790 let config = DeriveConfig::default();
791
792 let path = derive_path(&convo, &config);
793
794 assert!(
796 path.steps[1]
797 .step
798 .parents
799 .contains(&"uuid-1111".to_string())
800 );
801 assert!(
802 path.steps[2]
803 .step
804 .parents
805 .contains(&"uuid-2222".to_string())
806 );
807 }
808
809 #[test]
810 fn test_derive_path_conversation_artifact() {
811 let entries = vec![make_entry(
812 "uuid-1111",
813 MessageRole::User,
814 "Hello",
815 "2024-01-01T00:00:00Z",
816 )];
817 let convo = make_conversation(entries);
818 let config = DeriveConfig::default();
819
820 let path = derive_path(&convo, &config);
821
822 let convo_key = format!("agent://claude/{}", convo.session_id);
824 assert!(path.steps[0].change.contains_key(&convo_key));
825
826 let change = &path.steps[0].change[&convo_key];
827 let structural = change.structural.as_ref().unwrap();
828 assert_eq!(structural.change_type, "conversation.append");
829 assert_eq!(structural.extra["role"], "user");
830 }
831
832 #[test]
833 fn test_derive_path_no_meta_intent() {
834 let entries = vec![make_entry(
835 "uuid-1111",
836 MessageRole::User,
837 "Hello",
838 "2024-01-01T00:00:00Z",
839 )];
840 let convo = make_conversation(entries);
841 let config = DeriveConfig::default();
842
843 let path = derive_path(&convo, &config);
844
845 assert!(path.steps[0].meta.is_none());
847 }
848
849 #[test]
850 fn test_derive_path_actors() {
851 let entries = vec![
852 make_entry(
853 "uuid-1111",
854 MessageRole::User,
855 "Hello",
856 "2024-01-01T00:00:00Z",
857 ),
858 make_entry(
859 "uuid-2222",
860 MessageRole::Assistant,
861 "Hi",
862 "2024-01-01T00:00:01Z",
863 ),
864 ];
865 let convo = make_conversation(entries);
866 let config = DeriveConfig::default();
867
868 let path = derive_path(&convo, &config);
869 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
870
871 assert!(actors.contains_key("human:user"));
872 assert!(actors.contains_key("agent:claude-code"));
874 }
875
876 #[test]
877 fn test_derive_path_with_project_path_config() {
878 let convo = make_conversation(vec![make_entry(
879 "uuid-1",
880 MessageRole::User,
881 "Hello",
882 "2024-01-01T00:00:00Z",
883 )]);
884 let config = DeriveConfig {
885 project_path: Some("/my/project".to_string()),
886 ..Default::default()
887 };
888
889 let path = derive_path(&convo, &config);
890 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///my/project");
891 }
892
893 #[test]
894 fn test_derive_path_skips_empty_content() {
895 let mut entry = make_entry("uuid-1111", MessageRole::User, "", "2024-01-01T00:00:00Z");
896 entry.message.as_mut().unwrap().content = Some(MessageContent::Text(" ".to_string()));
898
899 let convo = make_conversation(vec![entry]);
900 let config = DeriveConfig::default();
901
902 let path = derive_path(&convo, &config);
903 assert!(path.steps.is_empty());
904 }
905
906 #[test]
907 fn test_derive_path_captures_system_messages_as_events() {
908 let entries = vec![
909 make_entry(
910 "uuid-1111",
911 MessageRole::System,
912 "System prompt",
913 "2024-01-01T00:00:00Z",
914 ),
915 make_entry(
916 "uuid-2222",
917 MessageRole::User,
918 "Hello",
919 "2024-01-01T00:00:01Z",
920 ),
921 ];
922 let convo = make_conversation(entries);
923 let config = DeriveConfig::default();
924
925 let path = derive_path(&convo, &config);
926 assert_eq!(path.steps.len(), 2);
928 assert_eq!(path.steps[0].step.actor, "tool:claude-code");
930 let convo_key = format!("agent://claude/{}", convo.session_id);
931 let structural = path.steps[0].change[&convo_key]
932 .structural
933 .as_ref()
934 .unwrap();
935 assert_eq!(structural.change_type, "conversation.event");
936 assert_eq!(structural.extra["entry_type"], "system");
937 assert_eq!(structural.extra["text"], "System prompt");
938 assert_eq!(path.steps[1].step.actor, "human:user");
940 }
941
942 #[test]
943 fn test_derive_path_with_tool_use() {
944 let mut convo = Conversation::new("test-session-12345678".to_string());
945 let entry = ConversationEntry {
946 parent_uuid: None,
947 is_sidechain: false,
948 entry_type: "assistant".to_string(),
949 uuid: "uuid-tool".to_string(),
950 timestamp: "2024-01-01T00:00:00Z".to_string(),
951 session_id: Some("test-session".to_string()),
952 message: Some(Message {
953 role: MessageRole::Assistant,
954 content: Some(MessageContent::Parts(vec![
955 ContentPart::Text {
956 text: "Let me write that".to_string(),
957 },
958 ContentPart::ToolUse {
959 id: "t1".to_string(),
960 name: "Write".to_string(),
961 input: serde_json::json!({"file_path": "/tmp/test.rs"}),
962 },
963 ])),
964 model: Some("claude-sonnet-4-5-20250929".to_string()),
965 id: None,
966 message_type: None,
967 stop_reason: None,
968 stop_sequence: None,
969 usage: None,
970 }),
971 cwd: None,
972 git_branch: None,
973 version: None,
974 user_type: None,
975 request_id: None,
976 tool_use_result: None,
977 snapshot: None,
978 message_id: None,
979 extra: Default::default(),
980 };
981 convo.add_entry(entry);
982 let config = DeriveConfig::default();
983
984 let path = derive_path(&convo, &config);
985
986 assert_eq!(path.steps.len(), 2);
988
989 let convo_key = format!("agent://claude/{}", convo.session_id);
991 assert!(path.steps[0].change.contains_key(&convo_key));
992
993 assert_eq!(path.steps[1].step.id, "uuid-tool-tool-Write");
995 assert_eq!(path.steps[1].step.actor, "agent:claude-code/tool:Write");
996 assert!(
997 path.steps[1]
998 .step
999 .parents
1000 .contains(&"uuid-tool".to_string())
1001 );
1002 assert!(path.steps[1].change.contains_key("/tmp/test.rs"));
1003
1004 let tool_change = &path.steps[1].change["/tmp/test.rs"];
1005 let structural = tool_change.structural.as_ref().unwrap();
1006 assert_eq!(structural.change_type, "tool.invoke");
1007 assert_eq!(structural.extra["name"], "Write");
1008 assert_eq!(structural.extra["tool_use_id"], "t1");
1009 assert_eq!(structural.extra["category"], "file_write");
1010 }
1011
1012 #[test]
1013 fn test_derive_path_sidechain_uses_parent_uuid() {
1014 let mut convo = Conversation::new("test-session-12345678".to_string());
1015
1016 let e1 = make_entry(
1017 "uuid-main-11",
1018 MessageRole::User,
1019 "Hello",
1020 "2024-01-01T00:00:00Z",
1021 );
1022 let e2 = make_entry(
1023 "uuid-main-22",
1024 MessageRole::Assistant,
1025 "Hi",
1026 "2024-01-01T00:00:01Z",
1027 );
1028 let mut e3 = make_entry(
1029 "uuid-side-33",
1030 MessageRole::User,
1031 "Side",
1032 "2024-01-01T00:00:02Z",
1033 );
1034 e3.is_sidechain = true;
1035 e3.parent_uuid = Some("uuid-main-11".to_string());
1036
1037 convo.add_entry(e1);
1038 convo.add_entry(e2);
1039 convo.add_entry(e3);
1040
1041 let config = DeriveConfig::default();
1042 let path = derive_path(&convo, &config);
1043
1044 assert_eq!(path.steps.len(), 3);
1045 let sidechain_step = &path.steps[2];
1047 assert!(
1048 sidechain_step
1049 .step
1050 .parents
1051 .contains(&"uuid-main-11".to_string())
1052 );
1053 }
1054
1055 #[test]
1058 fn test_derive_project() {
1059 let c1 = make_conversation(vec![make_entry(
1060 "uuid-1",
1061 MessageRole::User,
1062 "Hello",
1063 "2024-01-01T00:00:00Z",
1064 )]);
1065 let mut c2 = Conversation::new("session-2".to_string());
1066 c2.add_entry(make_entry(
1067 "uuid-2",
1068 MessageRole::User,
1069 "World",
1070 "2024-01-02T00:00:00Z",
1071 ));
1072
1073 let config = DeriveConfig::default();
1074 let paths = derive_project(&[c1, c2], &config);
1075
1076 assert_eq!(paths.len(), 2);
1077 }
1078
1079 #[test]
1080 fn test_derive_path_head_is_last_non_sidechain() {
1081 let entries = vec![
1082 make_entry(
1083 "uuid-1111",
1084 MessageRole::User,
1085 "Hello",
1086 "2024-01-01T00:00:00Z",
1087 ),
1088 make_entry(
1089 "uuid-2222",
1090 MessageRole::Assistant,
1091 "Hi",
1092 "2024-01-01T00:00:01Z",
1093 ),
1094 ];
1095 let convo = make_conversation(entries);
1096 let config = DeriveConfig::default();
1097
1098 let path = derive_path(&convo, &config);
1099
1100 assert_eq!(path.path.head, "uuid-2222");
1102 }
1103
1104 #[test]
1107 fn test_derive_path_tool_invocation_actors() {
1108 let mut convo = Conversation::new("test-session-12345678".to_string());
1109 convo.add_entry(ConversationEntry {
1110 parent_uuid: None,
1111 is_sidechain: false,
1112 entry_type: "assistant".to_string(),
1113 uuid: "uuid-1".to_string(),
1114 timestamp: "2024-01-01T00:00:00Z".to_string(),
1115 session_id: Some("test-session".to_string()),
1116 message: Some(Message {
1117 role: MessageRole::Assistant,
1118 content: Some(MessageContent::Parts(vec![
1119 ContentPart::Text {
1120 text: "Working".to_string(),
1121 },
1122 ContentPart::ToolUse {
1123 id: "t1".to_string(),
1124 name: "Read".to_string(),
1125 input: serde_json::json!({"file_path": "/foo.rs"}),
1126 },
1127 ])),
1128 model: None,
1129 id: None,
1130 message_type: None,
1131 stop_reason: None,
1132 stop_sequence: None,
1133 usage: None,
1134 }),
1135 cwd: None,
1136 git_branch: None,
1137 version: None,
1138 user_type: None,
1139 request_id: None,
1140 tool_use_result: None,
1141 snapshot: None,
1142 message_id: None,
1143 extra: Default::default(),
1144 });
1145 let config = DeriveConfig::default();
1146 let path = derive_path(&convo, &config);
1147
1148 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
1149 assert!(actors.contains_key("agent:claude-code/tool:Read"));
1150 }
1151
1152 #[test]
1153 fn test_derive_path_token_usage() {
1154 let mut convo = Conversation::new("test-session-12345678".to_string());
1155 convo.add_entry(ConversationEntry {
1156 parent_uuid: None,
1157 is_sidechain: false,
1158 entry_type: "assistant".to_string(),
1159 uuid: "uuid-usage".to_string(),
1160 timestamp: "2024-01-01T00:00:00Z".to_string(),
1161 session_id: Some("test-session".to_string()),
1162 message: Some(Message {
1163 role: MessageRole::Assistant,
1164 content: Some(MessageContent::Text("Response".to_string())),
1165 model: Some("claude-sonnet-4-5-20250929".to_string()),
1166 id: None,
1167 message_type: None,
1168 stop_reason: Some("end_turn".to_string()),
1169 stop_sequence: None,
1170 usage: Some(Usage {
1171 input_tokens: Some(100),
1172 output_tokens: Some(50),
1173 cache_creation_input_tokens: Some(10),
1174 cache_read_input_tokens: Some(80),
1175 cache_creation: None,
1176 service_tier: None,
1177 }),
1178 }),
1179 cwd: None,
1180 git_branch: None,
1181 version: None,
1182 user_type: None,
1183 request_id: None,
1184 tool_use_result: None,
1185 snapshot: None,
1186 message_id: None,
1187 extra: Default::default(),
1188 });
1189
1190 let config = DeriveConfig::default();
1191 let path = derive_path(&convo, &config);
1192
1193 let convo_key = format!("agent://claude/{}", convo.session_id);
1194 let change = &path.steps[0].change[&convo_key];
1195 let extra = &change.structural.as_ref().unwrap().extra;
1196
1197 assert_eq!(extra["model"], "claude-sonnet-4-5-20250929");
1198 assert_eq!(extra["stop_reason"], "end_turn");
1199 assert_eq!(extra["input_tokens"], 100);
1200 assert_eq!(extra["output_tokens"], 50);
1201 assert_eq!(extra["cache_read_tokens"], 80);
1202 assert_eq!(extra["cache_write_tokens"], 10);
1203 }
1204
1205 #[test]
1206 fn test_derive_path_full_text_no_truncation() {
1207 let long_text = "a".repeat(5000);
1208 let entries = vec![make_entry(
1209 "uuid-long",
1210 MessageRole::User,
1211 &long_text,
1212 "2024-01-01T00:00:00Z",
1213 )];
1214 let convo = make_conversation(entries);
1215 let config = DeriveConfig::default();
1216
1217 let path = derive_path(&convo, &config);
1218
1219 let convo_key = format!("agent://claude/{}", convo.session_id);
1220 let change = &path.steps[0].change[&convo_key];
1221 let text = change.structural.as_ref().unwrap().extra["text"]
1222 .as_str()
1223 .unwrap();
1224 assert_eq!(text.len(), 5000);
1225 assert!(!text.ends_with("..."));
1226 }
1227
1228 #[test]
1229 fn test_derive_path_multiple_tool_uses_same_type() {
1230 let mut convo = Conversation::new("test-session-12345678".to_string());
1231 convo.add_entry(ConversationEntry {
1232 parent_uuid: None,
1233 is_sidechain: false,
1234 entry_type: "assistant".to_string(),
1235 uuid: "uuid-multi".to_string(),
1236 timestamp: "2024-01-01T00:00:00Z".to_string(),
1237 session_id: Some("test-session".to_string()),
1238 message: Some(Message {
1239 role: MessageRole::Assistant,
1240 content: Some(MessageContent::Parts(vec![
1241 ContentPart::Text {
1242 text: "Reading files".to_string(),
1243 },
1244 ContentPart::ToolUse {
1245 id: "t1".to_string(),
1246 name: "Read".to_string(),
1247 input: serde_json::json!({"file_path": "/foo.rs"}),
1248 },
1249 ContentPart::ToolUse {
1250 id: "t2".to_string(),
1251 name: "Read".to_string(),
1252 input: serde_json::json!({"file_path": "/bar.rs"}),
1253 },
1254 ])),
1255 model: None,
1256 id: None,
1257 message_type: None,
1258 stop_reason: None,
1259 stop_sequence: None,
1260 usage: None,
1261 }),
1262 cwd: None,
1263 git_branch: None,
1264 version: None,
1265 user_type: None,
1266 request_id: None,
1267 tool_use_result: None,
1268 snapshot: None,
1269 message_id: None,
1270 extra: Default::default(),
1271 });
1272
1273 let config = DeriveConfig::default();
1274 let path = derive_path(&convo, &config);
1275
1276 assert_eq!(path.steps.len(), 2);
1278 assert_eq!(path.steps[1].step.id, "uuid-multi-tool-Read");
1279 assert_eq!(path.steps[1].change.len(), 2);
1281 assert!(path.steps[1].change.contains_key("/foo.rs"));
1282 assert!(path.steps[1].change.contains_key("/bar.rs"));
1283 }
1284
1285 #[test]
1286 fn test_derive_path_multiple_tool_uses_different_types() {
1287 let mut convo = Conversation::new("test-session-12345678".to_string());
1288 convo.add_entry(ConversationEntry {
1289 parent_uuid: None,
1290 is_sidechain: false,
1291 entry_type: "assistant".to_string(),
1292 uuid: "uuid-diff".to_string(),
1293 timestamp: "2024-01-01T00:00:00Z".to_string(),
1294 session_id: Some("test-session".to_string()),
1295 message: Some(Message {
1296 role: MessageRole::Assistant,
1297 content: Some(MessageContent::Parts(vec![
1298 ContentPart::Text {
1299 text: "Working".to_string(),
1300 },
1301 ContentPart::ToolUse {
1302 id: "t1".to_string(),
1303 name: "Read".to_string(),
1304 input: serde_json::json!({"file_path": "/foo.rs"}),
1305 },
1306 ContentPart::ToolUse {
1307 id: "t2".to_string(),
1308 name: "Bash".to_string(),
1309 input: serde_json::json!({"command": "cargo test"}),
1310 },
1311 ])),
1312 model: None,
1313 id: None,
1314 message_type: None,
1315 stop_reason: None,
1316 stop_sequence: None,
1317 usage: None,
1318 }),
1319 cwd: None,
1320 git_branch: None,
1321 version: None,
1322 user_type: None,
1323 request_id: None,
1324 tool_use_result: None,
1325 snapshot: None,
1326 message_id: None,
1327 extra: Default::default(),
1328 });
1329
1330 let config = DeriveConfig::default();
1331 let path = derive_path(&convo, &config);
1332
1333 assert_eq!(path.steps.len(), 3);
1335 assert_eq!(path.steps[1].step.id, "uuid-diff-tool-Read");
1336 assert_eq!(path.steps[2].step.id, "uuid-diff-tool-Bash");
1337
1338 let bash_change = &path.steps[2].change;
1340 assert_eq!(bash_change.len(), 1);
1341 let bash_key = bash_change.keys().next().unwrap();
1342 assert!(bash_key.starts_with("agent://claude/"));
1343 assert!(bash_key.contains("/tool/shell/"));
1344 }
1345
1346 #[test]
1347 fn test_derive_path_non_file_tool_artifact_key() {
1348 let mut convo = Conversation::new("sess-123".to_string());
1349 convo.add_entry(ConversationEntry {
1350 parent_uuid: None,
1351 is_sidechain: false,
1352 entry_type: "assistant".to_string(),
1353 uuid: "uuid-bash".to_string(),
1354 timestamp: "2024-01-01T00:00:00Z".to_string(),
1355 session_id: Some("test-session".to_string()),
1356 message: Some(Message {
1357 role: MessageRole::Assistant,
1358 content: Some(MessageContent::Parts(vec![
1359 ContentPart::Text {
1360 text: "Running".to_string(),
1361 },
1362 ContentPart::ToolUse {
1363 id: "tu-42".to_string(),
1364 name: "Bash".to_string(),
1365 input: serde_json::json!({"command": "ls"}),
1366 },
1367 ])),
1368 model: None,
1369 id: None,
1370 message_type: None,
1371 stop_reason: None,
1372 stop_sequence: None,
1373 usage: None,
1374 }),
1375 cwd: None,
1376 git_branch: None,
1377 version: None,
1378 user_type: None,
1379 request_id: None,
1380 tool_use_result: None,
1381 snapshot: None,
1382 message_id: None,
1383 extra: Default::default(),
1384 });
1385
1386 let config = DeriveConfig::default();
1387 let path = derive_path(&convo, &config);
1388
1389 let tool_step = &path.steps[1];
1390 let expected_key = "agent://claude/sess-123/tool/shell/tu-42";
1391 assert!(tool_step.change.contains_key(expected_key));
1392 }
1393
1394 #[test]
1395 fn test_derive_path_thinking_included_when_configured() {
1396 let mut convo = Conversation::new("test-session-12345678".to_string());
1397 convo.add_entry(ConversationEntry {
1398 parent_uuid: None,
1399 is_sidechain: false,
1400 entry_type: "assistant".to_string(),
1401 uuid: "uuid-think".to_string(),
1402 timestamp: "2024-01-01T00:00:00Z".to_string(),
1403 session_id: Some("test-session".to_string()),
1404 message: Some(Message {
1405 role: MessageRole::Assistant,
1406 content: Some(MessageContent::Parts(vec![
1407 ContentPart::Thinking {
1408 thinking: "Let me think about this".to_string(),
1409 signature: None,
1410 },
1411 ContentPart::Text {
1412 text: "Here is my answer".to_string(),
1413 },
1414 ])),
1415 model: None,
1416 id: None,
1417 message_type: None,
1418 stop_reason: None,
1419 stop_sequence: None,
1420 usage: None,
1421 }),
1422 cwd: None,
1423 git_branch: None,
1424 version: None,
1425 user_type: None,
1426 request_id: None,
1427 tool_use_result: None,
1428 snapshot: None,
1429 message_id: None,
1430 extra: Default::default(),
1431 });
1432
1433 let config = DeriveConfig {
1435 include_thinking: true,
1436 ..Default::default()
1437 };
1438 let path = derive_path(&convo, &config);
1439
1440 let convo_key = format!("agent://claude/{}", convo.session_id);
1441 let extra = &path.steps[0].change[&convo_key]
1442 .structural
1443 .as_ref()
1444 .unwrap()
1445 .extra;
1446 assert_eq!(extra["thinking"], "Let me think about this");
1447 assert_eq!(extra["text"], "Here is my answer");
1449 }
1450
1451 #[test]
1452 fn test_derive_path_thinking_excluded_by_default() {
1453 let mut convo = Conversation::new("test-session-12345678".to_string());
1454 convo.add_entry(ConversationEntry {
1455 parent_uuid: None,
1456 is_sidechain: false,
1457 entry_type: "assistant".to_string(),
1458 uuid: "uuid-think2".to_string(),
1459 timestamp: "2024-01-01T00:00:00Z".to_string(),
1460 session_id: Some("test-session".to_string()),
1461 message: Some(Message {
1462 role: MessageRole::Assistant,
1463 content: Some(MessageContent::Parts(vec![
1464 ContentPart::Thinking {
1465 thinking: "Secret thoughts".to_string(),
1466 signature: None,
1467 },
1468 ContentPart::Text {
1469 text: "Answer".to_string(),
1470 },
1471 ])),
1472 model: None,
1473 id: None,
1474 message_type: None,
1475 stop_reason: None,
1476 stop_sequence: None,
1477 usage: None,
1478 }),
1479 cwd: None,
1480 git_branch: None,
1481 version: None,
1482 user_type: None,
1483 request_id: None,
1484 tool_use_result: None,
1485 snapshot: None,
1486 message_id: None,
1487 extra: Default::default(),
1488 });
1489
1490 let config = DeriveConfig::default();
1491 let path = derive_path(&convo, &config);
1492
1493 let convo_key = format!("agent://claude/{}", convo.session_id);
1494 let extra = &path.steps[0].change[&convo_key]
1495 .structural
1496 .as_ref()
1497 .unwrap()
1498 .extra;
1499 assert!(!extra.contains_key("thinking"));
1500 }
1501
1502 #[test]
1503 fn test_derive_path_tool_step_does_not_advance_parent_chain() {
1504 let mut convo = Conversation::new("test-session-12345678".to_string());
1505 convo.add_entry(ConversationEntry {
1506 parent_uuid: None,
1507 is_sidechain: false,
1508 entry_type: "assistant".to_string(),
1509 uuid: "uuid-a1".to_string(),
1510 timestamp: "2024-01-01T00:00:00Z".to_string(),
1511 session_id: Some("test-session".to_string()),
1512 message: Some(Message {
1513 role: MessageRole::Assistant,
1514 content: Some(MessageContent::Parts(vec![
1515 ContentPart::Text {
1516 text: "Writing".to_string(),
1517 },
1518 ContentPart::ToolUse {
1519 id: "t1".to_string(),
1520 name: "Write".to_string(),
1521 input: serde_json::json!({"file_path": "/f.rs"}),
1522 },
1523 ])),
1524 model: None,
1525 id: None,
1526 message_type: None,
1527 stop_reason: None,
1528 stop_sequence: None,
1529 usage: None,
1530 }),
1531 cwd: None,
1532 git_branch: None,
1533 version: None,
1534 user_type: None,
1535 request_id: None,
1536 tool_use_result: None,
1537 snapshot: None,
1538 message_id: None,
1539 extra: Default::default(),
1540 });
1541 convo.add_entry(make_entry(
1542 "uuid-u2",
1543 MessageRole::User,
1544 "Next",
1545 "2024-01-01T00:00:01Z",
1546 ));
1547
1548 let config = DeriveConfig::default();
1549 let path = derive_path(&convo, &config);
1550
1551 assert_eq!(path.steps.len(), 3);
1553 assert_eq!(path.steps[2].step.parents, vec!["uuid-a1".to_string()]);
1555 }
1556
1557 #[test]
1558 fn test_derive_path_tool_input_preserved() {
1559 let mut convo = Conversation::new("test-session-12345678".to_string());
1560 let input_json = serde_json::json!({
1561 "file_path": "/src/main.rs",
1562 "content": "fn main() {}\n"
1563 });
1564 convo.add_entry(ConversationEntry {
1565 parent_uuid: None,
1566 is_sidechain: false,
1567 entry_type: "assistant".to_string(),
1568 uuid: "uuid-inp".to_string(),
1569 timestamp: "2024-01-01T00:00:00Z".to_string(),
1570 session_id: Some("test-session".to_string()),
1571 message: Some(Message {
1572 role: MessageRole::Assistant,
1573 content: Some(MessageContent::Parts(vec![
1574 ContentPart::Text {
1575 text: "Writing".to_string(),
1576 },
1577 ContentPart::ToolUse {
1578 id: "t1".to_string(),
1579 name: "Write".to_string(),
1580 input: input_json.clone(),
1581 },
1582 ])),
1583 model: None,
1584 id: None,
1585 message_type: None,
1586 stop_reason: None,
1587 stop_sequence: None,
1588 usage: None,
1589 }),
1590 cwd: None,
1591 git_branch: None,
1592 version: None,
1593 user_type: None,
1594 request_id: None,
1595 tool_use_result: None,
1596 snapshot: None,
1597 message_id: None,
1598 extra: Default::default(),
1599 });
1600
1601 let config = DeriveConfig::default();
1602 let path = derive_path(&convo, &config);
1603
1604 let tool_step = &path.steps[1];
1605 let change = &tool_step.change["/src/main.rs"];
1606 let extra = &change.structural.as_ref().unwrap().extra;
1607 assert_eq!(extra["input"], input_json);
1608 }
1609
1610 #[test]
1611 fn test_derive_path_edit_tool_emits_unified_diff() {
1612 let mut convo = Conversation::new("test-session-12345678".to_string());
1613 let input_json = serde_json::json!({
1614 "file_path": "/src/login.rs",
1615 "old_string": "validate_token()",
1616 "new_string": "validate_token_v2()",
1617 });
1618 convo.add_entry(ConversationEntry {
1619 parent_uuid: None,
1620 is_sidechain: false,
1621 entry_type: "assistant".to_string(),
1622 uuid: "uuid-edit".to_string(),
1623 timestamp: "2024-01-01T00:00:00Z".to_string(),
1624 session_id: Some("test-session".to_string()),
1625 message: Some(Message {
1626 role: MessageRole::Assistant,
1627 content: Some(MessageContent::Parts(vec![ContentPart::ToolUse {
1628 id: "t-edit".to_string(),
1629 name: "Edit".to_string(),
1630 input: input_json,
1631 }])),
1632 model: None,
1633 id: None,
1634 message_type: None,
1635 stop_reason: None,
1636 stop_sequence: None,
1637 usage: None,
1638 }),
1639 cwd: None,
1640 git_branch: None,
1641 version: None,
1642 user_type: None,
1643 request_id: None,
1644 tool_use_result: None,
1645 snapshot: None,
1646 message_id: None,
1647 extra: Default::default(),
1648 });
1649
1650 let path = derive_path(&convo, &DeriveConfig::default());
1651 let tool_step = &path.steps[1];
1653 let ch = &tool_step.change["/src/login.rs"];
1654 let raw = ch
1655 .raw
1656 .as_deref()
1657 .expect("edit tool should emit unified diff");
1658 assert!(raw.contains("--- a/src/login.rs"), "{}", raw);
1661 assert!(raw.contains("+++ b/src/login.rs"), "{}", raw);
1662 assert!(
1663 !raw.contains("a//"),
1664 "header should not double-slash: {}",
1665 raw
1666 );
1667 assert!(raw.contains("-validate_token()"), "{}", raw);
1668 assert!(raw.contains("+validate_token_v2()"), "{}", raw);
1669
1670 assert_eq!(tool_step.step.parents, vec![path.steps[0].step.id.clone()]);
1674 }
1675
1676 #[test]
1679 fn test_derive_path_tool_result_assembled() {
1680 use crate::types::ToolResultContent;
1681
1682 let mut convo = Conversation::new("test-session-12345678".to_string());
1683
1684 convo.add_entry(ConversationEntry {
1686 parent_uuid: None,
1687 is_sidechain: false,
1688 entry_type: "assistant".to_string(),
1689 uuid: "uuid-assist-1".to_string(),
1690 timestamp: "2024-01-01T00:00:00Z".to_string(),
1691 session_id: Some("test-session".to_string()),
1692 message: Some(Message {
1693 role: MessageRole::Assistant,
1694 content: Some(MessageContent::Parts(vec![
1695 ContentPart::Text {
1696 text: "Let me read that file".to_string(),
1697 },
1698 ContentPart::ToolUse {
1699 id: "tu-read-1".to_string(),
1700 name: "Read".to_string(),
1701 input: serde_json::json!({"file_path": "/src/lib.rs"}),
1702 },
1703 ])),
1704 model: None,
1705 id: None,
1706 message_type: None,
1707 stop_reason: None,
1708 stop_sequence: None,
1709 usage: None,
1710 }),
1711 cwd: None,
1712 git_branch: None,
1713 version: None,
1714 user_type: None,
1715 request_id: None,
1716 tool_use_result: None,
1717 snapshot: None,
1718 message_id: None,
1719 extra: Default::default(),
1720 });
1721
1722 convo.add_entry(ConversationEntry {
1724 parent_uuid: None,
1725 is_sidechain: false,
1726 entry_type: "user".to_string(),
1727 uuid: "uuid-result-1".to_string(),
1728 timestamp: "2024-01-01T00:00:01Z".to_string(),
1729 session_id: Some("test-session".to_string()),
1730 message: Some(Message {
1731 role: MessageRole::User,
1732 content: Some(MessageContent::Parts(vec![ContentPart::ToolResult {
1733 tool_use_id: "tu-read-1".to_string(),
1734 content: ToolResultContent::Text("fn main() {}".to_string()),
1735 is_error: false,
1736 }])),
1737 model: None,
1738 id: None,
1739 message_type: None,
1740 stop_reason: None,
1741 stop_sequence: None,
1742 usage: None,
1743 }),
1744 cwd: None,
1745 git_branch: None,
1746 version: None,
1747 user_type: None,
1748 request_id: None,
1749 tool_use_result: None,
1750 snapshot: None,
1751 message_id: None,
1752 extra: Default::default(),
1753 });
1754
1755 let config = DeriveConfig::default();
1756 let path = derive_path(&convo, &config);
1757
1758 assert_eq!(path.steps.len(), 2);
1760
1761 let tool_step = &path.steps[1];
1763 assert_eq!(tool_step.step.id, "uuid-assist-1-tool-Read");
1764 let change = &tool_step.change["/src/lib.rs"];
1765 let extra = &change.structural.as_ref().unwrap().extra;
1766 assert_eq!(extra["result"], "fn main() {}");
1767 assert_eq!(extra["is_error"], false);
1768 }
1769
1770 #[test]
1771 fn test_derive_path_tool_result_error() {
1772 use crate::types::ToolResultContent;
1773
1774 let mut convo = Conversation::new("test-session-12345678".to_string());
1775
1776 convo.add_entry(ConversationEntry {
1777 parent_uuid: None,
1778 is_sidechain: false,
1779 entry_type: "assistant".to_string(),
1780 uuid: "uuid-assist-err".to_string(),
1781 timestamp: "2024-01-01T00:00:00Z".to_string(),
1782 session_id: Some("test-session".to_string()),
1783 message: Some(Message {
1784 role: MessageRole::Assistant,
1785 content: Some(MessageContent::Parts(vec![
1786 ContentPart::Text {
1787 text: "Running command".to_string(),
1788 },
1789 ContentPart::ToolUse {
1790 id: "tu-bash-1".to_string(),
1791 name: "Bash".to_string(),
1792 input: serde_json::json!({"command": "cargo test"}),
1793 },
1794 ])),
1795 model: None,
1796 id: None,
1797 message_type: None,
1798 stop_reason: None,
1799 stop_sequence: None,
1800 usage: None,
1801 }),
1802 cwd: None,
1803 git_branch: None,
1804 version: None,
1805 user_type: None,
1806 request_id: None,
1807 tool_use_result: None,
1808 snapshot: None,
1809 message_id: None,
1810 extra: Default::default(),
1811 });
1812
1813 convo.add_entry(ConversationEntry {
1815 parent_uuid: None,
1816 is_sidechain: false,
1817 entry_type: "user".to_string(),
1818 uuid: "uuid-result-err".to_string(),
1819 timestamp: "2024-01-01T00:00:01Z".to_string(),
1820 session_id: Some("test-session".to_string()),
1821 message: Some(Message {
1822 role: MessageRole::User,
1823 content: Some(MessageContent::Parts(vec![ContentPart::ToolResult {
1824 tool_use_id: "tu-bash-1".to_string(),
1825 content: ToolResultContent::Text("compilation failed".to_string()),
1826 is_error: true,
1827 }])),
1828 model: None,
1829 id: None,
1830 message_type: None,
1831 stop_reason: None,
1832 stop_sequence: None,
1833 usage: None,
1834 }),
1835 cwd: None,
1836 git_branch: None,
1837 version: None,
1838 user_type: None,
1839 request_id: None,
1840 tool_use_result: None,
1841 snapshot: None,
1842 message_id: None,
1843 extra: Default::default(),
1844 });
1845
1846 let config = DeriveConfig::default();
1847 let path = derive_path(&convo, &config);
1848
1849 let tool_step = &path.steps[1];
1850 let bash_key = tool_step.change.keys().next().unwrap();
1851 let extra = &tool_step.change[bash_key]
1852 .structural
1853 .as_ref()
1854 .unwrap()
1855 .extra;
1856 assert_eq!(extra["result"], "compilation failed");
1857 assert_eq!(extra["is_error"], true);
1858 }
1859
1860 #[test]
1863 fn test_derive_path_init_step_with_cwd() {
1864 let mut convo = Conversation::new("test-session-12345678".to_string());
1865 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1866 entry.cwd = Some("/home/user/project".to_string());
1867 entry.version = Some("1.2.3".to_string());
1868 convo.add_entry(entry);
1869
1870 let config = DeriveConfig::default();
1871 let path = derive_path(&convo, &config);
1872
1873 assert_eq!(path.steps.len(), 2);
1875
1876 let init = &path.steps[0];
1877 assert_eq!(init.step.id, "test-session-12345678-init");
1878 assert_eq!(init.step.actor, "tool:claude-code");
1879 assert!(init.step.parents.is_empty());
1880
1881 let convo_key = format!("agent://claude/{}", convo.session_id);
1882 let structural = init.change[&convo_key].structural.as_ref().unwrap();
1883 assert_eq!(structural.change_type, "conversation.init");
1884 assert_eq!(structural.extra["working_dir"], "/home/user/project");
1885 assert_eq!(structural.extra["version"], "1.2.3");
1886 }
1887
1888 #[test]
1889 fn test_derive_path_init_step_is_parent_of_first() {
1890 let mut convo = Conversation::new("test-session-12345678".to_string());
1891 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1892 entry.cwd = Some("/project".to_string());
1893 convo.add_entry(entry);
1894
1895 let config = DeriveConfig::default();
1896 let path = derive_path(&convo, &config);
1897
1898 assert_eq!(path.steps.len(), 2);
1900 assert_eq!(
1901 path.steps[1].step.parents,
1902 vec!["test-session-12345678-init".to_string()]
1903 );
1904 }
1905
1906 #[test]
1907 fn test_derive_path_init_step_with_git_branch() {
1908 let mut convo = Conversation::new("test-session-12345678".to_string());
1909 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1910 entry.git_branch = Some("feature/foo".to_string());
1911 convo.add_entry(entry);
1912
1913 let config = DeriveConfig::default();
1914 let path = derive_path(&convo, &config);
1915
1916 assert_eq!(path.steps.len(), 2);
1917 let init = &path.steps[0];
1918 let convo_key = format!("agent://claude/{}", convo.session_id);
1919 let structural = init.change[&convo_key].structural.as_ref().unwrap();
1920 assert_eq!(structural.extra["vcs_branch"], "feature/foo");
1921 }
1922
1923 #[test]
1924 fn test_derive_path_no_init_step_without_metadata() {
1925 let entries = vec![make_entry(
1927 "uuid-1",
1928 MessageRole::User,
1929 "Hello",
1930 "2024-01-01T00:00:00Z",
1931 )];
1932 let convo = make_conversation(entries);
1933 let config = DeriveConfig::default();
1934
1935 let path = derive_path(&convo, &config);
1936
1937 assert_eq!(path.steps.len(), 1);
1939 assert_eq!(path.steps[0].step.id, "uuid-1");
1940 }
1941
1942 #[test]
1945 fn test_derive_path_captures_cwd_and_git_branch() {
1946 let mut convo = Conversation::new("test-session-12345678".to_string());
1947 let mut entry = make_entry(
1948 "uuid-meta-1",
1949 MessageRole::User,
1950 "Hello",
1951 "2024-01-01T00:00:00Z",
1952 );
1953 entry.cwd = Some("/home/user/project".to_string());
1954 entry.git_branch = Some("main".to_string());
1955 convo.add_entry(entry);
1956
1957 let config = DeriveConfig::default();
1958 let path = derive_path(&convo, &config);
1959
1960 let convo_key = format!("agent://claude/{}", convo.session_id);
1962 let append_step = path
1963 .steps
1964 .iter()
1965 .find(|s| {
1966 s.change
1967 .get(&convo_key)
1968 .and_then(|c| c.structural.as_ref())
1969 .is_some_and(|sc| sc.change_type == "conversation.append")
1970 })
1971 .expect("should have a conversation.append step");
1972 let extra = &append_step.change[&convo_key]
1973 .structural
1974 .as_ref()
1975 .unwrap()
1976 .extra;
1977
1978 assert_eq!(extra["cwd"], "/home/user/project");
1979 assert_eq!(extra["git_branch"], "main");
1980 }
1981
1982 #[test]
1983 fn test_derive_path_captures_version() {
1984 let mut convo = Conversation::new("test-session-12345678".to_string());
1985 let mut entry = make_entry(
1986 "uuid-meta-2",
1987 MessageRole::User,
1988 "Hello",
1989 "2024-01-01T00:00:00Z",
1990 );
1991 entry.version = Some("1.5.0".to_string());
1992 convo.add_entry(entry);
1993
1994 let config = DeriveConfig::default();
1995 let path = derive_path(&convo, &config);
1996
1997 let convo_key = format!("agent://claude/{}", convo.session_id);
1998 let append_step = path
1999 .steps
2000 .iter()
2001 .find(|s| {
2002 s.change
2003 .get(&convo_key)
2004 .and_then(|c| c.structural.as_ref())
2005 .is_some_and(|sc| sc.change_type == "conversation.append")
2006 })
2007 .expect("should have a conversation.append step");
2008 let extra = &append_step.change[&convo_key]
2009 .structural
2010 .as_ref()
2011 .unwrap()
2012 .extra;
2013
2014 assert_eq!(extra["version"], "1.5.0");
2015 }
2016
2017 #[test]
2018 fn test_derive_path_captures_user_type_and_request_id() {
2019 let mut convo = Conversation::new("test-session-12345678".to_string());
2020 convo.add_entry(ConversationEntry {
2021 parent_uuid: None,
2022 is_sidechain: false,
2023 entry_type: "assistant".to_string(),
2024 uuid: "uuid-meta-3".to_string(),
2025 timestamp: "2024-01-01T00:00:00Z".to_string(),
2026 session_id: Some("test-session".to_string()),
2027 message: Some(Message {
2028 role: MessageRole::Assistant,
2029 content: Some(MessageContent::Text("Response".to_string())),
2030 model: Some("claude-sonnet-4-5-20250929".to_string()),
2031 id: None,
2032 message_type: None,
2033 stop_reason: None,
2034 stop_sequence: None,
2035 usage: None,
2036 }),
2037 cwd: None,
2038 git_branch: None,
2039 version: None,
2040 user_type: Some("external".to_string()),
2041 request_id: Some("req-abc-123".to_string()),
2042 tool_use_result: None,
2043 snapshot: None,
2044 message_id: None,
2045 extra: Default::default(),
2046 });
2047
2048 let config = DeriveConfig::default();
2049 let path = derive_path(&convo, &config);
2050
2051 let convo_key = format!("agent://claude/{}", convo.session_id);
2052 let extra = &path.steps[0].change[&convo_key]
2053 .structural
2054 .as_ref()
2055 .unwrap()
2056 .extra;
2057
2058 assert_eq!(extra["user_type"], "external");
2059 assert_eq!(extra["request_id"], "req-abc-123");
2060 }
2061
2062 #[test]
2063 fn test_derive_path_captures_entry_extra() {
2064 let mut convo = Conversation::new("test-session-12345678".to_string());
2065 let mut entry_extra = HashMap::new();
2066 entry_extra.insert("entrypoint".to_string(), serde_json::json!("cli"));
2067 entry_extra.insert("isMeta".to_string(), serde_json::json!(true));
2068 entry_extra.insert("slug".to_string(), serde_json::json!("my-slug"));
2069
2070 convo.add_entry(ConversationEntry {
2071 parent_uuid: None,
2072 is_sidechain: false,
2073 entry_type: "user".to_string(),
2074 uuid: "uuid-meta-4".to_string(),
2075 timestamp: "2024-01-01T00:00:00Z".to_string(),
2076 session_id: Some("test-session".to_string()),
2077 message: Some(Message {
2078 role: MessageRole::User,
2079 content: Some(MessageContent::Text("Hello".to_string())),
2080 model: None,
2081 id: None,
2082 message_type: None,
2083 stop_reason: None,
2084 stop_sequence: None,
2085 usage: None,
2086 }),
2087 cwd: None,
2088 git_branch: None,
2089 version: None,
2090 user_type: None,
2091 request_id: None,
2092 tool_use_result: None,
2093 snapshot: None,
2094 message_id: None,
2095 extra: entry_extra,
2096 });
2097
2098 let config = DeriveConfig::default();
2099 let path = derive_path(&convo, &config);
2100
2101 let convo_key = format!("agent://claude/{}", convo.session_id);
2102 let extra = &path.steps[0].change[&convo_key]
2103 .structural
2104 .as_ref()
2105 .unwrap()
2106 .extra;
2107
2108 let entry_extra_val = extra
2109 .get("entry_extra")
2110 .expect("entry_extra should be present");
2111 assert_eq!(entry_extra_val["entrypoint"], "cli");
2112 assert_eq!(entry_extra_val["isMeta"], true);
2113 assert_eq!(entry_extra_val["slug"], "my-slug");
2114 }
2115
2116 #[test]
2117 fn test_derive_path_missing_metadata_not_included() {
2118 let entries = vec![make_entry(
2120 "uuid-meta-5",
2121 MessageRole::User,
2122 "Hello",
2123 "2024-01-01T00:00:00Z",
2124 )];
2125 let convo = make_conversation(entries);
2126 let config = DeriveConfig::default();
2127
2128 let path = derive_path(&convo, &config);
2129
2130 let convo_key = format!("agent://claude/{}", convo.session_id);
2131 let extra = &path.steps[0].change[&convo_key]
2132 .structural
2133 .as_ref()
2134 .unwrap()
2135 .extra;
2136
2137 assert!(!extra.contains_key("cwd"));
2139 assert!(!extra.contains_key("version"));
2140 assert!(!extra.contains_key("git_branch"));
2141 assert!(!extra.contains_key("user_type"));
2142 assert!(!extra.contains_key("request_id"));
2143 assert!(!extra.contains_key("entry_extra"));
2144 }
2145
2146 #[test]
2147 fn test_derive_path_init_step_actor_registered() {
2148 let mut convo = Conversation::new("test-session-12345678".to_string());
2149 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
2150 entry.cwd = Some("/project".to_string());
2151 convo.add_entry(entry);
2152
2153 let config = DeriveConfig::default();
2154 let path = derive_path(&convo, &config);
2155
2156 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
2157 assert!(actors.contains_key("tool:claude-code"));
2158 assert_eq!(
2159 actors["tool:claude-code"].name.as_deref(),
2160 Some("Claude Code")
2161 );
2162 }
2163
2164 fn make_event_entry(uuid: &str, entry_type: &str, timestamp: &str) -> ConversationEntry {
2167 ConversationEntry {
2168 parent_uuid: None,
2169 is_sidechain: false,
2170 entry_type: entry_type.to_string(),
2171 uuid: uuid.to_string(),
2172 timestamp: timestamp.to_string(),
2173 session_id: Some("test-session".to_string()),
2174 cwd: None,
2175 git_branch: None,
2176 version: None,
2177 message: None,
2178 user_type: None,
2179 request_id: None,
2180 tool_use_result: None,
2181 snapshot: None,
2182 message_id: None,
2183 extra: Default::default(),
2184 }
2185 }
2186
2187 #[test]
2188 fn test_derive_path_attachment_entry_captured_as_event() {
2189 let mut convo = Conversation::new("test-session-12345678".to_string());
2190 convo.add_entry(make_entry(
2191 "uuid-1",
2192 MessageRole::User,
2193 "Hello",
2194 "2024-01-01T00:00:00Z",
2195 ));
2196 convo.add_entry(make_event_entry(
2197 "uuid-attach-1",
2198 "attachment",
2199 "2024-01-01T00:00:01Z",
2200 ));
2201 convo.add_entry(make_entry(
2202 "uuid-2",
2203 MessageRole::Assistant,
2204 "Hi",
2205 "2024-01-01T00:00:02Z",
2206 ));
2207
2208 let config = DeriveConfig::default();
2209 let path = derive_path(&convo, &config);
2210
2211 assert_eq!(path.steps.len(), 3);
2213
2214 let event_step = &path.steps[1];
2215 assert_eq!(event_step.step.id, "uuid-attach-1");
2216 assert_eq!(event_step.step.actor, "tool:claude-code");
2217
2218 let convo_key = format!("agent://claude/{}", convo.session_id);
2219 let structural = event_step.change[&convo_key].structural.as_ref().unwrap();
2220 assert_eq!(structural.change_type, "conversation.event");
2221 assert_eq!(structural.extra["entry_type"], "attachment");
2222 }
2223
2224 #[test]
2225 fn test_derive_path_system_entry_captured_as_event() {
2226 let mut convo = Conversation::new("test-session-12345678".to_string());
2227 convo.add_entry(make_entry(
2228 "uuid-sys",
2229 MessageRole::System,
2230 "Turn duration: 5s",
2231 "2024-01-01T00:00:00Z",
2232 ));
2233
2234 let config = DeriveConfig::default();
2235 let path = derive_path(&convo, &config);
2236
2237 assert_eq!(path.steps.len(), 1);
2238 let event_step = &path.steps[0];
2239 assert_eq!(event_step.step.actor, "tool:claude-code");
2240
2241 let convo_key = format!("agent://claude/{}", convo.session_id);
2242 let structural = event_step.change[&convo_key].structural.as_ref().unwrap();
2243 assert_eq!(structural.change_type, "conversation.event");
2244 assert_eq!(structural.extra["entry_type"], "system");
2245 assert_eq!(structural.extra["text"], "Turn duration: 5s");
2246 }
2247
2248 #[test]
2249 fn test_derive_path_empty_uuid_entry_gets_synthetic_id() {
2250 let mut convo = Conversation::new("test-session-12345678".to_string());
2251 let mut event = make_event_entry("", "permission-mode", "2024-01-01T00:00:00Z");
2252 event.uuid = String::new();
2253 convo.add_entry(event);
2254
2255 let config = DeriveConfig::default();
2256 let path = derive_path(&convo, &config);
2257
2258 assert_eq!(path.steps.len(), 1);
2259 assert_eq!(path.steps[0].step.id, "test-session-12345678-event-0");
2261 }
2262
2263 #[test]
2264 fn test_derive_path_event_steps_dont_advance_parent_chain() {
2265 let mut convo = Conversation::new("test-session-12345678".to_string());
2266 convo.add_entry(make_entry(
2267 "uuid-u1",
2268 MessageRole::User,
2269 "Hello",
2270 "2024-01-01T00:00:00Z",
2271 ));
2272 convo.add_entry(make_event_entry(
2273 "uuid-attach",
2274 "attachment",
2275 "2024-01-01T00:00:01Z",
2276 ));
2277 convo.add_entry(make_entry(
2278 "uuid-a1",
2279 MessageRole::Assistant,
2280 "Hi",
2281 "2024-01-01T00:00:02Z",
2282 ));
2283
2284 let config = DeriveConfig::default();
2285 let path = derive_path(&convo, &config);
2286
2287 assert_eq!(path.steps.len(), 3);
2288 assert_eq!(path.steps[2].step.parents, vec!["uuid-u1".to_string()]);
2290 assert_eq!(path.path.head, "uuid-a1");
2292 }
2293
2294 #[test]
2295 fn test_derive_path_event_step_extras_contain_metadata() {
2296 let mut convo = Conversation::new("test-session-12345678".to_string());
2297 let mut event =
2298 make_event_entry("uuid-ev1", "file-history-snapshot", "2024-01-01T00:00:00Z");
2299 event.cwd = Some("/home/user/project".to_string());
2300 event.version = Some("1.5.0".to_string());
2301 event.git_branch = Some("main".to_string());
2302 event.user_type = Some("external".to_string());
2303 event.snapshot = Some(serde_json::json!({"files": ["/src/main.rs"]}));
2304 event.message_id = Some("msg-123".to_string());
2305 convo.add_entry(event);
2306
2307 let config = DeriveConfig::default();
2308 let path = derive_path(&convo, &config);
2309
2310 let convo_key = format!("agent://claude/{}", convo.session_id);
2312 let event_step = path
2314 .steps
2315 .iter()
2316 .find(|s| {
2317 s.change
2318 .get(&convo_key)
2319 .and_then(|c| c.structural.as_ref())
2320 .is_some_and(|sc| sc.change_type == "conversation.event")
2321 })
2322 .expect("should have a conversation.event step");
2323 let extra = &event_step.change[&convo_key]
2324 .structural
2325 .as_ref()
2326 .unwrap()
2327 .extra;
2328
2329 assert_eq!(extra["entry_type"], "file-history-snapshot");
2330 assert_eq!(extra["cwd"], "/home/user/project");
2331 assert_eq!(extra["version"], "1.5.0");
2332 assert_eq!(extra["git_branch"], "main");
2333 assert_eq!(extra["user_type"], "external");
2334 assert_eq!(
2335 extra["snapshot"],
2336 serde_json::json!({"files": ["/src/main.rs"]})
2337 );
2338 assert_eq!(extra["message_id"], "msg-123");
2339 }
2340
2341 #[test]
2342 fn test_derive_path_event_entry_extra_preserved() {
2343 let mut convo = Conversation::new("test-session-12345678".to_string());
2344 let mut event = make_event_entry("uuid-ev2", "attachment", "2024-01-01T00:00:00Z");
2345 let mut extras = HashMap::new();
2346 extras.insert("hookName".to_string(), serde_json::json!("pre-tool-use"));
2347 extras.insert("toolName".to_string(), serde_json::json!("Bash"));
2348 event.extra = extras;
2349 convo.add_entry(event);
2350
2351 let config = DeriveConfig::default();
2352 let path = derive_path(&convo, &config);
2353
2354 let convo_key = format!("agent://claude/{}", convo.session_id);
2355 let extra = &path.steps[0].change[&convo_key]
2356 .structural
2357 .as_ref()
2358 .unwrap()
2359 .extra;
2360
2361 let entry_extra = extra
2362 .get("entry_extra")
2363 .expect("entry_extra should be present");
2364 assert_eq!(entry_extra["hookName"], "pre-tool-use");
2365 assert_eq!(entry_extra["toolName"], "Bash");
2366 }
2367
2368 #[test]
2369 fn test_derive_path_event_with_parent_uuid() {
2370 let mut convo = Conversation::new("test-session-12345678".to_string());
2371 convo.add_entry(make_entry(
2372 "uuid-u1",
2373 MessageRole::User,
2374 "Hello",
2375 "2024-01-01T00:00:00Z",
2376 ));
2377 let mut event = make_event_entry("uuid-ev-parent", "attachment", "2024-01-01T00:00:01Z");
2378 event.parent_uuid = Some("uuid-u1".to_string());
2379 convo.add_entry(event);
2380
2381 let config = DeriveConfig::default();
2382 let path = derive_path(&convo, &config);
2383
2384 assert_eq!(path.steps[1].step.parents, vec!["uuid-u1".to_string()]);
2386 }
2387
2388 #[test]
2389 fn test_resolve_local_dir_prefers_entry_cwd() {
2390 let dir = resolve_local_dir(
2391 Some("/from/config"),
2392 Some("/from/convo"),
2393 Some("/from/entry"),
2394 )
2395 .unwrap();
2396 assert_eq!(dir, "/from/entry");
2397 }
2398
2399 #[test]
2400 fn test_resolve_local_dir_falls_back_to_config_then_convo() {
2401 let dir = resolve_local_dir(Some("/from/config"), Some("/from/convo"), None).unwrap();
2402 assert_eq!(dir, "/from/config");
2403 let dir = resolve_local_dir(None, Some("/from/convo"), None).unwrap();
2404 assert_eq!(dir, "/from/convo");
2405 assert!(resolve_local_dir(None, None, None).is_none());
2406 }
2407
2408 #[test]
2409 fn test_resolve_local_dir_strips_file_prefix() {
2410 let dir = resolve_local_dir(Some("file:///usr/local/src"), None, None).unwrap();
2411 assert_eq!(dir, "/usr/local/src");
2412 }
2413
2414 #[test]
2419 fn test_write_tool_before_state_comes_from_git_head() {
2420 use std::process::Command;
2421 let tmp = tempfile::tempdir().unwrap();
2422 let root = tmp.path();
2423
2424 let run = |args: &[&str]| {
2426 let out = Command::new("git")
2427 .current_dir(root)
2428 .args(args)
2429 .output()
2430 .expect("git on PATH");
2431 assert!(
2432 out.status.success(),
2433 "git {:?} failed: {}",
2434 args,
2435 String::from_utf8_lossy(&out.stderr)
2436 );
2437 };
2438 run(&["init", "-q", "-b", "main"]);
2439 run(&["config", "user.email", "test@example.com"]);
2440 run(&["config", "user.name", "Test"]);
2441 run(&["config", "commit.gpgsign", "false"]);
2442 std::fs::write(root.join("hello.txt"), "old-content\n").unwrap();
2443 run(&["add", "hello.txt"]);
2444 run(&["commit", "-q", "-m", "init"]);
2445
2446 let mut convo = Conversation::new("test-session-42".to_string());
2449 let mut entry = make_entry(
2450 "uuid-w",
2451 MessageRole::Assistant,
2452 "writing",
2453 "2024-01-01T00:00:00Z",
2454 );
2455 entry.cwd = Some(root.to_string_lossy().into_owned());
2456 if let Some(msg) = &mut entry.message {
2458 msg.content = Some(MessageContent::Parts(vec![ContentPart::ToolUse {
2459 id: "tu-1".into(),
2460 name: "Write".into(),
2461 input: json!({
2462 "file_path": root.join("hello.txt").to_string_lossy(),
2463 "content": "new-content\n",
2464 }),
2465 }]));
2466 }
2467 convo.add_entry(entry);
2468
2469 let path = derive_path(&convo, &DeriveConfig::default());
2470
2471 let artifact_key = root.join("hello.txt").to_string_lossy().into_owned();
2473 let change = path
2474 .steps
2475 .iter()
2476 .find_map(|s| s.change.get(&artifact_key))
2477 .expect("tool step with hello.txt artifact");
2478 let raw = change.raw.as_deref().expect("Write should emit raw diff");
2479 assert!(
2480 raw.contains("-old-content"),
2481 "expected removal line, got:\n{raw}"
2482 );
2483 assert!(
2484 raw.contains("+new-content"),
2485 "expected addition line, got:\n{raw}"
2486 );
2487 }
2488
2489 #[test]
2493 fn test_write_tool_falls_back_to_addition_only_without_git() {
2494 let tmp = tempfile::tempdir().unwrap();
2495 let root = tmp.path();
2496
2497 let mut convo = Conversation::new("test-session-43".to_string());
2498 let mut entry = make_entry(
2499 "uuid-w",
2500 MessageRole::Assistant,
2501 "writing",
2502 "2024-01-01T00:00:00Z",
2503 );
2504 entry.cwd = Some(root.to_string_lossy().into_owned());
2505 if let Some(msg) = &mut entry.message {
2506 msg.content = Some(MessageContent::Parts(vec![ContentPart::ToolUse {
2507 id: "tu-1".into(),
2508 name: "Write".into(),
2509 input: json!({
2510 "file_path": root.join("new.txt").to_string_lossy(),
2511 "content": "fresh\n",
2512 }),
2513 }]));
2514 }
2515 convo.add_entry(entry);
2516
2517 let path = derive_path(&convo, &DeriveConfig::default());
2518 let artifact_key = root.join("new.txt").to_string_lossy().into_owned();
2519 let raw = path
2520 .steps
2521 .iter()
2522 .find_map(|s| s.change.get(&artifact_key))
2523 .and_then(|c| c.raw.as_deref())
2524 .expect("Write should emit raw diff");
2525 assert!(raw.contains("+fresh"));
2526 assert!(
2528 !raw.lines()
2529 .any(|l| l.starts_with('-') && !l.starts_with("---")),
2530 "unexpected removal line in:\n{raw}"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_derive_path_event_with_tool_use_result() {
2536 let mut convo = Conversation::new("test-session-12345678".to_string());
2537 let mut event = make_event_entry("uuid-ev-tur", "attachment", "2024-01-01T00:00:00Z");
2538 event.tool_use_result = Some(serde_json::json!({
2539 "tool_use_id": "tu-123",
2540 "content": "hook output"
2541 }));
2542 convo.add_entry(event);
2543
2544 let config = DeriveConfig::default();
2545 let path = derive_path(&convo, &config);
2546
2547 let convo_key = format!("agent://claude/{}", convo.session_id);
2548 let extra = &path.steps[0].change[&convo_key]
2549 .structural
2550 .as_ref()
2551 .unwrap()
2552 .extra;
2553
2554 assert_eq!(extra["tool_use_result"]["tool_use_id"], "tu-123");
2555 assert_eq!(extra["tool_use_result"]["content"], "hook output");
2556 }
2557}