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