Skip to main content

toolpath_convo/
derive.rs

1//! Shared derivation: [`ConversationView`] → [`toolpath::v1::Path`].
2//!
3//! Provider-agnostic mapping used by the Pi, Claude, and future conversation
4//! providers. Takes a [`ConversationView`] and emits a [`Path`] document with
5//! one step per turn and a `conversation.append` structural change carrying
6//! the turn's text, thinking, tool uses, and token usage.
7
8use std::collections::HashMap;
9
10use toolpath::v1::{
11    ActorDefinition, ArtifactChange, Base, Path, PathIdentity, PathMeta, Step, StepIdentity,
12    StructuralChange,
13};
14
15use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn};
16
17/// Configuration for [`derive_path`].
18#[derive(Debug, Clone)]
19pub struct DeriveConfig {
20    /// Override `path.base.uri`. If `None`, fall back to the first turn's
21    /// `environment.working_dir`.
22    pub base_uri: Option<String>,
23    /// Override `path.id`. If `None`, derive as `path-{provider}-{8chars}`.
24    pub path_id: Option<String>,
25    /// Override `meta.title`. If `None`, default to `"{provider} session: {8chars}"`.
26    pub title: Option<String>,
27    /// Include `Turn.thinking` in the structural change extras.
28    pub include_thinking: bool,
29    /// Include `Turn.tool_uses` in the structural change extras.
30    pub include_tool_uses: bool,
31}
32
33impl Default for DeriveConfig {
34    fn default() -> Self {
35        Self {
36            base_uri: None,
37            path_id: None,
38            title: None,
39            include_thinking: true,
40            include_tool_uses: true,
41        }
42    }
43}
44
45/// Derive a [`Path`] from a [`ConversationView`].
46pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path {
47    let provider = view.provider_id.as_deref().unwrap_or("unknown");
48    let id_prefix: String = view.id.chars().take(8).collect();
49
50    let path_id = config
51        .path_id
52        .clone()
53        .unwrap_or_else(|| format!("path-{}-{}", provider, id_prefix));
54
55    // Base URI: config override wins; otherwise first turn's working_dir
56    let base = config
57        .base_uri
58        .clone()
59        .map(|uri| Base { uri, ref_str: None })
60        .or_else(|| {
61            view.turns
62                .iter()
63                .find_map(|t| t.environment.as_ref()?.working_dir.clone())
64                .map(|wd| {
65                    let uri = if wd.starts_with('/') {
66                        format!("file://{}", wd)
67                    } else {
68                        wd
69                    };
70                    Base { uri, ref_str: None }
71                })
72        });
73
74    let conv_artifact_key = format!("{}://{}", provider, view.id);
75
76    let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
77    let mut turn_to_step: HashMap<String, String> = HashMap::new();
78    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
79
80    for (idx, turn) in view.turns.iter().enumerate() {
81        let step_id = format!("step-{:04}", idx + 1);
82        turn_to_step.insert(turn.id.clone(), step_id.clone());
83
84        let actor = actor_for_turn(turn, provider);
85        record_actor(&mut actors, &actor, turn, provider, view);
86
87        let mut step = Step {
88            step: StepIdentity {
89                id: step_id,
90                parents: Vec::new(),
91                actor,
92                timestamp: turn.timestamp.clone(),
93            },
94            change: HashMap::new(),
95            meta: None,
96        };
97
98        // Parent mapping
99        if let Some(parent_id) = &turn.parent_id
100            && let Some(parent_step_id) = turn_to_step.get(parent_id)
101        {
102            step.step.parents.push(parent_step_id.clone());
103        }
104
105        // Build conversation.append structural change extras
106        let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
107        extra.insert(
108            "role".to_string(),
109            serde_json::Value::String(turn.role.to_string()),
110        );
111        extra.insert(
112            "text".to_string(),
113            serde_json::Value::String(turn.text.clone()),
114        );
115
116        if config.include_thinking
117            && let Some(thinking) = &turn.thinking
118        {
119            extra.insert(
120                "thinking".to_string(),
121                serde_json::Value::String(thinking.clone()),
122            );
123        }
124
125        if config.include_tool_uses && !turn.tool_uses.is_empty() {
126            let arr: Vec<serde_json::Value> = turn
127                .tool_uses
128                .iter()
129                .map(|t| {
130                    let mut obj = serde_json::json!({
131                        "id": t.id,
132                        "name": t.name,
133                        "input": t.input,
134                        "category": t.category,
135                    });
136                    if let Some(result) = &t.result
137                        && let Ok(v) = serde_json::to_value(result)
138                    {
139                        obj.as_object_mut().unwrap().insert("result".to_string(), v);
140                    }
141                    obj
142                })
143                .collect();
144            extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
145        }
146
147        if let Some(usage) = &turn.token_usage
148            && let Ok(v) = serde_json::to_value(usage)
149        {
150            extra.insert("token_usage".to_string(), v);
151        }
152
153        if !turn.delegations.is_empty()
154            && let Ok(v) = serde_json::to_value(&turn.delegations)
155        {
156            extra.insert("delegations".to_string(), v);
157        }
158
159        if let Some(stop_reason) = &turn.stop_reason {
160            extra.insert(
161                "stop_reason".to_string(),
162                serde_json::Value::String(stop_reason.clone()),
163            );
164        }
165
166        if let Some(env) = &turn.environment
167            && let Ok(v) = serde_json::to_value(env)
168        {
169            extra.insert("environment".to_string(), v);
170        }
171
172        if !turn.extra.is_empty()
173            && let Ok(v) = serde_json::to_value(&turn.extra)
174        {
175            extra.insert("turn_extra".to_string(), v);
176        }
177
178        step.change.insert(
179            conv_artifact_key.clone(),
180            ArtifactChange {
181                raw: None,
182                structural: Some(StructuralChange {
183                    change_type: "conversation.append".to_string(),
184                    extra,
185                }),
186            },
187        );
188
189        // File-write tool invocations → artifact changes. Each gets a unified
190        // diff in `raw` (so it renders like a git diff) plus the structured
191        // before/after strings in `structural.extra` for tools that want to
192        // re-apply or inspect the op programmatically.
193        for tool in &turn.tool_uses {
194            if tool.category != Some(ToolCategory::FileWrite) {
195                continue;
196            }
197            let Some(path) = extract_file_path(tool) else {
198                continue;
199            };
200            // Shared derivation doesn't have access to a local checkout,
201            // so it can't resolve pre-write file state. Providers that do
202            // (e.g. `toolpath-claude`) build their own steps and pass a
203            // resolved `before_state` directly to `file_write_diff`.
204            let (raw, mut t_extra) = file_write_change(tool, &path, None);
205            t_extra.insert(
206                "tool".to_string(),
207                serde_json::Value::String(tool.name.clone()),
208            );
209            t_extra.insert(
210                "tool_id".to_string(),
211                serde_json::Value::String(tool.id.clone()),
212            );
213            step.change.insert(
214                path,
215                ArtifactChange {
216                    raw,
217                    structural: Some(StructuralChange {
218                        change_type: "file.write".to_string(),
219                        extra: t_extra,
220                    }),
221                },
222            );
223        }
224
225        steps.push(step);
226    }
227
228    let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
229
230    // Meta
231    let title = config
232        .title
233        .clone()
234        .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
235
236    let mut meta = PathMeta {
237        title: Some(title),
238        source: view.provider_id.clone(),
239        ..Default::default()
240    };
241
242    if !actors.is_empty() {
243        meta.actors = Some(actors);
244    }
245
246    if !view.files_changed.is_empty()
247        && let Ok(v) = serde_json::to_value(&view.files_changed)
248    {
249        meta.extra.insert("files_changed".to_string(), v);
250    }
251
252    Path {
253        path: PathIdentity {
254            id: path_id,
255            base,
256            head,
257            graph_ref: None,
258        },
259        steps,
260        meta: Some(meta),
261    }
262}
263
264fn actor_for_turn(turn: &Turn, provider: &str) -> String {
265    match &turn.role {
266        Role::User => "human:user".to_string(),
267        Role::Assistant => {
268            let model = turn.model.as_deref().unwrap_or("unknown");
269            format!("agent:{}", model)
270        }
271        Role::System => format!("system:{}", provider),
272        Role::Other(s) => format!("{}:unknown", s),
273    }
274}
275
276fn record_actor(
277    actors: &mut HashMap<String, ActorDefinition>,
278    actor: &str,
279    turn: &Turn,
280    provider: &str,
281    _view: &ConversationView,
282) {
283    if actors.contains_key(actor) {
284        return;
285    }
286    let def = if let Some(rest) = actor.strip_prefix("agent:") {
287        ActorDefinition {
288            name: Some(rest.to_string()),
289            provider: Some(provider.to_string()),
290            model: turn.model.clone(),
291            identities: vec![],
292            keys: vec![],
293        }
294    } else if let Some(rest) = actor.strip_prefix("human:") {
295        ActorDefinition {
296            name: Some(rest.to_string()),
297            ..Default::default()
298        }
299    } else {
300        // system:*, other:*
301        let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
302        ActorDefinition {
303            name: Some(name),
304            ..Default::default()
305        }
306    };
307    actors.insert(actor.to_string(), def);
308}
309
310fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
311    for field in &["file_path", "path", "filename", "file"] {
312        if let Some(v) = tool.input.get(*field)
313            && let Some(s) = v.as_str()
314        {
315            return Some(s.to_string());
316        }
317    }
318    None
319}
320
321/// Build `(raw_diff, extra)` for a single FileWrite tool invocation.
322///
323/// See [`file_write_diff`] for the input shapes handled; this helper
324/// additionally captures the structured before/after strings in `extra`.
325///
326/// `before_state` is threaded through to [`file_write_diff`] for the
327/// `Write { content }` shape: when `Some`, it becomes the pre-image and
328/// is also recorded in `extra["before"]`. When `None`, the diff falls
329/// back to an empty pre-image (addition-only hunk).
330fn file_write_change(
331    tool: &ToolInvocation,
332    path: &str,
333    before_state: Option<&str>,
334) -> (Option<String>, HashMap<String, serde_json::Value>) {
335    let input = &tool.input;
336    let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
337
338    let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
339
340    if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
341        extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
342        extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
343    } else if let Some(content) = str_field("content") {
344        if let Some(before) = before_state {
345            extra.insert(
346                "before".to_string(),
347                serde_json::Value::String(before.to_string()),
348            );
349        }
350        extra.insert("after".to_string(), serde_json::Value::String(content));
351    } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
352        extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
353    }
354
355    (
356        file_write_diff(&tool.name, input, path, before_state),
357        extra,
358    )
359}
360
361/// Compute a unified diff string for a file-write tool invocation, given the
362/// raw tool input JSON. Handles Claude's Edit / Write / MultiEdit / NotebookEdit
363/// shapes; returns `None` for any unrecognised shape or if nothing to diff.
364///
365/// Exposed so non-Conversation derivers (e.g. `toolpath-claude`'s bespoke
366/// Claude-JSONL deriver, which emits its own `tool.invoke` steps) can populate
367/// `ArtifactChange.raw` without reimplementing the diff logic.
368///
369/// Shapes handled:
370///   - `Edit    { old_string, new_string, ... }`  → diff old→new
371///   - `Write   { content }`                      → diff `before_state`→content
372///     (uses `""` when `before_state` is `None`, producing an addition-only hunk)
373///   - `MultiEdit { edits: [{old_string, new_string}, ...] }` → hunks joined,
374///     each prefixed with `# edit N/total` so consumers can tell them apart.
375///
376/// # `before_state` for `Write`
377///
378/// The `Write` tool replaces a file's whole contents but the JSONL log
379/// doesn't carry the prior state. Callers that can reconstruct it
380/// out-of-band (e.g. by reading `git show HEAD:<path>`) should pass it
381/// as `before_state`; the resulting diff shows honest `-`/`+` lines for
382/// replaced content. When `None`, we fall back to diffing against the
383/// empty string — correct for new files, misleading for overwrites, but
384/// the best we can do from the log alone.
385///
386/// `before_state` is ignored for `Edit` / `MultiEdit` shapes, which
387/// already carry their own `old_string`/`new_string` pre-image.
388pub fn file_write_diff(
389    tool_name: &str,
390    input: &serde_json::Value,
391    path: &str,
392    before_state: Option<&str>,
393) -> Option<String> {
394    let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
395
396    // Edit / NotebookEdit / anything else with old/new pair.
397    if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
398        return Some(unified_diff(path, old, new));
399    }
400
401    // Write — whole-file content; diff against the caller-supplied
402    // before-state when present, else empty (addition-only hunk).
403    if let Some(content) = str_field("content") {
404        let before = before_state.unwrap_or("");
405        return Some(unified_diff(path, before, content));
406    }
407
408    // MultiEdit — multiple sequential edits on one file.
409    if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
410        if edits.is_empty() {
411            return None;
412        }
413        let mut parts: Vec<String> = Vec::new();
414        for (idx, edit) in edits.iter().enumerate() {
415            let old = edit
416                .get("old_string")
417                .and_then(|v| v.as_str())
418                .unwrap_or("");
419            let new = edit
420                .get("new_string")
421                .and_then(|v| v.as_str())
422                .unwrap_or("");
423            let header = format!("# edit {}/{}", idx + 1, edits.len());
424            parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
425        }
426        return Some(parts.join("\n"));
427    }
428
429    // Unused today, but keeps `tool_name` addressable for future per-tool
430    // branches (e.g. NotebookEdit may one day need cell-scoped diffs).
431    let _ = tool_name;
432    None
433}
434
435/// Produce a minimal unified-diff string using `similar::TextDiff`.
436///
437/// Always emits a `--- a/{path}` / `+++ b/{path}` header even when one side is
438/// empty so downstream renderers can anchor the change to the file it touched.
439///
440/// Any leading `/` on `path` is stripped before splicing into the header —
441/// git-style `a/` and `b/` prefixes already denote the repo root, so an
442/// absolute path like `/abs/file.rs` would otherwise emit `--- a//abs/file.rs`,
443/// which breaks `patch(1)` and other consumers that parse the header.
444pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
445    use similar::TextDiff;
446    let diff = TextDiff::from_lines(before, after);
447    let display = path.trim_start_matches('/');
448    let mut out = String::new();
449    out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
450    out.push_str(
451        &diff
452            .unified_diff()
453            .context_radius(3)
454            .header("", "")
455            .to_string(),
456    );
457    out
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
464
465    fn base_turn(id: &str, role: Role) -> Turn {
466        Turn {
467            id: id.to_string(),
468            parent_id: None,
469            role,
470            timestamp: "2026-01-01T00:00:00Z".to_string(),
471            text: String::new(),
472            thinking: None,
473            tool_uses: vec![],
474            model: None,
475            stop_reason: None,
476            token_usage: None,
477            environment: None,
478            delegations: vec![],
479            extra: HashMap::new(),
480        }
481    }
482
483    fn view_with(turns: Vec<Turn>) -> ConversationView {
484        ConversationView {
485            id: "abcdef012345".to_string(),
486            started_at: None,
487            last_activity: None,
488            turns,
489            total_usage: None,
490            provider_id: Some("pi".to_string()),
491            files_changed: vec![],
492            session_ids: vec![],
493            events: vec![],
494        }
495    }
496
497    fn conv_change(step: &Step) -> &StructuralChange {
498        let key = step
499            .change
500            .keys()
501            .find(|k| k.contains("://"))
502            .expect("conversation artifact key present");
503        step.change[key].structural.as_ref().unwrap()
504    }
505
506    #[test]
507    fn test_empty_view() {
508        let view = view_with(vec![]);
509        let path = derive_path(&view, &DeriveConfig::default());
510        assert!(path.steps.is_empty());
511        assert_eq!(path.path.head, "");
512    }
513
514    #[test]
515    fn test_single_user_turn() {
516        let mut turn = base_turn("t1", Role::User);
517        turn.text = "hello".into();
518        let view = view_with(vec![turn]);
519        let path = derive_path(&view, &DeriveConfig::default());
520        assert_eq!(path.steps.len(), 1);
521        assert_eq!(path.steps[0].step.actor, "human:user");
522        assert_eq!(path.steps[0].step.id, "step-0001");
523    }
524
525    #[test]
526    fn test_single_assistant_turn() {
527        let mut turn = base_turn("t1", Role::Assistant);
528        turn.model = Some("claude-opus-4-7".into());
529        let view = view_with(vec![turn]);
530        let path = derive_path(&view, &DeriveConfig::default());
531        assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
532    }
533
534    #[test]
535    fn test_assistant_without_model() {
536        let turn = base_turn("t1", Role::Assistant);
537        let view = view_with(vec![turn]);
538        let path = derive_path(&view, &DeriveConfig::default());
539        assert_eq!(path.steps[0].step.actor, "agent:unknown");
540    }
541
542    #[test]
543    fn test_system_role() {
544        let turn = base_turn("t1", Role::System);
545        let view = view_with(vec![turn]);
546        let path = derive_path(&view, &DeriveConfig::default());
547        assert_eq!(path.steps[0].step.actor, "system:pi");
548    }
549
550    #[test]
551    fn test_other_role() {
552        let turn = base_turn("t1", Role::Other("tool".into()));
553        let view = view_with(vec![turn]);
554        let path = derive_path(&view, &DeriveConfig::default());
555        assert_eq!(path.steps[0].step.actor, "tool:unknown");
556    }
557
558    #[test]
559    fn test_parent_id_preserved() {
560        let t1 = base_turn("t1", Role::User);
561        let mut t2 = base_turn("t2", Role::Assistant);
562        t2.parent_id = Some("t1".into());
563        t2.model = Some("m".into());
564        let view = view_with(vec![t1, t2]);
565        let path = derive_path(&view, &DeriveConfig::default());
566        assert_eq!(path.steps[1].step.parents, vec!["step-0001".to_string()]);
567    }
568
569    fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
570        ToolInvocation {
571            id: id.to_string(),
572            name: name.to_string(),
573            input,
574            result: None,
575            category: Some(ToolCategory::FileWrite),
576        }
577    }
578
579    #[test]
580    fn test_tool_use_filewrite_with_file_path_field() {
581        let mut turn = base_turn("t1", Role::Assistant);
582        turn.tool_uses = vec![fw_tool(
583            "Write",
584            "tu1",
585            serde_json::json!({"file_path": "src/main.rs"}),
586        )];
587        let view = view_with(vec![turn]);
588        let path = derive_path(&view, &DeriveConfig::default());
589        assert!(path.steps[0].change.contains_key("src/main.rs"));
590        let sc = path.steps[0].change["src/main.rs"]
591            .structural
592            .as_ref()
593            .unwrap();
594        assert_eq!(sc.change_type, "file.write");
595        assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
596        assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
597    }
598
599    #[test]
600    fn test_tool_use_filewrite_with_path_field() {
601        let mut turn = base_turn("t1", Role::Assistant);
602        turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
603        let view = view_with(vec![turn]);
604        let path = derive_path(&view, &DeriveConfig::default());
605        assert!(path.steps[0].change.contains_key("a.rs"));
606    }
607
608    #[test]
609    fn test_tool_use_filewrite_with_filename_field() {
610        let mut turn = base_turn("t1", Role::Assistant);
611        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
612        let view = view_with(vec![turn]);
613        let path = derive_path(&view, &DeriveConfig::default());
614        assert!(path.steps[0].change.contains_key("b.rs"));
615    }
616
617    #[test]
618    fn test_tool_use_filewrite_with_file_field() {
619        let mut turn = base_turn("t1", Role::Assistant);
620        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
621        let view = view_with(vec![turn]);
622        let path = derive_path(&view, &DeriveConfig::default());
623        assert!(path.steps[0].change.contains_key("c.rs"));
624    }
625
626    #[test]
627    fn test_tool_use_filewrite_no_recognized_field() {
628        let mut turn = base_turn("t1", Role::Assistant);
629        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
630        let view = view_with(vec![turn]);
631        let path = derive_path(&view, &DeriveConfig::default());
632        assert_eq!(path.steps[0].change.len(), 1);
633        let sc = conv_change(&path.steps[0]);
634        assert!(sc.extra.contains_key("tool_uses"));
635    }
636
637    #[test]
638    fn test_tool_use_non_filewrite_ignored() {
639        let mut turn = base_turn("t1", Role::Assistant);
640        turn.tool_uses = vec![ToolInvocation {
641            id: "tu1".into(),
642            name: "Read".into(),
643            input: serde_json::json!({"file_path": "x.rs"}),
644            result: None,
645            category: Some(ToolCategory::FileRead),
646        }];
647        let view = view_with(vec![turn]);
648        let path = derive_path(&view, &DeriveConfig::default());
649        assert!(!path.steps[0].change.contains_key("x.rs"));
650        assert_eq!(path.steps[0].change.len(), 1);
651    }
652
653    #[test]
654    fn test_tool_use_edit_emits_unified_diff() {
655        let mut turn = base_turn("t1", Role::Assistant);
656        turn.tool_uses = vec![fw_tool(
657            "Edit",
658            "tu1",
659            serde_json::json!({
660                "file_path": "src/login.rs",
661                "old_string": "validate_token()",
662                "new_string": "validate_token_v2()",
663            }),
664        )];
665        let view = view_with(vec![turn]);
666        let path = derive_path(&view, &DeriveConfig::default());
667        let ch = &path.steps[0].change["src/login.rs"];
668        let raw = ch.raw.as_deref().expect("edit should emit unified diff");
669        assert!(raw.contains("--- a/src/login.rs"));
670        assert!(raw.contains("+++ b/src/login.rs"));
671        assert!(raw.contains("-validate_token()"));
672        assert!(raw.contains("+validate_token_v2()"));
673        let sc = ch.structural.as_ref().unwrap();
674        assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
675        assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
676    }
677
678    #[test]
679    fn test_tool_use_write_emits_full_content_diff() {
680        let mut turn = base_turn("t1", Role::Assistant);
681        turn.tool_uses = vec![fw_tool(
682            "Write",
683            "tu1",
684            serde_json::json!({
685                "file_path": "hello.txt",
686                "content": "hi\nthere\n",
687            }),
688        )];
689        let view = view_with(vec![turn]);
690        let path = derive_path(&view, &DeriveConfig::default());
691        let ch = &path.steps[0].change["hello.txt"];
692        let raw = ch.raw.as_deref().expect("write should emit diff");
693        assert!(raw.contains("+hi"));
694        assert!(raw.contains("+there"));
695        let sc = ch.structural.as_ref().unwrap();
696        assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
697        assert!(!sc.extra.contains_key("before"));
698    }
699
700    #[test]
701    fn test_file_write_diff_write_without_before_state_is_addition_only() {
702        // Backwards-compatible fallback: `None` → diff against "".
703        let input = serde_json::json!({
704            "file_path": "hello.txt",
705            "content": "hi\nthere\n",
706        });
707        let raw =
708            file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
709        assert!(raw.contains("+hi"));
710        assert!(raw.contains("+there"));
711        // No `-` lines — nothing was there before.
712        assert!(
713            !raw.lines()
714                .any(|l| l.starts_with('-') && !l.starts_with("---"))
715        );
716    }
717
718    #[test]
719    fn test_file_write_diff_write_with_before_state_shows_replacement() {
720        let input = serde_json::json!({
721            "file_path": "hello.txt",
722            "content": "hi\nthere\n",
723        });
724        let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
725            .expect("write should emit diff");
726        // Before content should appear as removals.
727        assert!(raw.contains("-bye"));
728        assert!(raw.contains("-friend"));
729        // After content should appear as additions.
730        assert!(raw.contains("+hi"));
731        assert!(raw.contains("+there"));
732    }
733
734    #[test]
735    fn test_file_write_diff_before_state_ignored_for_edit_shape() {
736        // `Edit` has its own `old_string`; supplied before_state should
737        // be ignored.
738        let input = serde_json::json!({
739            "file_path": "a.rs",
740            "old_string": "foo",
741            "new_string": "bar",
742        });
743        let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
744            .expect("edit should emit diff");
745        assert!(raw.contains("-foo"));
746        assert!(raw.contains("+bar"));
747        assert!(!raw.contains("something else entirely"));
748    }
749
750    #[test]
751    fn test_unified_diff_strips_leading_slash_on_absolute_path() {
752        // Regression for #36: headers for absolute paths must not contain `a//`.
753        let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
754        assert!(
755            raw.contains("--- a/abs/path.rs\n"),
756            "missing stripped --- header: {raw}"
757        );
758        assert!(
759            raw.contains("+++ b/abs/path.rs\n"),
760            "missing stripped +++ header: {raw}"
761        );
762        assert!(
763            !raw.contains("a//"),
764            "header should not contain doubled slash: {raw}"
765        );
766        assert!(
767            !raw.contains("b//"),
768            "header should not contain doubled slash: {raw}"
769        );
770    }
771
772    #[test]
773    fn test_unified_diff_preserves_relative_path() {
774        // Relative paths (no leading slash) are unchanged — only a single
775        // leading `/` is stripped.
776        let raw = unified_diff("src/login.rs", "a\n", "b\n");
777        assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
778        assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
779    }
780
781    #[test]
782    fn test_tool_use_multiedit_emits_per_hunk_diff() {
783        let mut turn = base_turn("t1", Role::Assistant);
784        turn.tool_uses = vec![fw_tool(
785            "MultiEdit",
786            "tu1",
787            serde_json::json!({
788                "file_path": "m.rs",
789                "edits": [
790                    {"old_string": "foo", "new_string": "bar"},
791                    {"old_string": "baz", "new_string": "qux"},
792                ],
793            }),
794        )];
795        let view = view_with(vec![turn]);
796        let path = derive_path(&view, &DeriveConfig::default());
797        let ch = &path.steps[0].change["m.rs"];
798        let raw = ch.raw.as_deref().expect("multiedit should emit diff");
799        assert!(raw.contains("# edit 1/2"));
800        assert!(raw.contains("# edit 2/2"));
801        assert!(raw.contains("-foo"));
802        assert!(raw.contains("+bar"));
803        assert!(raw.contains("-baz"));
804        assert!(raw.contains("+qux"));
805    }
806
807    #[test]
808    fn test_thinking_included_when_enabled() {
809        let mut turn = base_turn("t1", Role::Assistant);
810        turn.thinking = Some("hmm".into());
811        let view = view_with(vec![turn]);
812        let path = derive_path(&view, &DeriveConfig::default());
813        let sc = conv_change(&path.steps[0]);
814        assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
815    }
816
817    #[test]
818    fn test_thinking_omitted_when_disabled() {
819        let mut turn = base_turn("t1", Role::Assistant);
820        turn.thinking = Some("hmm".into());
821        let view = view_with(vec![turn]);
822        let cfg = DeriveConfig {
823            include_thinking: false,
824            ..Default::default()
825        };
826        let path = derive_path(&view, &cfg);
827        let sc = conv_change(&path.steps[0]);
828        assert!(!sc.extra.contains_key("thinking"));
829    }
830
831    #[test]
832    fn test_tool_uses_included_when_enabled() {
833        let mut turn = base_turn("t1", Role::Assistant);
834        turn.tool_uses = vec![ToolInvocation {
835            id: "tu1".into(),
836            name: "Read".into(),
837            input: serde_json::json!({}),
838            result: Some(ToolResult {
839                content: "x".into(),
840                is_error: false,
841            }),
842            category: Some(ToolCategory::FileRead),
843        }];
844        let view = view_with(vec![turn]);
845        let path = derive_path(&view, &DeriveConfig::default());
846        let sc = conv_change(&path.steps[0]);
847        assert!(sc.extra.contains_key("tool_uses"));
848    }
849
850    #[test]
851    fn test_tool_uses_omitted_when_disabled() {
852        let mut turn = base_turn("t1", Role::Assistant);
853        turn.tool_uses = vec![ToolInvocation {
854            id: "tu1".into(),
855            name: "Read".into(),
856            input: serde_json::json!({}),
857            result: None,
858            category: Some(ToolCategory::FileRead),
859        }];
860        let view = view_with(vec![turn]);
861        let cfg = DeriveConfig {
862            include_tool_uses: false,
863            ..Default::default()
864        };
865        let path = derive_path(&view, &cfg);
866        let sc = conv_change(&path.steps[0]);
867        assert!(!sc.extra.contains_key("tool_uses"));
868    }
869
870    #[test]
871    fn test_base_uri_from_working_dir() {
872        let mut turn = base_turn("t1", Role::User);
873        turn.environment = Some(EnvironmentSnapshot {
874            working_dir: Some("/Users/alex/proj".into()),
875            ..Default::default()
876        });
877        let view = view_with(vec![turn]);
878        let path = derive_path(&view, &DeriveConfig::default());
879        assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
880    }
881
882    #[test]
883    fn test_base_uri_from_config_override() {
884        let mut turn = base_turn("t1", Role::User);
885        turn.environment = Some(EnvironmentSnapshot {
886            working_dir: Some("/Users/alex/proj".into()),
887            ..Default::default()
888        });
889        let view = view_with(vec![turn]);
890        let cfg = DeriveConfig {
891            base_uri: Some("github:org/repo".into()),
892            ..Default::default()
893        };
894        let path = derive_path(&view, &cfg);
895        assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
896    }
897
898    #[test]
899    fn test_base_uri_absent_when_no_source() {
900        let turn = base_turn("t1", Role::User);
901        let view = view_with(vec![turn]);
902        let path = derive_path(&view, &DeriveConfig::default());
903        assert!(path.path.base.is_none());
904    }
905
906    #[test]
907    fn test_path_id_from_config_override() {
908        let view = view_with(vec![]);
909        let cfg = DeriveConfig {
910            path_id: Some("my-custom-id".into()),
911            ..Default::default()
912        };
913        let path = derive_path(&view, &cfg);
914        assert_eq!(path.path.id, "my-custom-id");
915    }
916
917    #[test]
918    fn test_path_id_default_format() {
919        let view = view_with(vec![]);
920        let path = derive_path(&view, &DeriveConfig::default());
921        assert_eq!(path.path.id, "path-pi-abcdef01");
922    }
923
924    #[test]
925    fn test_files_changed_in_meta() {
926        let mut view = view_with(vec![]);
927        view.files_changed = vec!["a.rs".into(), "b.rs".into()];
928        let path = derive_path(&view, &DeriveConfig::default());
929        let meta = path.meta.unwrap();
930        assert_eq!(
931            meta.extra["files_changed"],
932            serde_json::json!(["a.rs", "b.rs"])
933        );
934    }
935
936    #[test]
937    fn test_actors_in_meta() {
938        let u = base_turn("t1", Role::User);
939        let mut a = base_turn("t2", Role::Assistant);
940        a.model = Some("claude-opus-4-7".into());
941        let view = view_with(vec![u, a]);
942        let path = derive_path(&view, &DeriveConfig::default());
943        let actors = path.meta.unwrap().actors.unwrap();
944        assert!(actors.contains_key("human:user"));
945        assert!(actors.contains_key("agent:claude-opus-4-7"));
946        let agent = &actors["agent:claude-opus-4-7"];
947        assert_eq!(agent.provider.as_deref(), Some("pi"));
948        assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
949        let human = &actors["human:user"];
950        assert_eq!(human.name.as_deref(), Some("user"));
951    }
952
953    #[test]
954    fn test_head_is_last_step_id() {
955        let turns = vec![
956            base_turn("t1", Role::User),
957            base_turn("t2", Role::User),
958            base_turn("t3", Role::User),
959        ];
960        let view = view_with(turns);
961        let path = derive_path(&view, &DeriveConfig::default());
962        assert_eq!(path.path.head, "step-0003");
963    }
964
965    #[test]
966    fn test_token_usage_in_extras() {
967        let mut turn = base_turn("t1", Role::Assistant);
968        turn.token_usage = Some(TokenUsage {
969            input_tokens: Some(100),
970            output_tokens: Some(50),
971            cache_read_tokens: None,
972            cache_write_tokens: None,
973        });
974        let view = view_with(vec![turn]);
975        let path = derive_path(&view, &DeriveConfig::default());
976        let sc = conv_change(&path.steps[0]);
977        assert!(sc.extra.contains_key("token_usage"));
978        assert_eq!(
979            sc.extra["token_usage"]["input_tokens"],
980            serde_json::json!(100)
981        );
982    }
983
984    #[test]
985    fn test_delegations_in_extras() {
986        let mut turn = base_turn("t1", Role::Assistant);
987        turn.delegations = vec![DelegatedWork {
988            agent_id: "sub-1".into(),
989            prompt: "do a thing".into(),
990            turns: vec![],
991            result: None,
992        }];
993        let view = view_with(vec![turn]);
994        let path = derive_path(&view, &DeriveConfig::default());
995        let sc = conv_change(&path.steps[0]);
996        assert!(sc.extra.contains_key("delegations"));
997        assert_eq!(
998            sc.extra["delegations"][0]["agent_id"],
999            serde_json::json!("sub-1")
1000        );
1001    }
1002
1003    #[test]
1004    fn test_title_from_config() {
1005        let view = view_with(vec![]);
1006        let cfg = DeriveConfig {
1007            title: Some("My Session".into()),
1008            ..Default::default()
1009        };
1010        let path = derive_path(&view, &cfg);
1011        assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1012    }
1013
1014    #[test]
1015    fn test_title_default_when_unset() {
1016        let view = view_with(vec![]);
1017        let path = derive_path(&view, &DeriveConfig::default());
1018        assert_eq!(
1019            path.meta.unwrap().title.as_deref(),
1020            Some("pi session: abcdef01")
1021        );
1022    }
1023
1024    #[test]
1025    fn test_serde_roundtrip() {
1026        let mut t1 = base_turn("t1", Role::User);
1027        t1.text = "hello".into();
1028        t1.environment = Some(EnvironmentSnapshot {
1029            working_dir: Some("/proj".into()),
1030            ..Default::default()
1031        });
1032        let mut t2 = base_turn("t2", Role::Assistant);
1033        t2.parent_id = Some("t1".into());
1034        t2.model = Some("m".into());
1035        t2.tool_uses = vec![fw_tool(
1036            "Write",
1037            "tu1",
1038            serde_json::json!({"file_path": "x.rs"}),
1039        )];
1040
1041        let mut view = view_with(vec![t1, t2]);
1042        view.files_changed = vec!["x.rs".into()];
1043
1044        let path = derive_path(&view, &DeriveConfig::default());
1045        let json = serde_json::to_string(&path).unwrap();
1046        let back: Path = serde_json::from_str(&json).unwrap();
1047        assert_eq!(back.path.id, path.path.id);
1048        assert_eq!(back.path.head, path.path.head);
1049        assert_eq!(back.steps.len(), 2);
1050        assert_eq!(back.steps[1].step.parents, vec!["step-0001".to_string()]);
1051        assert!(back.steps[1].change.contains_key("x.rs"));
1052    }
1053}