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