Skip to main content

toolpath_claude/
derive.rs

1//! Derive Toolpath documents from Claude conversation logs.
2//!
3//! The conversation itself is treated as an artifact under change. Each turn
4//! appends to `agent://claude/<session-id>` via a `conversation.append`
5//! structural operation. Tool invocations produce separate steps with
6//! `tool.invoke` structural changes.
7
8use 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
20/// Best-effort lookup of a file's contents at `HEAD` in the git repo
21/// rooted at `repo_dir` (or one of its ancestors).
22///
23/// Shells out to `git show HEAD:<relative-path>`. Returns `None` when
24/// any of these hold: `repo_dir` isn't inside a git repo, `path` isn't
25/// tracked at `HEAD`, `git` isn't on `PATH`, or the command otherwise
26/// fails. Used by the `Write`-tool before-state resolver; callers must
27/// fall through to the empty-string diff on `None`.
28///
29/// `path` may be absolute or relative. If absolute, it's made relative
30/// to `repo_dir` before invoking git; if it doesn't sit beneath
31/// `repo_dir`, returns `None`.
32fn 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    // `git show HEAD:<path>` expects forward-slash paths.
41    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
55/// Resolve the local working-directory root for a conversation entry,
56/// preferring the entry's own `cwd` (accurate per-turn) and falling
57/// back to the conversation-level project path. Strips any `file://`
58/// prefix the config may have carried.
59fn 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/// Configuration for deriving Toolpath documents from Claude conversations.
70#[derive(Default)]
71pub struct DeriveConfig {
72    /// Override the project path used for `path.base.uri`.
73    pub project_path: Option<String>,
74    /// Include thinking blocks in the conversation artifact.
75    pub include_thinking: bool,
76}
77
78/// Map a Claude tool name to a category string.
79///
80/// Keep in sync with [`crate::provider::tool_category`] — same table,
81/// different return type (string for path-doc serialization vs
82/// [`toolpath_convo::ToolCategory`] for in-memory views).
83fn 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
95/// Whether a tool operates on files (uses `file_path` input as artifact key).
96fn is_file_tool(name: &str) -> bool {
97    matches!(
98        name,
99        "Read" | "Write" | "Edit" | "Glob" | "Grep" | "NotebookEdit"
100    )
101}
102
103/// A collected tool use from a content part.
104struct ToolUseInfo {
105    id: String,
106    name: String,
107    input: serde_json::Value,
108}
109
110/// Derive a single Toolpath Path from a Claude conversation.
111///
112/// The conversation is modeled as an artifact at `agent://claude/<session-id>`.
113/// Each user or assistant turn produces a step whose `change` map contains
114/// a `conversation.append` structural change on that artifact. Assistant turns
115/// with tool uses additionally produce one step per tool type, each containing
116/// `tool.invoke` structural changes.
117pub 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    // Build a ConversationView with cross-entry tool result assembly
122    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    // Generate conversation.init step from first entry metadata
131    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        // Determine if this is a conversational entry (user/assistant with message)
194        // or a non-message event entry
195        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            // Event entry — capture as conversation.event step
201            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            // Register tool:claude-code actor for event entries
216            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            // Include system message text if present
248            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            // Entry-level extras
255            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            // Event steps do NOT advance last_step_id
284            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            // is_conversational guarantees User or Assistant
328            MessageRole::System => unreachable!(),
329        };
330
331        // Collect conversation text and tool uses from this turn
332        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        // Collect tool name list for the summary field
366        let tool_names: Vec<String> = tool_use_infos.iter().map(|t| t.name.clone()).collect();
367
368        // Skip entries with no conversation content and no tool uses
369        if text_parts.is_empty() && thinking_parts.is_empty() && tool_use_infos.is_empty() {
370            continue;
371        }
372
373        // Build the conversation artifact change
374        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        // Add model, stop_reason, and usage fields from the message
389        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        // Per-entry metadata for round-trip fidelity
411        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        // Entry-level extras (isMeta, slug, entrypoint, promptId, etc.)
427        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        // Build conversation step using full UUID as step ID
443        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        // Emit tool invocation steps (one per tool type, grouped)
467        if !tool_use_infos.is_empty() {
468            // Group tool uses by tool name, preserving order of first occurrence
469            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                // Register the tool actor
487                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                    // Determine artifact key
499                    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                    // Look up assembled tool result from ConversationView
525                    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                    // For file-write tools (Edit / Write / MultiEdit /
535                    // NotebookEdit), compute a unified diff so the artifact
536                    // carries the actual change, not just the raw tool input.
537                    //
538                    // For `Write { content }` specifically the JSONL log
539                    // doesn't capture the prior file state, so we consult
540                    // git HEAD as a best-effort pre-image. If the project
541                    // isn't a git repo or the file isn't tracked, we fall
542                    // back to diffing against "" (addition-only hunk).
543                    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                // Tool steps do NOT advance last_step_id
588                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
621/// Derive Toolpath Paths from multiple conversations in a project.
622pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
623    conversations
624        .iter()
625        .map(|c| derive_path(c, config))
626        .collect()
627}
628
629/// Return the first `n` characters of a string, safe for any UTF-8 content.
630fn 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    // ── safe_prefix ────────────────────────────────────────────────────
688
689    #[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    // ── tool helpers ──────────────────────────────────────────────────
708
709    #[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    // ── derive_path ────────────────────────────────────────────────────
736
737    #[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        // Step IDs are full UUIDs
761        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        // Parents are full UUIDs
795        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        // Artifact key uses agent:// scheme
823        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        // meta.intent should NOT be set (we removed it as redundant)
846        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        // Assistant actor depends on model (None in our test)
873        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        // Empty text, no tool uses, no file changes -> should be skipped
897        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        // System message captured as event, plus user message
927        assert_eq!(path.steps.len(), 2);
928        // First step is the system event
929        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        // Second step is the user message
939        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        // Now produces 2 steps: conversation + tool
987        assert_eq!(path.steps.len(), 2);
988
989        // Conversation step has the conversation artifact
990        let convo_key = format!("agent://claude/{}", convo.session_id);
991        assert!(path.steps[0].change.contains_key(&convo_key));
992
993        // Tool step has the file artifact with tool.invoke
994        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        // Sidechain step should reference e1's full UUID as parent
1046        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    // ── derive_project ─────────────────────────────────────────────────
1056
1057    #[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        // Head should point to the last conversation step (full UUID)
1101        assert_eq!(path.path.head, "uuid-2222");
1102    }
1103
1104    // ── new tests for enriched derive ──────────────────────────────────
1105
1106    #[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        // 1 conversation step + 1 tool step (both Reads grouped)
1277        assert_eq!(path.steps.len(), 2);
1278        assert_eq!(path.steps[1].step.id, "uuid-multi-tool-Read");
1279        // Two artifact changes in the tool step
1280        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        // 1 conversation step + 2 tool steps (Read and Bash)
1334        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        // Bash tool uses agent:// URI since it's not a file tool
1339        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        // With thinking enabled
1434        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        // Text should be separate from thinking
1448        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        // Steps: conversation(uuid-a1), tool(uuid-a1-tool-Write), conversation(uuid-u2)
1552        assert_eq!(path.steps.len(), 3);
1553        // The user step's parent should be the conversation step, not the tool step
1554        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        // steps[0] = assistant turn, steps[1] = tool step (siblings).
1652        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        // Leading `/` is stripped from the header so `a/`/`b/` don't double up
1659        // (git-style prefixes already denote the repo root). See #36.
1660        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        // Sanity-check the parent wiring that the chat view relies on:
1671        // the tool step's parent is the assistant step, and they share
1672        // the same `entry.uuid` root so the frontend splice works.
1673        assert_eq!(tool_step.step.parents, vec![path.steps[0].step.id.clone()]);
1674    }
1675
1676    // ── tool result assembly ──────────────────────────────────────────
1677
1678    #[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        // Assistant entry with a tool use
1685        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        // Tool-result-only user entry
1723        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        // Should produce 2 steps: conversation + tool (tool-result-only entry skipped)
1759        assert_eq!(path.steps.len(), 2);
1760
1761        // The tool step should have the result assembled
1762        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        // Tool result with error
1814        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    // ── conversation.init step ────────────────────────────────────────
1861
1862    #[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        // Should have init step + conversation step
1874        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        // The first conversation step should have init as parent
1899        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        // Standard make_entry has no cwd/version/git_branch
1926        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        // No init step should be generated
1938        assert_eq!(path.steps.len(), 1);
1939        assert_eq!(path.steps[0].step.id, "uuid-1");
1940    }
1941
1942    // ── per-entry metadata capture ──────────────────────────────────
1943
1944    #[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        // Find the conversation.append step (skip init step)
1961        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        // Standard make_entry has no cwd/version/git_branch/user_type/request_id/extra
2119        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        // None of the per-entry metadata fields should be present
2138        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    // ── conversation.event steps (non-message entries) ────────────────
2165
2166    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        // 3 steps: user, attachment event, assistant
2212        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        // Synthetic ID: {session_id}-event-{index}
2260        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        // The assistant step's parent should be the USER step, not the event step
2289        assert_eq!(path.steps[2].step.parents, vec!["uuid-u1".to_string()]);
2290        // The head should be the assistant step, not the event step
2291        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        // First step is init (because cwd is present), second is the event
2311        let convo_key = format!("agent://claude/{}", convo.session_id);
2312        // Find the event step (skip init)
2313        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        // Event step should use its own parent_uuid
2385        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    /// End-to-end: spin up a real tempdir git repo with a tracked file,
2415    /// run a Claude Write-tool invocation through `derive_path`, and
2416    /// verify the resulting `raw` diff shows `-` lines for the prior
2417    /// committed content (not just `+` additions).
2418    #[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        // Initialise a tiny git repo with a file checked in at HEAD.
2425        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        // Build a minimal Conversation with one assistant entry that
2447        // carries a Write tool use against `hello.txt`.
2448        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        // Override message content with a Write tool_use content part.
2457        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        // Find the tool step and its Write artifact change.
2472        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    /// Symmetric fallback: no git repo → before-state resolver returns
2490    /// None → `file_write_diff` produces an addition-only diff (existing
2491    /// behaviour preserved for new files / non-git projects).
2492    #[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        // No `-` lines (other than the `---` header).
2527        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}