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 {
604 uri,
605 ref_str: None,
606 branch: None,
607 }),
608 head,
609 graph_ref: None,
610 },
611 steps,
612 meta: Some(PathMeta {
613 title: Some(format!("Claude session: {}", session_short)),
614 source: Some("claude-code".to_string()),
615 actors: if actors.is_empty() {
616 None
617 } else {
618 Some(actors)
619 },
620 ..Default::default()
621 }),
622 }
623}
624
625pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
627 conversations
628 .iter()
629 .map(|c| derive_path(c, config))
630 .collect()
631}
632
633fn safe_prefix(s: &str, n: usize) -> String {
635 s.chars().take(n).collect()
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use crate::types::{ContentPart, ConversationEntry, Message, MessageContent, Usage};
642
643 fn make_entry(
644 uuid: &str,
645 role: MessageRole,
646 content: &str,
647 timestamp: &str,
648 ) -> ConversationEntry {
649 ConversationEntry {
650 parent_uuid: None,
651 is_sidechain: false,
652 entry_type: match role {
653 MessageRole::User => "user",
654 MessageRole::Assistant => "assistant",
655 MessageRole::System => "system",
656 }
657 .to_string(),
658 uuid: uuid.to_string(),
659 timestamp: timestamp.to_string(),
660 session_id: Some("test-session".to_string()),
661 cwd: None,
662 git_branch: None,
663 version: None,
664 message: Some(Message {
665 role,
666 content: Some(MessageContent::Text(content.to_string())),
667 model: None,
668 id: None,
669 message_type: None,
670 stop_reason: None,
671 stop_sequence: None,
672 usage: None,
673 }),
674 user_type: None,
675 request_id: None,
676 tool_use_result: None,
677 snapshot: None,
678 message_id: None,
679 extra: Default::default(),
680 }
681 }
682
683 fn make_conversation(entries: Vec<ConversationEntry>) -> Conversation {
684 let mut convo = Conversation::new("test-session-12345678".to_string());
685 for entry in entries {
686 convo.add_entry(entry);
687 }
688 convo
689 }
690
691 #[test]
694 fn test_safe_prefix_normal() {
695 assert_eq!(safe_prefix("abcdef1234", 8), "abcdef12");
696 }
697
698 #[test]
699 fn test_safe_prefix_short() {
700 assert_eq!(safe_prefix("abc", 8), "abc");
701 }
702
703 #[test]
704 fn test_safe_prefix_unicode() {
705 assert_eq!(
706 safe_prefix("\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30B9}\u{30C8}", 3),
707 "\u{65E5}\u{672C}\u{8A9E}"
708 );
709 }
710
711 #[test]
714 fn test_tool_category_str() {
715 assert_eq!(tool_category_str("Read"), "file_read");
716 assert_eq!(tool_category_str("Write"), "file_write");
717 assert_eq!(tool_category_str("Edit"), "file_write");
718 assert_eq!(tool_category_str("Glob"), "file_search");
719 assert_eq!(tool_category_str("Grep"), "file_search");
720 assert_eq!(tool_category_str("Bash"), "shell");
721 assert_eq!(tool_category_str("WebFetch"), "network");
722 assert_eq!(tool_category_str("Task"), "delegation");
723 assert_eq!(tool_category_str("SomethingElse"), "unknown");
724 }
725
726 #[test]
727 fn test_is_file_tool() {
728 assert!(is_file_tool("Read"));
729 assert!(is_file_tool("Write"));
730 assert!(is_file_tool("Edit"));
731 assert!(is_file_tool("Glob"));
732 assert!(is_file_tool("Grep"));
733 assert!(is_file_tool("NotebookEdit"));
734 assert!(!is_file_tool("Bash"));
735 assert!(!is_file_tool("WebFetch"));
736 assert!(!is_file_tool("Task"));
737 }
738
739 #[test]
742 fn test_derive_path_basic() {
743 let entries = vec![
744 make_entry(
745 "uuid-1111-aaaa",
746 MessageRole::User,
747 "Hello",
748 "2024-01-01T00:00:00Z",
749 ),
750 make_entry(
751 "uuid-2222-bbbb",
752 MessageRole::Assistant,
753 "Hi there",
754 "2024-01-01T00:00:01Z",
755 ),
756 ];
757 let convo = make_conversation(entries);
758 let config = DeriveConfig::default();
759
760 let path = derive_path(&convo, &config);
761
762 assert!(path.path.id.starts_with("path-claude-"));
763 assert_eq!(path.steps.len(), 2);
764 assert_eq!(path.steps[0].step.id, "uuid-1111-aaaa");
766 assert_eq!(path.steps[1].step.id, "uuid-2222-bbbb");
767 assert_eq!(path.steps[0].step.actor, "human:user");
768 assert!(path.steps[1].step.actor.starts_with("agent:"));
769 }
770
771 #[test]
772 fn test_derive_path_step_parents() {
773 let entries = vec![
774 make_entry(
775 "uuid-1111",
776 MessageRole::User,
777 "Hello",
778 "2024-01-01T00:00:00Z",
779 ),
780 make_entry(
781 "uuid-2222",
782 MessageRole::Assistant,
783 "Hi",
784 "2024-01-01T00:00:01Z",
785 ),
786 make_entry(
787 "uuid-3333",
788 MessageRole::User,
789 "More",
790 "2024-01-01T00:00:02Z",
791 ),
792 ];
793 let convo = make_conversation(entries);
794 let config = DeriveConfig::default();
795
796 let path = derive_path(&convo, &config);
797
798 assert!(
800 path.steps[1]
801 .step
802 .parents
803 .contains(&"uuid-1111".to_string())
804 );
805 assert!(
806 path.steps[2]
807 .step
808 .parents
809 .contains(&"uuid-2222".to_string())
810 );
811 }
812
813 #[test]
814 fn test_derive_path_conversation_artifact() {
815 let entries = vec![make_entry(
816 "uuid-1111",
817 MessageRole::User,
818 "Hello",
819 "2024-01-01T00:00:00Z",
820 )];
821 let convo = make_conversation(entries);
822 let config = DeriveConfig::default();
823
824 let path = derive_path(&convo, &config);
825
826 let convo_key = format!("agent://claude/{}", convo.session_id);
828 assert!(path.steps[0].change.contains_key(&convo_key));
829
830 let change = &path.steps[0].change[&convo_key];
831 let structural = change.structural.as_ref().unwrap();
832 assert_eq!(structural.change_type, "conversation.append");
833 assert_eq!(structural.extra["role"], "user");
834 }
835
836 #[test]
837 fn test_derive_path_no_meta_intent() {
838 let entries = vec![make_entry(
839 "uuid-1111",
840 MessageRole::User,
841 "Hello",
842 "2024-01-01T00:00:00Z",
843 )];
844 let convo = make_conversation(entries);
845 let config = DeriveConfig::default();
846
847 let path = derive_path(&convo, &config);
848
849 assert!(path.steps[0].meta.is_none());
851 }
852
853 #[test]
854 fn test_derive_path_actors() {
855 let entries = vec![
856 make_entry(
857 "uuid-1111",
858 MessageRole::User,
859 "Hello",
860 "2024-01-01T00:00:00Z",
861 ),
862 make_entry(
863 "uuid-2222",
864 MessageRole::Assistant,
865 "Hi",
866 "2024-01-01T00:00:01Z",
867 ),
868 ];
869 let convo = make_conversation(entries);
870 let config = DeriveConfig::default();
871
872 let path = derive_path(&convo, &config);
873 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
874
875 assert!(actors.contains_key("human:user"));
876 assert!(actors.contains_key("agent:claude-code"));
878 }
879
880 #[test]
881 fn test_derive_path_with_project_path_config() {
882 let convo = make_conversation(vec![make_entry(
883 "uuid-1",
884 MessageRole::User,
885 "Hello",
886 "2024-01-01T00:00:00Z",
887 )]);
888 let config = DeriveConfig {
889 project_path: Some("/my/project".to_string()),
890 ..Default::default()
891 };
892
893 let path = derive_path(&convo, &config);
894 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///my/project");
895 }
896
897 #[test]
898 fn test_derive_path_skips_empty_content() {
899 let mut entry = make_entry("uuid-1111", MessageRole::User, "", "2024-01-01T00:00:00Z");
900 entry.message.as_mut().unwrap().content = Some(MessageContent::Text(" ".to_string()));
902
903 let convo = make_conversation(vec![entry]);
904 let config = DeriveConfig::default();
905
906 let path = derive_path(&convo, &config);
907 assert!(path.steps.is_empty());
908 }
909
910 #[test]
911 fn test_derive_path_captures_system_messages_as_events() {
912 let entries = vec![
913 make_entry(
914 "uuid-1111",
915 MessageRole::System,
916 "System prompt",
917 "2024-01-01T00:00:00Z",
918 ),
919 make_entry(
920 "uuid-2222",
921 MessageRole::User,
922 "Hello",
923 "2024-01-01T00:00:01Z",
924 ),
925 ];
926 let convo = make_conversation(entries);
927 let config = DeriveConfig::default();
928
929 let path = derive_path(&convo, &config);
930 assert_eq!(path.steps.len(), 2);
932 assert_eq!(path.steps[0].step.actor, "tool:claude-code");
934 let convo_key = format!("agent://claude/{}", convo.session_id);
935 let structural = path.steps[0].change[&convo_key]
936 .structural
937 .as_ref()
938 .unwrap();
939 assert_eq!(structural.change_type, "conversation.event");
940 assert_eq!(structural.extra["entry_type"], "system");
941 assert_eq!(structural.extra["text"], "System prompt");
942 assert_eq!(path.steps[1].step.actor, "human:user");
944 }
945
946 #[test]
947 fn test_derive_path_with_tool_use() {
948 let mut convo = Conversation::new("test-session-12345678".to_string());
949 let entry = ConversationEntry {
950 parent_uuid: None,
951 is_sidechain: false,
952 entry_type: "assistant".to_string(),
953 uuid: "uuid-tool".to_string(),
954 timestamp: "2024-01-01T00:00:00Z".to_string(),
955 session_id: Some("test-session".to_string()),
956 message: Some(Message {
957 role: MessageRole::Assistant,
958 content: Some(MessageContent::Parts(vec![
959 ContentPart::Text {
960 text: "Let me write that".to_string(),
961 },
962 ContentPart::ToolUse {
963 id: "t1".to_string(),
964 name: "Write".to_string(),
965 input: serde_json::json!({"file_path": "/tmp/test.rs"}),
966 },
967 ])),
968 model: Some("claude-sonnet-4-5-20250929".to_string()),
969 id: None,
970 message_type: None,
971 stop_reason: None,
972 stop_sequence: None,
973 usage: None,
974 }),
975 cwd: None,
976 git_branch: None,
977 version: None,
978 user_type: None,
979 request_id: None,
980 tool_use_result: None,
981 snapshot: None,
982 message_id: None,
983 extra: Default::default(),
984 };
985 convo.add_entry(entry);
986 let config = DeriveConfig::default();
987
988 let path = derive_path(&convo, &config);
989
990 assert_eq!(path.steps.len(), 2);
992
993 let convo_key = format!("agent://claude/{}", convo.session_id);
995 assert!(path.steps[0].change.contains_key(&convo_key));
996
997 assert_eq!(path.steps[1].step.id, "uuid-tool-tool-Write");
999 assert_eq!(path.steps[1].step.actor, "agent:claude-code/tool:Write");
1000 assert!(
1001 path.steps[1]
1002 .step
1003 .parents
1004 .contains(&"uuid-tool".to_string())
1005 );
1006 assert!(path.steps[1].change.contains_key("/tmp/test.rs"));
1007
1008 let tool_change = &path.steps[1].change["/tmp/test.rs"];
1009 let structural = tool_change.structural.as_ref().unwrap();
1010 assert_eq!(structural.change_type, "tool.invoke");
1011 assert_eq!(structural.extra["name"], "Write");
1012 assert_eq!(structural.extra["tool_use_id"], "t1");
1013 assert_eq!(structural.extra["category"], "file_write");
1014 }
1015
1016 #[test]
1017 fn test_derive_path_sidechain_uses_parent_uuid() {
1018 let mut convo = Conversation::new("test-session-12345678".to_string());
1019
1020 let e1 = make_entry(
1021 "uuid-main-11",
1022 MessageRole::User,
1023 "Hello",
1024 "2024-01-01T00:00:00Z",
1025 );
1026 let e2 = make_entry(
1027 "uuid-main-22",
1028 MessageRole::Assistant,
1029 "Hi",
1030 "2024-01-01T00:00:01Z",
1031 );
1032 let mut e3 = make_entry(
1033 "uuid-side-33",
1034 MessageRole::User,
1035 "Side",
1036 "2024-01-01T00:00:02Z",
1037 );
1038 e3.is_sidechain = true;
1039 e3.parent_uuid = Some("uuid-main-11".to_string());
1040
1041 convo.add_entry(e1);
1042 convo.add_entry(e2);
1043 convo.add_entry(e3);
1044
1045 let config = DeriveConfig::default();
1046 let path = derive_path(&convo, &config);
1047
1048 assert_eq!(path.steps.len(), 3);
1049 let sidechain_step = &path.steps[2];
1051 assert!(
1052 sidechain_step
1053 .step
1054 .parents
1055 .contains(&"uuid-main-11".to_string())
1056 );
1057 }
1058
1059 #[test]
1062 fn test_derive_project() {
1063 let c1 = make_conversation(vec![make_entry(
1064 "uuid-1",
1065 MessageRole::User,
1066 "Hello",
1067 "2024-01-01T00:00:00Z",
1068 )]);
1069 let mut c2 = Conversation::new("session-2".to_string());
1070 c2.add_entry(make_entry(
1071 "uuid-2",
1072 MessageRole::User,
1073 "World",
1074 "2024-01-02T00:00:00Z",
1075 ));
1076
1077 let config = DeriveConfig::default();
1078 let paths = derive_project(&[c1, c2], &config);
1079
1080 assert_eq!(paths.len(), 2);
1081 }
1082
1083 #[test]
1084 fn test_derive_path_head_is_last_non_sidechain() {
1085 let entries = vec![
1086 make_entry(
1087 "uuid-1111",
1088 MessageRole::User,
1089 "Hello",
1090 "2024-01-01T00:00:00Z",
1091 ),
1092 make_entry(
1093 "uuid-2222",
1094 MessageRole::Assistant,
1095 "Hi",
1096 "2024-01-01T00:00:01Z",
1097 ),
1098 ];
1099 let convo = make_conversation(entries);
1100 let config = DeriveConfig::default();
1101
1102 let path = derive_path(&convo, &config);
1103
1104 assert_eq!(path.path.head, "uuid-2222");
1106 }
1107
1108 #[test]
1111 fn test_derive_path_tool_invocation_actors() {
1112 let mut convo = Conversation::new("test-session-12345678".to_string());
1113 convo.add_entry(ConversationEntry {
1114 parent_uuid: None,
1115 is_sidechain: false,
1116 entry_type: "assistant".to_string(),
1117 uuid: "uuid-1".to_string(),
1118 timestamp: "2024-01-01T00:00:00Z".to_string(),
1119 session_id: Some("test-session".to_string()),
1120 message: Some(Message {
1121 role: MessageRole::Assistant,
1122 content: Some(MessageContent::Parts(vec![
1123 ContentPart::Text {
1124 text: "Working".to_string(),
1125 },
1126 ContentPart::ToolUse {
1127 id: "t1".to_string(),
1128 name: "Read".to_string(),
1129 input: serde_json::json!({"file_path": "/foo.rs"}),
1130 },
1131 ])),
1132 model: None,
1133 id: None,
1134 message_type: None,
1135 stop_reason: None,
1136 stop_sequence: None,
1137 usage: None,
1138 }),
1139 cwd: None,
1140 git_branch: None,
1141 version: None,
1142 user_type: None,
1143 request_id: None,
1144 tool_use_result: None,
1145 snapshot: None,
1146 message_id: None,
1147 extra: Default::default(),
1148 });
1149 let config = DeriveConfig::default();
1150 let path = derive_path(&convo, &config);
1151
1152 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
1153 assert!(actors.contains_key("agent:claude-code/tool:Read"));
1154 }
1155
1156 #[test]
1157 fn test_derive_path_token_usage() {
1158 let mut convo = Conversation::new("test-session-12345678".to_string());
1159 convo.add_entry(ConversationEntry {
1160 parent_uuid: None,
1161 is_sidechain: false,
1162 entry_type: "assistant".to_string(),
1163 uuid: "uuid-usage".to_string(),
1164 timestamp: "2024-01-01T00:00:00Z".to_string(),
1165 session_id: Some("test-session".to_string()),
1166 message: Some(Message {
1167 role: MessageRole::Assistant,
1168 content: Some(MessageContent::Text("Response".to_string())),
1169 model: Some("claude-sonnet-4-5-20250929".to_string()),
1170 id: None,
1171 message_type: None,
1172 stop_reason: Some("end_turn".to_string()),
1173 stop_sequence: None,
1174 usage: Some(Usage {
1175 input_tokens: Some(100),
1176 output_tokens: Some(50),
1177 cache_creation_input_tokens: Some(10),
1178 cache_read_input_tokens: Some(80),
1179 cache_creation: None,
1180 service_tier: None,
1181 }),
1182 }),
1183 cwd: None,
1184 git_branch: None,
1185 version: None,
1186 user_type: None,
1187 request_id: None,
1188 tool_use_result: None,
1189 snapshot: None,
1190 message_id: None,
1191 extra: Default::default(),
1192 });
1193
1194 let config = DeriveConfig::default();
1195 let path = derive_path(&convo, &config);
1196
1197 let convo_key = format!("agent://claude/{}", convo.session_id);
1198 let change = &path.steps[0].change[&convo_key];
1199 let extra = &change.structural.as_ref().unwrap().extra;
1200
1201 assert_eq!(extra["model"], "claude-sonnet-4-5-20250929");
1202 assert_eq!(extra["stop_reason"], "end_turn");
1203 assert_eq!(extra["input_tokens"], 100);
1204 assert_eq!(extra["output_tokens"], 50);
1205 assert_eq!(extra["cache_read_tokens"], 80);
1206 assert_eq!(extra["cache_write_tokens"], 10);
1207 }
1208
1209 #[test]
1210 fn test_derive_path_full_text_no_truncation() {
1211 let long_text = "a".repeat(5000);
1212 let entries = vec![make_entry(
1213 "uuid-long",
1214 MessageRole::User,
1215 &long_text,
1216 "2024-01-01T00:00:00Z",
1217 )];
1218 let convo = make_conversation(entries);
1219 let config = DeriveConfig::default();
1220
1221 let path = derive_path(&convo, &config);
1222
1223 let convo_key = format!("agent://claude/{}", convo.session_id);
1224 let change = &path.steps[0].change[&convo_key];
1225 let text = change.structural.as_ref().unwrap().extra["text"]
1226 .as_str()
1227 .unwrap();
1228 assert_eq!(text.len(), 5000);
1229 assert!(!text.ends_with("..."));
1230 }
1231
1232 #[test]
1233 fn test_derive_path_multiple_tool_uses_same_type() {
1234 let mut convo = Conversation::new("test-session-12345678".to_string());
1235 convo.add_entry(ConversationEntry {
1236 parent_uuid: None,
1237 is_sidechain: false,
1238 entry_type: "assistant".to_string(),
1239 uuid: "uuid-multi".to_string(),
1240 timestamp: "2024-01-01T00:00:00Z".to_string(),
1241 session_id: Some("test-session".to_string()),
1242 message: Some(Message {
1243 role: MessageRole::Assistant,
1244 content: Some(MessageContent::Parts(vec![
1245 ContentPart::Text {
1246 text: "Reading files".to_string(),
1247 },
1248 ContentPart::ToolUse {
1249 id: "t1".to_string(),
1250 name: "Read".to_string(),
1251 input: serde_json::json!({"file_path": "/foo.rs"}),
1252 },
1253 ContentPart::ToolUse {
1254 id: "t2".to_string(),
1255 name: "Read".to_string(),
1256 input: serde_json::json!({"file_path": "/bar.rs"}),
1257 },
1258 ])),
1259 model: None,
1260 id: None,
1261 message_type: None,
1262 stop_reason: None,
1263 stop_sequence: None,
1264 usage: None,
1265 }),
1266 cwd: None,
1267 git_branch: None,
1268 version: None,
1269 user_type: None,
1270 request_id: None,
1271 tool_use_result: None,
1272 snapshot: None,
1273 message_id: None,
1274 extra: Default::default(),
1275 });
1276
1277 let config = DeriveConfig::default();
1278 let path = derive_path(&convo, &config);
1279
1280 assert_eq!(path.steps.len(), 2);
1282 assert_eq!(path.steps[1].step.id, "uuid-multi-tool-Read");
1283 assert_eq!(path.steps[1].change.len(), 2);
1285 assert!(path.steps[1].change.contains_key("/foo.rs"));
1286 assert!(path.steps[1].change.contains_key("/bar.rs"));
1287 }
1288
1289 #[test]
1290 fn test_derive_path_multiple_tool_uses_different_types() {
1291 let mut convo = Conversation::new("test-session-12345678".to_string());
1292 convo.add_entry(ConversationEntry {
1293 parent_uuid: None,
1294 is_sidechain: false,
1295 entry_type: "assistant".to_string(),
1296 uuid: "uuid-diff".to_string(),
1297 timestamp: "2024-01-01T00:00:00Z".to_string(),
1298 session_id: Some("test-session".to_string()),
1299 message: Some(Message {
1300 role: MessageRole::Assistant,
1301 content: Some(MessageContent::Parts(vec![
1302 ContentPart::Text {
1303 text: "Working".to_string(),
1304 },
1305 ContentPart::ToolUse {
1306 id: "t1".to_string(),
1307 name: "Read".to_string(),
1308 input: serde_json::json!({"file_path": "/foo.rs"}),
1309 },
1310 ContentPart::ToolUse {
1311 id: "t2".to_string(),
1312 name: "Bash".to_string(),
1313 input: serde_json::json!({"command": "cargo test"}),
1314 },
1315 ])),
1316 model: None,
1317 id: None,
1318 message_type: None,
1319 stop_reason: None,
1320 stop_sequence: None,
1321 usage: None,
1322 }),
1323 cwd: None,
1324 git_branch: None,
1325 version: None,
1326 user_type: None,
1327 request_id: None,
1328 tool_use_result: None,
1329 snapshot: None,
1330 message_id: None,
1331 extra: Default::default(),
1332 });
1333
1334 let config = DeriveConfig::default();
1335 let path = derive_path(&convo, &config);
1336
1337 assert_eq!(path.steps.len(), 3);
1339 assert_eq!(path.steps[1].step.id, "uuid-diff-tool-Read");
1340 assert_eq!(path.steps[2].step.id, "uuid-diff-tool-Bash");
1341
1342 let bash_change = &path.steps[2].change;
1344 assert_eq!(bash_change.len(), 1);
1345 let bash_key = bash_change.keys().next().unwrap();
1346 assert!(bash_key.starts_with("agent://claude/"));
1347 assert!(bash_key.contains("/tool/shell/"));
1348 }
1349
1350 #[test]
1351 fn test_derive_path_non_file_tool_artifact_key() {
1352 let mut convo = Conversation::new("sess-123".to_string());
1353 convo.add_entry(ConversationEntry {
1354 parent_uuid: None,
1355 is_sidechain: false,
1356 entry_type: "assistant".to_string(),
1357 uuid: "uuid-bash".to_string(),
1358 timestamp: "2024-01-01T00:00:00Z".to_string(),
1359 session_id: Some("test-session".to_string()),
1360 message: Some(Message {
1361 role: MessageRole::Assistant,
1362 content: Some(MessageContent::Parts(vec![
1363 ContentPart::Text {
1364 text: "Running".to_string(),
1365 },
1366 ContentPart::ToolUse {
1367 id: "tu-42".to_string(),
1368 name: "Bash".to_string(),
1369 input: serde_json::json!({"command": "ls"}),
1370 },
1371 ])),
1372 model: None,
1373 id: None,
1374 message_type: None,
1375 stop_reason: None,
1376 stop_sequence: None,
1377 usage: None,
1378 }),
1379 cwd: None,
1380 git_branch: None,
1381 version: None,
1382 user_type: None,
1383 request_id: None,
1384 tool_use_result: None,
1385 snapshot: None,
1386 message_id: None,
1387 extra: Default::default(),
1388 });
1389
1390 let config = DeriveConfig::default();
1391 let path = derive_path(&convo, &config);
1392
1393 let tool_step = &path.steps[1];
1394 let expected_key = "agent://claude/sess-123/tool/shell/tu-42";
1395 assert!(tool_step.change.contains_key(expected_key));
1396 }
1397
1398 #[test]
1399 fn test_derive_path_thinking_included_when_configured() {
1400 let mut convo = Conversation::new("test-session-12345678".to_string());
1401 convo.add_entry(ConversationEntry {
1402 parent_uuid: None,
1403 is_sidechain: false,
1404 entry_type: "assistant".to_string(),
1405 uuid: "uuid-think".to_string(),
1406 timestamp: "2024-01-01T00:00:00Z".to_string(),
1407 session_id: Some("test-session".to_string()),
1408 message: Some(Message {
1409 role: MessageRole::Assistant,
1410 content: Some(MessageContent::Parts(vec![
1411 ContentPart::Thinking {
1412 thinking: "Let me think about this".to_string(),
1413 signature: None,
1414 },
1415 ContentPart::Text {
1416 text: "Here is my answer".to_string(),
1417 },
1418 ])),
1419 model: None,
1420 id: None,
1421 message_type: None,
1422 stop_reason: None,
1423 stop_sequence: None,
1424 usage: None,
1425 }),
1426 cwd: None,
1427 git_branch: None,
1428 version: None,
1429 user_type: None,
1430 request_id: None,
1431 tool_use_result: None,
1432 snapshot: None,
1433 message_id: None,
1434 extra: Default::default(),
1435 });
1436
1437 let config = DeriveConfig {
1439 include_thinking: true,
1440 ..Default::default()
1441 };
1442 let path = derive_path(&convo, &config);
1443
1444 let convo_key = format!("agent://claude/{}", convo.session_id);
1445 let extra = &path.steps[0].change[&convo_key]
1446 .structural
1447 .as_ref()
1448 .unwrap()
1449 .extra;
1450 assert_eq!(extra["thinking"], "Let me think about this");
1451 assert_eq!(extra["text"], "Here is my answer");
1453 }
1454
1455 #[test]
1456 fn test_derive_path_thinking_excluded_by_default() {
1457 let mut convo = Conversation::new("test-session-12345678".to_string());
1458 convo.add_entry(ConversationEntry {
1459 parent_uuid: None,
1460 is_sidechain: false,
1461 entry_type: "assistant".to_string(),
1462 uuid: "uuid-think2".to_string(),
1463 timestamp: "2024-01-01T00:00:00Z".to_string(),
1464 session_id: Some("test-session".to_string()),
1465 message: Some(Message {
1466 role: MessageRole::Assistant,
1467 content: Some(MessageContent::Parts(vec![
1468 ContentPart::Thinking {
1469 thinking: "Secret thoughts".to_string(),
1470 signature: None,
1471 },
1472 ContentPart::Text {
1473 text: "Answer".to_string(),
1474 },
1475 ])),
1476 model: None,
1477 id: None,
1478 message_type: None,
1479 stop_reason: None,
1480 stop_sequence: None,
1481 usage: None,
1482 }),
1483 cwd: None,
1484 git_branch: None,
1485 version: None,
1486 user_type: None,
1487 request_id: None,
1488 tool_use_result: None,
1489 snapshot: None,
1490 message_id: None,
1491 extra: Default::default(),
1492 });
1493
1494 let config = DeriveConfig::default();
1495 let path = derive_path(&convo, &config);
1496
1497 let convo_key = format!("agent://claude/{}", convo.session_id);
1498 let extra = &path.steps[0].change[&convo_key]
1499 .structural
1500 .as_ref()
1501 .unwrap()
1502 .extra;
1503 assert!(!extra.contains_key("thinking"));
1504 }
1505
1506 #[test]
1507 fn test_derive_path_tool_step_does_not_advance_parent_chain() {
1508 let mut convo = Conversation::new("test-session-12345678".to_string());
1509 convo.add_entry(ConversationEntry {
1510 parent_uuid: None,
1511 is_sidechain: false,
1512 entry_type: "assistant".to_string(),
1513 uuid: "uuid-a1".to_string(),
1514 timestamp: "2024-01-01T00:00:00Z".to_string(),
1515 session_id: Some("test-session".to_string()),
1516 message: Some(Message {
1517 role: MessageRole::Assistant,
1518 content: Some(MessageContent::Parts(vec![
1519 ContentPart::Text {
1520 text: "Writing".to_string(),
1521 },
1522 ContentPart::ToolUse {
1523 id: "t1".to_string(),
1524 name: "Write".to_string(),
1525 input: serde_json::json!({"file_path": "/f.rs"}),
1526 },
1527 ])),
1528 model: None,
1529 id: None,
1530 message_type: None,
1531 stop_reason: None,
1532 stop_sequence: None,
1533 usage: None,
1534 }),
1535 cwd: None,
1536 git_branch: None,
1537 version: None,
1538 user_type: None,
1539 request_id: None,
1540 tool_use_result: None,
1541 snapshot: None,
1542 message_id: None,
1543 extra: Default::default(),
1544 });
1545 convo.add_entry(make_entry(
1546 "uuid-u2",
1547 MessageRole::User,
1548 "Next",
1549 "2024-01-01T00:00:01Z",
1550 ));
1551
1552 let config = DeriveConfig::default();
1553 let path = derive_path(&convo, &config);
1554
1555 assert_eq!(path.steps.len(), 3);
1557 assert_eq!(path.steps[2].step.parents, vec!["uuid-a1".to_string()]);
1559 }
1560
1561 #[test]
1562 fn test_derive_path_tool_input_preserved() {
1563 let mut convo = Conversation::new("test-session-12345678".to_string());
1564 let input_json = serde_json::json!({
1565 "file_path": "/src/main.rs",
1566 "content": "fn main() {}\n"
1567 });
1568 convo.add_entry(ConversationEntry {
1569 parent_uuid: None,
1570 is_sidechain: false,
1571 entry_type: "assistant".to_string(),
1572 uuid: "uuid-inp".to_string(),
1573 timestamp: "2024-01-01T00:00:00Z".to_string(),
1574 session_id: Some("test-session".to_string()),
1575 message: Some(Message {
1576 role: MessageRole::Assistant,
1577 content: Some(MessageContent::Parts(vec![
1578 ContentPart::Text {
1579 text: "Writing".to_string(),
1580 },
1581 ContentPart::ToolUse {
1582 id: "t1".to_string(),
1583 name: "Write".to_string(),
1584 input: input_json.clone(),
1585 },
1586 ])),
1587 model: None,
1588 id: None,
1589 message_type: None,
1590 stop_reason: None,
1591 stop_sequence: None,
1592 usage: None,
1593 }),
1594 cwd: None,
1595 git_branch: None,
1596 version: None,
1597 user_type: None,
1598 request_id: None,
1599 tool_use_result: None,
1600 snapshot: None,
1601 message_id: None,
1602 extra: Default::default(),
1603 });
1604
1605 let config = DeriveConfig::default();
1606 let path = derive_path(&convo, &config);
1607
1608 let tool_step = &path.steps[1];
1609 let change = &tool_step.change["/src/main.rs"];
1610 let extra = &change.structural.as_ref().unwrap().extra;
1611 assert_eq!(extra["input"], input_json);
1612 }
1613
1614 #[test]
1615 fn test_derive_path_edit_tool_emits_unified_diff() {
1616 let mut convo = Conversation::new("test-session-12345678".to_string());
1617 let input_json = serde_json::json!({
1618 "file_path": "/src/login.rs",
1619 "old_string": "validate_token()",
1620 "new_string": "validate_token_v2()",
1621 });
1622 convo.add_entry(ConversationEntry {
1623 parent_uuid: None,
1624 is_sidechain: false,
1625 entry_type: "assistant".to_string(),
1626 uuid: "uuid-edit".to_string(),
1627 timestamp: "2024-01-01T00:00:00Z".to_string(),
1628 session_id: Some("test-session".to_string()),
1629 message: Some(Message {
1630 role: MessageRole::Assistant,
1631 content: Some(MessageContent::Parts(vec![ContentPart::ToolUse {
1632 id: "t-edit".to_string(),
1633 name: "Edit".to_string(),
1634 input: input_json,
1635 }])),
1636 model: None,
1637 id: None,
1638 message_type: None,
1639 stop_reason: None,
1640 stop_sequence: None,
1641 usage: None,
1642 }),
1643 cwd: None,
1644 git_branch: None,
1645 version: None,
1646 user_type: None,
1647 request_id: None,
1648 tool_use_result: None,
1649 snapshot: None,
1650 message_id: None,
1651 extra: Default::default(),
1652 });
1653
1654 let path = derive_path(&convo, &DeriveConfig::default());
1655 let tool_step = &path.steps[1];
1657 let ch = &tool_step.change["/src/login.rs"];
1658 let raw = ch
1659 .raw
1660 .as_deref()
1661 .expect("edit tool should emit unified diff");
1662 assert!(raw.contains("--- a/src/login.rs"), "{}", raw);
1665 assert!(raw.contains("+++ b/src/login.rs"), "{}", raw);
1666 assert!(
1667 !raw.contains("a//"),
1668 "header should not double-slash: {}",
1669 raw
1670 );
1671 assert!(raw.contains("-validate_token()"), "{}", raw);
1672 assert!(raw.contains("+validate_token_v2()"), "{}", raw);
1673
1674 assert_eq!(tool_step.step.parents, vec![path.steps[0].step.id.clone()]);
1678 }
1679
1680 #[test]
1683 fn test_derive_path_tool_result_assembled() {
1684 use crate::types::ToolResultContent;
1685
1686 let mut convo = Conversation::new("test-session-12345678".to_string());
1687
1688 convo.add_entry(ConversationEntry {
1690 parent_uuid: None,
1691 is_sidechain: false,
1692 entry_type: "assistant".to_string(),
1693 uuid: "uuid-assist-1".to_string(),
1694 timestamp: "2024-01-01T00:00:00Z".to_string(),
1695 session_id: Some("test-session".to_string()),
1696 message: Some(Message {
1697 role: MessageRole::Assistant,
1698 content: Some(MessageContent::Parts(vec![
1699 ContentPart::Text {
1700 text: "Let me read that file".to_string(),
1701 },
1702 ContentPart::ToolUse {
1703 id: "tu-read-1".to_string(),
1704 name: "Read".to_string(),
1705 input: serde_json::json!({"file_path": "/src/lib.rs"}),
1706 },
1707 ])),
1708 model: None,
1709 id: None,
1710 message_type: None,
1711 stop_reason: None,
1712 stop_sequence: None,
1713 usage: None,
1714 }),
1715 cwd: None,
1716 git_branch: None,
1717 version: None,
1718 user_type: None,
1719 request_id: None,
1720 tool_use_result: None,
1721 snapshot: None,
1722 message_id: None,
1723 extra: Default::default(),
1724 });
1725
1726 convo.add_entry(ConversationEntry {
1728 parent_uuid: None,
1729 is_sidechain: false,
1730 entry_type: "user".to_string(),
1731 uuid: "uuid-result-1".to_string(),
1732 timestamp: "2024-01-01T00:00:01Z".to_string(),
1733 session_id: Some("test-session".to_string()),
1734 message: Some(Message {
1735 role: MessageRole::User,
1736 content: Some(MessageContent::Parts(vec![ContentPart::ToolResult {
1737 tool_use_id: "tu-read-1".to_string(),
1738 content: ToolResultContent::Text("fn main() {}".to_string()),
1739 is_error: false,
1740 }])),
1741 model: None,
1742 id: None,
1743 message_type: None,
1744 stop_reason: None,
1745 stop_sequence: None,
1746 usage: None,
1747 }),
1748 cwd: None,
1749 git_branch: None,
1750 version: None,
1751 user_type: None,
1752 request_id: None,
1753 tool_use_result: None,
1754 snapshot: None,
1755 message_id: None,
1756 extra: Default::default(),
1757 });
1758
1759 let config = DeriveConfig::default();
1760 let path = derive_path(&convo, &config);
1761
1762 assert_eq!(path.steps.len(), 2);
1764
1765 let tool_step = &path.steps[1];
1767 assert_eq!(tool_step.step.id, "uuid-assist-1-tool-Read");
1768 let change = &tool_step.change["/src/lib.rs"];
1769 let extra = &change.structural.as_ref().unwrap().extra;
1770 assert_eq!(extra["result"], "fn main() {}");
1771 assert_eq!(extra["is_error"], false);
1772 }
1773
1774 #[test]
1775 fn test_derive_path_tool_result_error() {
1776 use crate::types::ToolResultContent;
1777
1778 let mut convo = Conversation::new("test-session-12345678".to_string());
1779
1780 convo.add_entry(ConversationEntry {
1781 parent_uuid: None,
1782 is_sidechain: false,
1783 entry_type: "assistant".to_string(),
1784 uuid: "uuid-assist-err".to_string(),
1785 timestamp: "2024-01-01T00:00:00Z".to_string(),
1786 session_id: Some("test-session".to_string()),
1787 message: Some(Message {
1788 role: MessageRole::Assistant,
1789 content: Some(MessageContent::Parts(vec![
1790 ContentPart::Text {
1791 text: "Running command".to_string(),
1792 },
1793 ContentPart::ToolUse {
1794 id: "tu-bash-1".to_string(),
1795 name: "Bash".to_string(),
1796 input: serde_json::json!({"command": "cargo test"}),
1797 },
1798 ])),
1799 model: None,
1800 id: None,
1801 message_type: None,
1802 stop_reason: None,
1803 stop_sequence: None,
1804 usage: None,
1805 }),
1806 cwd: None,
1807 git_branch: None,
1808 version: None,
1809 user_type: None,
1810 request_id: None,
1811 tool_use_result: None,
1812 snapshot: None,
1813 message_id: None,
1814 extra: Default::default(),
1815 });
1816
1817 convo.add_entry(ConversationEntry {
1819 parent_uuid: None,
1820 is_sidechain: false,
1821 entry_type: "user".to_string(),
1822 uuid: "uuid-result-err".to_string(),
1823 timestamp: "2024-01-01T00:00:01Z".to_string(),
1824 session_id: Some("test-session".to_string()),
1825 message: Some(Message {
1826 role: MessageRole::User,
1827 content: Some(MessageContent::Parts(vec![ContentPart::ToolResult {
1828 tool_use_id: "tu-bash-1".to_string(),
1829 content: ToolResultContent::Text("compilation failed".to_string()),
1830 is_error: true,
1831 }])),
1832 model: None,
1833 id: None,
1834 message_type: None,
1835 stop_reason: None,
1836 stop_sequence: None,
1837 usage: None,
1838 }),
1839 cwd: None,
1840 git_branch: None,
1841 version: None,
1842 user_type: None,
1843 request_id: None,
1844 tool_use_result: None,
1845 snapshot: None,
1846 message_id: None,
1847 extra: Default::default(),
1848 });
1849
1850 let config = DeriveConfig::default();
1851 let path = derive_path(&convo, &config);
1852
1853 let tool_step = &path.steps[1];
1854 let bash_key = tool_step.change.keys().next().unwrap();
1855 let extra = &tool_step.change[bash_key]
1856 .structural
1857 .as_ref()
1858 .unwrap()
1859 .extra;
1860 assert_eq!(extra["result"], "compilation failed");
1861 assert_eq!(extra["is_error"], true);
1862 }
1863
1864 #[test]
1867 fn test_derive_path_init_step_with_cwd() {
1868 let mut convo = Conversation::new("test-session-12345678".to_string());
1869 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1870 entry.cwd = Some("/home/user/project".to_string());
1871 entry.version = Some("1.2.3".to_string());
1872 convo.add_entry(entry);
1873
1874 let config = DeriveConfig::default();
1875 let path = derive_path(&convo, &config);
1876
1877 assert_eq!(path.steps.len(), 2);
1879
1880 let init = &path.steps[0];
1881 assert_eq!(init.step.id, "test-session-12345678-init");
1882 assert_eq!(init.step.actor, "tool:claude-code");
1883 assert!(init.step.parents.is_empty());
1884
1885 let convo_key = format!("agent://claude/{}", convo.session_id);
1886 let structural = init.change[&convo_key].structural.as_ref().unwrap();
1887 assert_eq!(structural.change_type, "conversation.init");
1888 assert_eq!(structural.extra["working_dir"], "/home/user/project");
1889 assert_eq!(structural.extra["version"], "1.2.3");
1890 }
1891
1892 #[test]
1893 fn test_derive_path_init_step_is_parent_of_first() {
1894 let mut convo = Conversation::new("test-session-12345678".to_string());
1895 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1896 entry.cwd = Some("/project".to_string());
1897 convo.add_entry(entry);
1898
1899 let config = DeriveConfig::default();
1900 let path = derive_path(&convo, &config);
1901
1902 assert_eq!(path.steps.len(), 2);
1904 assert_eq!(
1905 path.steps[1].step.parents,
1906 vec!["test-session-12345678-init".to_string()]
1907 );
1908 }
1909
1910 #[test]
1911 fn test_derive_path_init_step_with_git_branch() {
1912 let mut convo = Conversation::new("test-session-12345678".to_string());
1913 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
1914 entry.git_branch = Some("feature/foo".to_string());
1915 convo.add_entry(entry);
1916
1917 let config = DeriveConfig::default();
1918 let path = derive_path(&convo, &config);
1919
1920 assert_eq!(path.steps.len(), 2);
1921 let init = &path.steps[0];
1922 let convo_key = format!("agent://claude/{}", convo.session_id);
1923 let structural = init.change[&convo_key].structural.as_ref().unwrap();
1924 assert_eq!(structural.extra["vcs_branch"], "feature/foo");
1925 }
1926
1927 #[test]
1928 fn test_derive_path_no_init_step_without_metadata() {
1929 let entries = vec![make_entry(
1931 "uuid-1",
1932 MessageRole::User,
1933 "Hello",
1934 "2024-01-01T00:00:00Z",
1935 )];
1936 let convo = make_conversation(entries);
1937 let config = DeriveConfig::default();
1938
1939 let path = derive_path(&convo, &config);
1940
1941 assert_eq!(path.steps.len(), 1);
1943 assert_eq!(path.steps[0].step.id, "uuid-1");
1944 }
1945
1946 #[test]
1949 fn test_derive_path_captures_cwd_and_git_branch() {
1950 let mut convo = Conversation::new("test-session-12345678".to_string());
1951 let mut entry = make_entry(
1952 "uuid-meta-1",
1953 MessageRole::User,
1954 "Hello",
1955 "2024-01-01T00:00:00Z",
1956 );
1957 entry.cwd = Some("/home/user/project".to_string());
1958 entry.git_branch = Some("main".to_string());
1959 convo.add_entry(entry);
1960
1961 let config = DeriveConfig::default();
1962 let path = derive_path(&convo, &config);
1963
1964 let convo_key = format!("agent://claude/{}", convo.session_id);
1966 let append_step = path
1967 .steps
1968 .iter()
1969 .find(|s| {
1970 s.change
1971 .get(&convo_key)
1972 .and_then(|c| c.structural.as_ref())
1973 .is_some_and(|sc| sc.change_type == "conversation.append")
1974 })
1975 .expect("should have a conversation.append step");
1976 let extra = &append_step.change[&convo_key]
1977 .structural
1978 .as_ref()
1979 .unwrap()
1980 .extra;
1981
1982 assert_eq!(extra["cwd"], "/home/user/project");
1983 assert_eq!(extra["git_branch"], "main");
1984 }
1985
1986 #[test]
1987 fn test_derive_path_captures_version() {
1988 let mut convo = Conversation::new("test-session-12345678".to_string());
1989 let mut entry = make_entry(
1990 "uuid-meta-2",
1991 MessageRole::User,
1992 "Hello",
1993 "2024-01-01T00:00:00Z",
1994 );
1995 entry.version = Some("1.5.0".to_string());
1996 convo.add_entry(entry);
1997
1998 let config = DeriveConfig::default();
1999 let path = derive_path(&convo, &config);
2000
2001 let convo_key = format!("agent://claude/{}", convo.session_id);
2002 let append_step = path
2003 .steps
2004 .iter()
2005 .find(|s| {
2006 s.change
2007 .get(&convo_key)
2008 .and_then(|c| c.structural.as_ref())
2009 .is_some_and(|sc| sc.change_type == "conversation.append")
2010 })
2011 .expect("should have a conversation.append step");
2012 let extra = &append_step.change[&convo_key]
2013 .structural
2014 .as_ref()
2015 .unwrap()
2016 .extra;
2017
2018 assert_eq!(extra["version"], "1.5.0");
2019 }
2020
2021 #[test]
2022 fn test_derive_path_captures_user_type_and_request_id() {
2023 let mut convo = Conversation::new("test-session-12345678".to_string());
2024 convo.add_entry(ConversationEntry {
2025 parent_uuid: None,
2026 is_sidechain: false,
2027 entry_type: "assistant".to_string(),
2028 uuid: "uuid-meta-3".to_string(),
2029 timestamp: "2024-01-01T00:00:00Z".to_string(),
2030 session_id: Some("test-session".to_string()),
2031 message: Some(Message {
2032 role: MessageRole::Assistant,
2033 content: Some(MessageContent::Text("Response".to_string())),
2034 model: Some("claude-sonnet-4-5-20250929".to_string()),
2035 id: None,
2036 message_type: None,
2037 stop_reason: None,
2038 stop_sequence: None,
2039 usage: None,
2040 }),
2041 cwd: None,
2042 git_branch: None,
2043 version: None,
2044 user_type: Some("external".to_string()),
2045 request_id: Some("req-abc-123".to_string()),
2046 tool_use_result: None,
2047 snapshot: None,
2048 message_id: None,
2049 extra: Default::default(),
2050 });
2051
2052 let config = DeriveConfig::default();
2053 let path = derive_path(&convo, &config);
2054
2055 let convo_key = format!("agent://claude/{}", convo.session_id);
2056 let extra = &path.steps[0].change[&convo_key]
2057 .structural
2058 .as_ref()
2059 .unwrap()
2060 .extra;
2061
2062 assert_eq!(extra["user_type"], "external");
2063 assert_eq!(extra["request_id"], "req-abc-123");
2064 }
2065
2066 #[test]
2067 fn test_derive_path_captures_entry_extra() {
2068 let mut convo = Conversation::new("test-session-12345678".to_string());
2069 let mut entry_extra = HashMap::new();
2070 entry_extra.insert("entrypoint".to_string(), serde_json::json!("cli"));
2071 entry_extra.insert("isMeta".to_string(), serde_json::json!(true));
2072 entry_extra.insert("slug".to_string(), serde_json::json!("my-slug"));
2073
2074 convo.add_entry(ConversationEntry {
2075 parent_uuid: None,
2076 is_sidechain: false,
2077 entry_type: "user".to_string(),
2078 uuid: "uuid-meta-4".to_string(),
2079 timestamp: "2024-01-01T00:00:00Z".to_string(),
2080 session_id: Some("test-session".to_string()),
2081 message: Some(Message {
2082 role: MessageRole::User,
2083 content: Some(MessageContent::Text("Hello".to_string())),
2084 model: None,
2085 id: None,
2086 message_type: None,
2087 stop_reason: None,
2088 stop_sequence: None,
2089 usage: None,
2090 }),
2091 cwd: None,
2092 git_branch: None,
2093 version: None,
2094 user_type: None,
2095 request_id: None,
2096 tool_use_result: None,
2097 snapshot: None,
2098 message_id: None,
2099 extra: entry_extra,
2100 });
2101
2102 let config = DeriveConfig::default();
2103 let path = derive_path(&convo, &config);
2104
2105 let convo_key = format!("agent://claude/{}", convo.session_id);
2106 let extra = &path.steps[0].change[&convo_key]
2107 .structural
2108 .as_ref()
2109 .unwrap()
2110 .extra;
2111
2112 let entry_extra_val = extra
2113 .get("entry_extra")
2114 .expect("entry_extra should be present");
2115 assert_eq!(entry_extra_val["entrypoint"], "cli");
2116 assert_eq!(entry_extra_val["isMeta"], true);
2117 assert_eq!(entry_extra_val["slug"], "my-slug");
2118 }
2119
2120 #[test]
2121 fn test_derive_path_missing_metadata_not_included() {
2122 let entries = vec![make_entry(
2124 "uuid-meta-5",
2125 MessageRole::User,
2126 "Hello",
2127 "2024-01-01T00:00:00Z",
2128 )];
2129 let convo = make_conversation(entries);
2130 let config = DeriveConfig::default();
2131
2132 let path = derive_path(&convo, &config);
2133
2134 let convo_key = format!("agent://claude/{}", convo.session_id);
2135 let extra = &path.steps[0].change[&convo_key]
2136 .structural
2137 .as_ref()
2138 .unwrap()
2139 .extra;
2140
2141 assert!(!extra.contains_key("cwd"));
2143 assert!(!extra.contains_key("version"));
2144 assert!(!extra.contains_key("git_branch"));
2145 assert!(!extra.contains_key("user_type"));
2146 assert!(!extra.contains_key("request_id"));
2147 assert!(!extra.contains_key("entry_extra"));
2148 }
2149
2150 #[test]
2151 fn test_derive_path_init_step_actor_registered() {
2152 let mut convo = Conversation::new("test-session-12345678".to_string());
2153 let mut entry = make_entry("uuid-1", MessageRole::User, "Hello", "2024-01-01T00:00:00Z");
2154 entry.cwd = Some("/project".to_string());
2155 convo.add_entry(entry);
2156
2157 let config = DeriveConfig::default();
2158 let path = derive_path(&convo, &config);
2159
2160 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
2161 assert!(actors.contains_key("tool:claude-code"));
2162 assert_eq!(
2163 actors["tool:claude-code"].name.as_deref(),
2164 Some("Claude Code")
2165 );
2166 }
2167
2168 fn make_event_entry(uuid: &str, entry_type: &str, timestamp: &str) -> ConversationEntry {
2171 ConversationEntry {
2172 parent_uuid: None,
2173 is_sidechain: false,
2174 entry_type: entry_type.to_string(),
2175 uuid: uuid.to_string(),
2176 timestamp: timestamp.to_string(),
2177 session_id: Some("test-session".to_string()),
2178 cwd: None,
2179 git_branch: None,
2180 version: None,
2181 message: None,
2182 user_type: None,
2183 request_id: None,
2184 tool_use_result: None,
2185 snapshot: None,
2186 message_id: None,
2187 extra: Default::default(),
2188 }
2189 }
2190
2191 #[test]
2192 fn test_derive_path_attachment_entry_captured_as_event() {
2193 let mut convo = Conversation::new("test-session-12345678".to_string());
2194 convo.add_entry(make_entry(
2195 "uuid-1",
2196 MessageRole::User,
2197 "Hello",
2198 "2024-01-01T00:00:00Z",
2199 ));
2200 convo.add_entry(make_event_entry(
2201 "uuid-attach-1",
2202 "attachment",
2203 "2024-01-01T00:00:01Z",
2204 ));
2205 convo.add_entry(make_entry(
2206 "uuid-2",
2207 MessageRole::Assistant,
2208 "Hi",
2209 "2024-01-01T00:00:02Z",
2210 ));
2211
2212 let config = DeriveConfig::default();
2213 let path = derive_path(&convo, &config);
2214
2215 assert_eq!(path.steps.len(), 3);
2217
2218 let event_step = &path.steps[1];
2219 assert_eq!(event_step.step.id, "uuid-attach-1");
2220 assert_eq!(event_step.step.actor, "tool:claude-code");
2221
2222 let convo_key = format!("agent://claude/{}", convo.session_id);
2223 let structural = event_step.change[&convo_key].structural.as_ref().unwrap();
2224 assert_eq!(structural.change_type, "conversation.event");
2225 assert_eq!(structural.extra["entry_type"], "attachment");
2226 }
2227
2228 #[test]
2229 fn test_derive_path_system_entry_captured_as_event() {
2230 let mut convo = Conversation::new("test-session-12345678".to_string());
2231 convo.add_entry(make_entry(
2232 "uuid-sys",
2233 MessageRole::System,
2234 "Turn duration: 5s",
2235 "2024-01-01T00:00:00Z",
2236 ));
2237
2238 let config = DeriveConfig::default();
2239 let path = derive_path(&convo, &config);
2240
2241 assert_eq!(path.steps.len(), 1);
2242 let event_step = &path.steps[0];
2243 assert_eq!(event_step.step.actor, "tool:claude-code");
2244
2245 let convo_key = format!("agent://claude/{}", convo.session_id);
2246 let structural = event_step.change[&convo_key].structural.as_ref().unwrap();
2247 assert_eq!(structural.change_type, "conversation.event");
2248 assert_eq!(structural.extra["entry_type"], "system");
2249 assert_eq!(structural.extra["text"], "Turn duration: 5s");
2250 }
2251
2252 #[test]
2253 fn test_derive_path_empty_uuid_entry_gets_synthetic_id() {
2254 let mut convo = Conversation::new("test-session-12345678".to_string());
2255 let mut event = make_event_entry("", "permission-mode", "2024-01-01T00:00:00Z");
2256 event.uuid = String::new();
2257 convo.add_entry(event);
2258
2259 let config = DeriveConfig::default();
2260 let path = derive_path(&convo, &config);
2261
2262 assert_eq!(path.steps.len(), 1);
2263 assert_eq!(path.steps[0].step.id, "test-session-12345678-event-0");
2265 }
2266
2267 #[test]
2268 fn test_derive_path_event_steps_dont_advance_parent_chain() {
2269 let mut convo = Conversation::new("test-session-12345678".to_string());
2270 convo.add_entry(make_entry(
2271 "uuid-u1",
2272 MessageRole::User,
2273 "Hello",
2274 "2024-01-01T00:00:00Z",
2275 ));
2276 convo.add_entry(make_event_entry(
2277 "uuid-attach",
2278 "attachment",
2279 "2024-01-01T00:00:01Z",
2280 ));
2281 convo.add_entry(make_entry(
2282 "uuid-a1",
2283 MessageRole::Assistant,
2284 "Hi",
2285 "2024-01-01T00:00:02Z",
2286 ));
2287
2288 let config = DeriveConfig::default();
2289 let path = derive_path(&convo, &config);
2290
2291 assert_eq!(path.steps.len(), 3);
2292 assert_eq!(path.steps[2].step.parents, vec!["uuid-u1".to_string()]);
2294 assert_eq!(path.path.head, "uuid-a1");
2296 }
2297
2298 #[test]
2299 fn test_derive_path_event_step_extras_contain_metadata() {
2300 let mut convo = Conversation::new("test-session-12345678".to_string());
2301 let mut event =
2302 make_event_entry("uuid-ev1", "file-history-snapshot", "2024-01-01T00:00:00Z");
2303 event.cwd = Some("/home/user/project".to_string());
2304 event.version = Some("1.5.0".to_string());
2305 event.git_branch = Some("main".to_string());
2306 event.user_type = Some("external".to_string());
2307 event.snapshot = Some(serde_json::json!({"files": ["/src/main.rs"]}));
2308 event.message_id = Some("msg-123".to_string());
2309 convo.add_entry(event);
2310
2311 let config = DeriveConfig::default();
2312 let path = derive_path(&convo, &config);
2313
2314 let convo_key = format!("agent://claude/{}", convo.session_id);
2316 let event_step = path
2318 .steps
2319 .iter()
2320 .find(|s| {
2321 s.change
2322 .get(&convo_key)
2323 .and_then(|c| c.structural.as_ref())
2324 .is_some_and(|sc| sc.change_type == "conversation.event")
2325 })
2326 .expect("should have a conversation.event step");
2327 let extra = &event_step.change[&convo_key]
2328 .structural
2329 .as_ref()
2330 .unwrap()
2331 .extra;
2332
2333 assert_eq!(extra["entry_type"], "file-history-snapshot");
2334 assert_eq!(extra["cwd"], "/home/user/project");
2335 assert_eq!(extra["version"], "1.5.0");
2336 assert_eq!(extra["git_branch"], "main");
2337 assert_eq!(extra["user_type"], "external");
2338 assert_eq!(
2339 extra["snapshot"],
2340 serde_json::json!({"files": ["/src/main.rs"]})
2341 );
2342 assert_eq!(extra["message_id"], "msg-123");
2343 }
2344
2345 #[test]
2346 fn test_derive_path_event_entry_extra_preserved() {
2347 let mut convo = Conversation::new("test-session-12345678".to_string());
2348 let mut event = make_event_entry("uuid-ev2", "attachment", "2024-01-01T00:00:00Z");
2349 let mut extras = HashMap::new();
2350 extras.insert("hookName".to_string(), serde_json::json!("pre-tool-use"));
2351 extras.insert("toolName".to_string(), serde_json::json!("Bash"));
2352 event.extra = extras;
2353 convo.add_entry(event);
2354
2355 let config = DeriveConfig::default();
2356 let path = derive_path(&convo, &config);
2357
2358 let convo_key = format!("agent://claude/{}", convo.session_id);
2359 let extra = &path.steps[0].change[&convo_key]
2360 .structural
2361 .as_ref()
2362 .unwrap()
2363 .extra;
2364
2365 let entry_extra = extra
2366 .get("entry_extra")
2367 .expect("entry_extra should be present");
2368 assert_eq!(entry_extra["hookName"], "pre-tool-use");
2369 assert_eq!(entry_extra["toolName"], "Bash");
2370 }
2371
2372 #[test]
2373 fn test_derive_path_event_with_parent_uuid() {
2374 let mut convo = Conversation::new("test-session-12345678".to_string());
2375 convo.add_entry(make_entry(
2376 "uuid-u1",
2377 MessageRole::User,
2378 "Hello",
2379 "2024-01-01T00:00:00Z",
2380 ));
2381 let mut event = make_event_entry("uuid-ev-parent", "attachment", "2024-01-01T00:00:01Z");
2382 event.parent_uuid = Some("uuid-u1".to_string());
2383 convo.add_entry(event);
2384
2385 let config = DeriveConfig::default();
2386 let path = derive_path(&convo, &config);
2387
2388 assert_eq!(path.steps[1].step.parents, vec!["uuid-u1".to_string()]);
2390 }
2391
2392 #[test]
2393 fn test_resolve_local_dir_prefers_entry_cwd() {
2394 let dir = resolve_local_dir(
2395 Some("/from/config"),
2396 Some("/from/convo"),
2397 Some("/from/entry"),
2398 )
2399 .unwrap();
2400 assert_eq!(dir, "/from/entry");
2401 }
2402
2403 #[test]
2404 fn test_resolve_local_dir_falls_back_to_config_then_convo() {
2405 let dir = resolve_local_dir(Some("/from/config"), Some("/from/convo"), None).unwrap();
2406 assert_eq!(dir, "/from/config");
2407 let dir = resolve_local_dir(None, Some("/from/convo"), None).unwrap();
2408 assert_eq!(dir, "/from/convo");
2409 assert!(resolve_local_dir(None, None, None).is_none());
2410 }
2411
2412 #[test]
2413 fn test_resolve_local_dir_strips_file_prefix() {
2414 let dir = resolve_local_dir(Some("file:///usr/local/src"), None, None).unwrap();
2415 assert_eq!(dir, "/usr/local/src");
2416 }
2417
2418 #[test]
2423 fn test_write_tool_before_state_comes_from_git_head() {
2424 use std::process::Command;
2425 let tmp = tempfile::tempdir().unwrap();
2426 let root = tmp.path();
2427
2428 let run = |args: &[&str]| {
2430 let out = Command::new("git")
2431 .current_dir(root)
2432 .args(args)
2433 .output()
2434 .expect("git on PATH");
2435 assert!(
2436 out.status.success(),
2437 "git {:?} failed: {}",
2438 args,
2439 String::from_utf8_lossy(&out.stderr)
2440 );
2441 };
2442 run(&["init", "-q", "-b", "main"]);
2443 run(&["config", "user.email", "test@example.com"]);
2444 run(&["config", "user.name", "Test"]);
2445 run(&["config", "commit.gpgsign", "false"]);
2446 std::fs::write(root.join("hello.txt"), "old-content\n").unwrap();
2447 run(&["add", "hello.txt"]);
2448 run(&["commit", "-q", "-m", "init"]);
2449
2450 let mut convo = Conversation::new("test-session-42".to_string());
2453 let mut entry = make_entry(
2454 "uuid-w",
2455 MessageRole::Assistant,
2456 "writing",
2457 "2024-01-01T00:00:00Z",
2458 );
2459 entry.cwd = Some(root.to_string_lossy().into_owned());
2460 if let Some(msg) = &mut entry.message {
2462 msg.content = Some(MessageContent::Parts(vec![ContentPart::ToolUse {
2463 id: "tu-1".into(),
2464 name: "Write".into(),
2465 input: json!({
2466 "file_path": root.join("hello.txt").to_string_lossy(),
2467 "content": "new-content\n",
2468 }),
2469 }]));
2470 }
2471 convo.add_entry(entry);
2472
2473 let path = derive_path(&convo, &DeriveConfig::default());
2474
2475 let artifact_key = root.join("hello.txt").to_string_lossy().into_owned();
2477 let change = path
2478 .steps
2479 .iter()
2480 .find_map(|s| s.change.get(&artifact_key))
2481 .expect("tool step with hello.txt artifact");
2482 let raw = change.raw.as_deref().expect("Write should emit raw diff");
2483 assert!(
2484 raw.contains("-old-content"),
2485 "expected removal line, got:\n{raw}"
2486 );
2487 assert!(
2488 raw.contains("+new-content"),
2489 "expected addition line, got:\n{raw}"
2490 );
2491 }
2492
2493 #[test]
2497 fn test_write_tool_falls_back_to_addition_only_without_git() {
2498 let tmp = tempfile::tempdir().unwrap();
2499 let root = tmp.path();
2500
2501 let mut convo = Conversation::new("test-session-43".to_string());
2502 let mut entry = make_entry(
2503 "uuid-w",
2504 MessageRole::Assistant,
2505 "writing",
2506 "2024-01-01T00:00:00Z",
2507 );
2508 entry.cwd = Some(root.to_string_lossy().into_owned());
2509 if let Some(msg) = &mut entry.message {
2510 msg.content = Some(MessageContent::Parts(vec![ContentPart::ToolUse {
2511 id: "tu-1".into(),
2512 name: "Write".into(),
2513 input: json!({
2514 "file_path": root.join("new.txt").to_string_lossy(),
2515 "content": "fresh\n",
2516 }),
2517 }]));
2518 }
2519 convo.add_entry(entry);
2520
2521 let path = derive_path(&convo, &DeriveConfig::default());
2522 let artifact_key = root.join("new.txt").to_string_lossy().into_owned();
2523 let raw = path
2524 .steps
2525 .iter()
2526 .find_map(|s| s.change.get(&artifact_key))
2527 .and_then(|c| c.raw.as_deref())
2528 .expect("Write should emit raw diff");
2529 assert!(raw.contains("+fresh"));
2530 assert!(
2532 !raw.lines()
2533 .any(|l| l.starts_with('-') && !l.starts_with("---")),
2534 "unexpected removal line in:\n{raw}"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_derive_path_event_with_tool_use_result() {
2540 let mut convo = Conversation::new("test-session-12345678".to_string());
2541 let mut event = make_event_entry("uuid-ev-tur", "attachment", "2024-01-01T00:00:00Z");
2542 event.tool_use_result = Some(serde_json::json!({
2543 "tool_use_id": "tu-123",
2544 "content": "hook output"
2545 }));
2546 convo.add_entry(event);
2547
2548 let config = DeriveConfig::default();
2549 let path = derive_path(&convo, &config);
2550
2551 let convo_key = format!("agent://claude/{}", convo.session_id);
2552 let extra = &path.steps[0].change[&convo_key]
2553 .structural
2554 .as_ref()
2555 .unwrap()
2556 .extra;
2557
2558 assert_eq!(extra["tool_use_result"]["tool_use_id"], "tu-123");
2559 assert_eq!(extra["tool_use_result"]["content"], "hook output");
2560 }
2561}