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. The emitted path is
7//! tagged with `meta.kind = PATH_KIND_AGENT_CODING_SESSION`.
8
9use std::collections::HashMap;
10
11use toolpath::v1::{
12    ActorDefinition, ArtifactChange, Base, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity,
13    PathMeta, Step, StepIdentity, StructuralChange,
14};
15
16use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn};
17
18/// Configuration for [`derive_path`].
19#[derive(Debug, Clone)]
20pub struct DeriveConfig {
21    /// Override `path.base.uri`. If `None`, fall back to the first turn's
22    /// `environment.working_dir`.
23    pub base_uri: Option<String>,
24    /// Override `path.id`. If `None`, derive as `path-{provider}-{8chars}`.
25    pub path_id: Option<String>,
26    /// Override `meta.title`. If `None`, default to `"{provider} session: {8chars}"`.
27    pub title: Option<String>,
28    /// Include `Turn.thinking` in the structural change extras.
29    pub include_thinking: bool,
30    /// Include `Turn.tool_uses` in the structural change extras.
31    pub include_tool_uses: bool,
32}
33
34impl Default for DeriveConfig {
35    fn default() -> Self {
36        Self {
37            base_uri: None,
38            path_id: None,
39            title: None,
40            include_thinking: true,
41            include_tool_uses: true,
42        }
43    }
44}
45
46/// Derive a [`Path`] from a [`ConversationView`].
47pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path {
48    let provider = view.provider_id.as_deref().unwrap_or("unknown");
49    let id_prefix: String = view.id.chars().take(8).collect();
50
51    let path_id = config
52        .path_id
53        .clone()
54        .unwrap_or_else(|| format!("path-{}-{}", provider, id_prefix));
55
56    // Base resolution order:
57    //   1. `config.base_uri` (CLI override): provides the `uri`; ref/branch
58    //      come from `view.base` if set.
59    //   2. `view.base` (provider-populated): the canonical source.
60    //   3. First turn's `environment.working_dir` (legacy fallback).
61    let base = config
62        .base_uri
63        .clone()
64        .map(|uri| Base {
65            uri,
66            ref_str: view.base.as_ref().and_then(|b| b.vcs_revision.clone()),
67            branch: view.base.as_ref().and_then(|b| b.vcs_branch.clone()),
68        })
69        .or_else(|| {
70            view.base.as_ref().and_then(|b| {
71                let wd = b.working_dir.as_ref()?;
72                let uri = if wd.starts_with('/') {
73                    format!("file://{}", wd)
74                } else {
75                    wd.clone()
76                };
77                Some(Base {
78                    uri,
79                    ref_str: b.vcs_revision.clone(),
80                    branch: b.vcs_branch.clone(),
81                })
82            })
83        })
84        .or_else(|| {
85            view.turns
86                .iter()
87                .find_map(|t| t.environment.as_ref()?.working_dir.clone())
88                .map(|wd| {
89                    let uri = if wd.starts_with('/') {
90                        format!("file://{}", wd)
91                    } else {
92                        wd
93                    };
94                    Base {
95                        uri,
96                        ref_str: None,
97                        branch: None,
98                    }
99                })
100        });
101
102    let conv_artifact_key = format!("{}://{}", provider, view.id);
103
104    let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
105    // Final step id → index in `steps`, for resolving id collisions as steps
106    // are emitted (see `push_step_and_dedup`).
107    let mut by_id: HashMap<String, usize> = HashMap::new();
108    let mut turn_to_step: HashMap<String, String> = HashMap::new();
109    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
110
111    for (idx, turn) in view.turns.iter().enumerate() {
112        // Step id: use the turn's native id when set so it round-trips
113        // through `extract_conversation`; otherwise synthesize sequentially.
114        let step_id = if turn.id.is_empty() {
115            format!("step-{:04}", idx + 1)
116        } else {
117            turn.id.clone()
118        };
119
120        let actor = actor_for_turn(turn, provider);
121        record_actor(&mut actors, &actor, turn, provider, view);
122
123        let mut step = Step {
124            step: StepIdentity {
125                id: step_id,
126                parents: Vec::new(),
127                actor,
128                timestamp: turn.timestamp.clone(),
129            },
130            change: HashMap::new(),
131            meta: None,
132        };
133
134        // Parent mapping
135        if let Some(parent_id) = &turn.parent_id
136            && let Some(parent_step_id) = turn_to_step.get(parent_id)
137        {
138            step.step.parents.push(parent_step_id.clone());
139        }
140
141        // Build conversation.append structural change extras
142        let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
143        extra.insert(
144            "role".to_string(),
145            serde_json::Value::String(turn.role.to_string()),
146        );
147        extra.insert(
148            "text".to_string(),
149            serde_json::Value::String(turn.text.clone()),
150        );
151
152        if config.include_thinking
153            && let Some(thinking) = &turn.thinking
154        {
155            extra.insert(
156                "thinking".to_string(),
157                serde_json::Value::String(thinking.clone()),
158            );
159        }
160
161        if config.include_tool_uses && !turn.tool_uses.is_empty() {
162            let arr: Vec<serde_json::Value> = turn
163                .tool_uses
164                .iter()
165                .map(|t| {
166                    let mut obj = serde_json::json!({
167                        "id": t.id,
168                        "name": t.name,
169                        "input": t.input,
170                        "category": t.category,
171                    });
172                    if let Some(result) = &t.result
173                        && let Ok(v) = serde_json::to_value(result)
174                    {
175                        obj.as_object_mut().unwrap().insert("result".to_string(), v);
176                    }
177                    obj
178                })
179                .collect();
180            extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
181        }
182
183        // Message-level accounting lands exactly once per message: when a
184        // provider splits one message across several turns (group_id
185        // set on each), only the run's last turn carries token_usage, so
186        // summing over steps yields session totals. A turn without a
187        // group_id is its own accounting unit.
188        let last_of_message = match &turn.group_id {
189            None => true,
190            Some(mid) => view
191                .turns
192                .get(idx + 1)
193                .is_none_or(|next| next.group_id.as_ref() != Some(mid)),
194        };
195        if last_of_message
196            && let Some(usage) = &turn.token_usage
197            && let Ok(v) = serde_json::to_value(usage)
198        {
199            extra.insert("token_usage".to_string(), v);
200        }
201
202        // Per-step attributed spend rides its own key on every step that
203        // has it (independent of the once-per-message `token_usage`), so
204        // summing `token_usage` is unaffected while per-step cost stays
205        // readable structurally.
206        if let Some(attr) = &turn.attributed_token_usage
207            && let Ok(v) = serde_json::to_value(attr)
208        {
209            extra.insert("attributed_token_usage".to_string(), v);
210        }
211
212        if let Some(mid) = &turn.group_id {
213            extra.insert(
214                "group_id".to_string(),
215                serde_json::Value::String(mid.clone()),
216            );
217        }
218
219        if !turn.delegations.is_empty()
220            && let Ok(v) = serde_json::to_value(&turn.delegations)
221        {
222            extra.insert("delegations".to_string(), v);
223        }
224
225        if let Some(stop_reason) = &turn.stop_reason {
226            extra.insert(
227                "stop_reason".to_string(),
228                serde_json::Value::String(stop_reason.clone()),
229            );
230        }
231
232        if let Some(env) = &turn.environment
233            && let Ok(v) = serde_json::to_value(env)
234        {
235            extra.insert("environment".to_string(), v);
236        }
237
238        step.change.insert(
239            conv_artifact_key.clone(),
240            ArtifactChange {
241                raw: None,
242                structural: Some(StructuralChange {
243                    change_type: "conversation.append".to_string(),
244                    extra,
245                }),
246            },
247        );
248
249        // File mutations → sibling `file.write` change entries.
250        //
251        // Preferred: each `Turn::file_mutations` entry comes from the
252        // provider's `to_view` with the resolved diff already in
253        // `raw_diff` (claude's git-HEAD lookup, codex's `apply_patch_end`
254        // parse, opencode's git2 tree↔tree, etc.). `tool_id` links back
255        // to a specific `ToolInvocation` when the provider can attribute.
256        //
257        // Fallback (un-migrated providers): for any `FileWrite`-category
258        // tool with no matching mutation, synthesize from `tool.input`
259        // via `file_write_change`.
260        let attributed: std::collections::HashSet<String> = turn
261            .file_mutations
262            .iter()
263            .filter_map(|fm| fm.tool_id.clone())
264            .collect();
265        for fm in &turn.file_mutations {
266            let mut t_extra: HashMap<String, serde_json::Value> = HashMap::new();
267            if let Some(tid) = &fm.tool_id {
268                t_extra.insert(
269                    "tool_id".to_string(),
270                    serde_json::Value::String(tid.clone()),
271                );
272                if let Some(tool) = turn.tool_uses.iter().find(|t| &t.id == tid) {
273                    t_extra.insert(
274                        "tool".to_string(),
275                        serde_json::Value::String(tool.name.clone()),
276                    );
277                }
278            }
279            if let Some(op) = &fm.operation {
280                t_extra.insert(
281                    "operation".to_string(),
282                    serde_json::Value::String(op.clone()),
283                );
284            }
285            if let Some(b) = &fm.before {
286                t_extra.insert("before".to_string(), serde_json::Value::String(b.clone()));
287            }
288            if let Some(a) = &fm.after {
289                t_extra.insert("after".to_string(), serde_json::Value::String(a.clone()));
290            }
291            if let Some(rt) = &fm.rename_to {
292                t_extra.insert(
293                    "rename_to".to_string(),
294                    serde_json::Value::String(rt.clone()),
295                );
296            }
297            step.change.insert(
298                fm.path.clone(),
299                ArtifactChange {
300                    raw: fm.raw_diff.clone(),
301                    structural: Some(StructuralChange {
302                        change_type: "file.write".to_string(),
303                        extra: t_extra,
304                    }),
305                },
306            );
307        }
308        for tool in &turn.tool_uses {
309            if tool.category != Some(ToolCategory::FileWrite) || attributed.contains(&tool.id) {
310                continue;
311            }
312            let Some(path) = extract_file_path(tool) else {
313                continue;
314            };
315            let (raw, mut t_extra) = file_write_change(tool, &path, None);
316            t_extra.insert(
317                "tool".to_string(),
318                serde_json::Value::String(tool.name.clone()),
319            );
320            t_extra.insert(
321                "tool_id".to_string(),
322                serde_json::Value::String(tool.id.clone()),
323            );
324            step.change.insert(
325                path,
326                ArtifactChange {
327                    raw,
328                    structural: Some(StructuralChange {
329                        change_type: "file.write".to_string(),
330                        extra: t_extra,
331                    }),
332                },
333            );
334        }
335
336        let final_id = push_step_and_dedup(&mut steps, &mut by_id, step);
337        turn_to_step.insert(turn.id.clone(), final_id);
338    }
339
340    // Emit `view.events` as `conversation.event` steps so that attachments,
341    // preamble lines (ai-title, last-prompt, queue-operation, permission-mode),
342    // and other non-turn entries survive the IR-to-Path-to-IR roundtrip.
343    // Without this, derive_path drops everything outside `turns`, so a
344    // Claude session loses ~10–25% of its lines on import/export.
345    // Track the last emitted step id so events without an explicit
346    // `parent_id` can chain off whatever step came before them.
347    let mut last_step_id: Option<String> = steps.last().map(|s| s.step.id.clone());
348    for (idx, event) in view.events.iter().enumerate() {
349        // Event step id: prefer the event's native id so it round-trips.
350        let step_id = if event.id.is_empty() {
351            format!("event-{:04}", idx + 1)
352        } else {
353            event.id.clone()
354        };
355        let actor = format!("tool:{}", provider);
356        actors
357            .entry(actor.clone())
358            .or_insert_with(|| ActorDefinition {
359                name: Some(provider.to_string()),
360                provider: Some(provider.to_string()),
361                ..Default::default()
362            });
363
364        // event.data is flattened into StructuralChange.extra. Strip keys
365        // that collide with the typed fields on StructuralChange itself —
366        // most importantly `type`, which serde renames `change_type` to.
367        // A Codex `user_message` event carries `data["type"] = "user_message"`,
368        // which would otherwise overwrite our `change_type = "conversation.event"`
369        // and break PathOrRef untagged-enum disambiguation on parse.
370        let mut extra: HashMap<String, serde_json::Value> = event
371            .data
372            .iter()
373            .filter(|(k, _)| k.as_str() != "type")
374            .map(|(k, v)| (k.clone(), v.clone()))
375            .collect();
376        // Stash the original `type` value under a non-colliding key so
377        // round-trip can recover it for providers that need it.
378        if let Some(t) = event.data.get("type") {
379            extra.insert("event_data_type".to_string(), t.clone());
380        }
381        extra.insert(
382            "entry_type".to_string(),
383            serde_json::Value::String(event.event_type.clone()),
384        );
385        if !event.id.is_empty() {
386            extra.insert(
387                "event_source_id".to_string(),
388                serde_json::Value::String(event.id.clone()),
389            );
390        }
391
392        let parents: Vec<String> = event
393            .parent_id
394            .as_ref()
395            .and_then(|pid| turn_to_step.get(pid).cloned())
396            .or_else(|| last_step_id.clone())
397            .into_iter()
398            .collect();
399
400        let mut step = Step {
401            step: StepIdentity {
402                id: step_id.clone(),
403                parents,
404                actor,
405                timestamp: event.timestamp.clone(),
406            },
407            change: HashMap::new(),
408            meta: None,
409        };
410
411        step.change.insert(
412            conv_artifact_key.clone(),
413            ArtifactChange {
414                raw: None,
415                structural: Some(StructuralChange {
416                    change_type: "conversation.event".to_string(),
417                    extra,
418                }),
419            },
420        );
421        last_step_id = Some(push_step_and_dedup(&mut steps, &mut by_id, step));
422    }
423
424    let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
425
426    // Meta
427    let title = config
428        .title
429        .clone()
430        .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
431
432    let mut meta = PathMeta {
433        title: Some(title),
434        kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()),
435        source: view.provider_id.clone(),
436        ..Default::default()
437    };
438
439    if !actors.is_empty() {
440        meta.actors = Some(actors);
441    }
442
443    if !view.files_changed.is_empty()
444        && let Ok(v) = serde_json::to_value(&view.files_changed)
445    {
446        meta.extra.insert("files_changed".to_string(), v);
447    }
448
449    // Carry `vcs_remote` (not representable on `Base`) under meta.extra.
450    if let Some(remote) = view.base.as_ref().and_then(|b| b.vcs_remote.as_ref())
451        && !meta.extra.contains_key("vcs_remote")
452    {
453        meta.extra.insert(
454            "vcs_remote".to_string(),
455            serde_json::Value::String(remote.clone()),
456        );
457    }
458
459    // Project canonical session-level fields under well-known keys.
460    if let Some(producer) = &view.producer
461        && let Ok(v) = serde_json::to_value(producer)
462    {
463        meta.extra.insert("producer".to_string(), v);
464    }
465
466    Path {
467        path: PathIdentity {
468            id: path_id,
469            base,
470            head,
471            graph_ref: None,
472        },
473        steps,
474        meta: Some(meta),
475    }
476}
477
478/// Push `step` into `steps`, resolving an id collision with an already-emitted
479/// step so the path's step ids stay unique. A byte-identical re-emission (same
480/// serialized step) is dropped — keeping it would only duplicate a step that
481/// already exists — and a same-id-but-different step is re-IDed to a fresh
482/// `<id>#<n>` so the original id stays recoverable and no data is lost. Returns
483/// the id the step ended up under (the surviving id when dropped, the new id
484/// when re-IDed), which the caller records in `turn_to_step` / `last_step_id`
485/// so parent references keep pointing at a real step.
486fn push_step_and_dedup(
487    steps: &mut Vec<Step>,
488    by_id: &mut HashMap<String, usize>,
489    mut step: Step,
490) -> String {
491    let id = step.step.id.clone();
492    let Some(&existing) = by_id.get(&id) else {
493        by_id.insert(id.clone(), steps.len());
494        steps.push(step);
495        return id;
496    };
497    if serde_value_eq(&steps[existing], &step) {
498        return id;
499    }
500    let mut n = 2u32;
501    let mut renamed = format!("{id}#{n}");
502    while by_id.contains_key(&renamed) {
503        n += 1;
504        renamed = format!("{id}#{n}");
505    }
506    step.step.id = renamed.clone();
507    by_id.insert(renamed.clone(), steps.len());
508    steps.push(step);
509    renamed
510}
511
512/// Whether two steps are the same entry — equal once serialized, so dropping
513/// one is lossless. `Step` doesn't implement `PartialEq`, and this only runs on
514/// an actual id collision (rare), so the serialize cost is negligible.
515fn serde_value_eq(a: &Step, b: &Step) -> bool {
516    serde_json::to_value(a).ok() == serde_json::to_value(b).ok()
517}
518
519fn actor_for_turn(turn: &Turn, provider: &str) -> String {
520    match &turn.role {
521        Role::User => "human:user".to_string(),
522        Role::Assistant => {
523            let model = turn.model.as_deref().unwrap_or("unknown");
524            format!("agent:{}", model)
525        }
526        Role::System => format!("tool:{}", provider),
527        Role::Other(_) => format!("tool:{}", provider),
528    }
529}
530
531fn record_actor(
532    actors: &mut HashMap<String, ActorDefinition>,
533    actor: &str,
534    turn: &Turn,
535    provider: &str,
536    _view: &ConversationView,
537) {
538    if actors.contains_key(actor) {
539        return;
540    }
541    let def = if let Some(rest) = actor.strip_prefix("agent:") {
542        ActorDefinition {
543            name: Some(rest.to_string()),
544            provider: Some(provider.to_string()),
545            model: turn.model.clone(),
546            identities: vec![],
547            keys: vec![],
548        }
549    } else if let Some(rest) = actor.strip_prefix("human:") {
550        ActorDefinition {
551            name: Some(rest.to_string()),
552            ..Default::default()
553        }
554    } else {
555        let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
556        ActorDefinition {
557            name: Some(name),
558            provider: Some(provider.to_string()),
559            ..Default::default()
560        }
561    };
562    actors.insert(actor.to_string(), def);
563}
564
565fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
566    for field in &["file_path", "path", "filename", "file"] {
567        if let Some(v) = tool.input.get(*field)
568            && let Some(s) = v.as_str()
569        {
570            return Some(s.to_string());
571        }
572    }
573    None
574}
575
576/// Build `(raw_diff, extra)` for a single FileWrite tool invocation.
577///
578/// See [`file_write_diff`] for the input shapes handled; this helper
579/// additionally captures the structured before/after strings in `extra`.
580///
581/// `before_state` is threaded through to [`file_write_diff`] for the
582/// `Write { content }` shape: when `Some`, it becomes the pre-image and
583/// is also recorded in `extra["before"]`. When `None`, the diff falls
584/// back to an empty pre-image (addition-only hunk).
585fn file_write_change(
586    tool: &ToolInvocation,
587    path: &str,
588    before_state: Option<&str>,
589) -> (Option<String>, HashMap<String, serde_json::Value>) {
590    let input = &tool.input;
591    let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
592
593    let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
594
595    if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
596        extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
597        extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
598    } else if let Some(content) = str_field("content") {
599        if let Some(before) = before_state {
600            extra.insert(
601                "before".to_string(),
602                serde_json::Value::String(before.to_string()),
603            );
604        }
605        extra.insert("after".to_string(), serde_json::Value::String(content));
606    } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
607        extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
608    }
609
610    (
611        file_write_diff(&tool.name, input, path, before_state),
612        extra,
613    )
614}
615
616/// Compute a unified diff string for a file-write tool invocation, given the
617/// raw tool input JSON. Handles Claude's Edit / Write / MultiEdit / NotebookEdit
618/// shapes; returns `None` for any unrecognised shape or if nothing to diff.
619///
620/// Exposed so non-Conversation derivers (e.g. `toolpath-claude`'s bespoke
621/// Claude-JSONL deriver, which emits its own `tool.invoke` steps) can populate
622/// `ArtifactChange.raw` without reimplementing the diff logic.
623///
624/// Shapes handled:
625///   - `Edit    { old_string, new_string, ... }`  → diff old→new
626///   - `Write   { content }`                      → diff `before_state`→content
627///     (uses `""` when `before_state` is `None`, producing an addition-only hunk)
628///   - `MultiEdit { edits: [{old_string, new_string}, ...] }` → hunks joined,
629///     each prefixed with `# edit N/total` so consumers can tell them apart.
630///
631/// # `before_state` for `Write`
632///
633/// The `Write` tool replaces a file's whole contents but the JSONL log
634/// doesn't carry the prior state. Callers that can reconstruct it
635/// out-of-band (e.g. by reading `git show HEAD:<path>`) should pass it
636/// as `before_state`; the resulting diff shows honest `-`/`+` lines for
637/// replaced content. When `None`, we fall back to diffing against the
638/// empty string — correct for new files, misleading for overwrites, but
639/// the best we can do from the log alone.
640///
641/// `before_state` is ignored for `Edit` / `MultiEdit` shapes, which
642/// already carry their own `old_string`/`new_string` pre-image.
643pub fn file_write_diff(
644    tool_name: &str,
645    input: &serde_json::Value,
646    path: &str,
647    before_state: Option<&str>,
648) -> Option<String> {
649    let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
650
651    // Edit / NotebookEdit / anything else with old/new pair.
652    if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
653        return Some(unified_diff(path, old, new));
654    }
655
656    // Write — whole-file content; diff against the caller-supplied
657    // before-state when present, else empty (addition-only hunk).
658    if let Some(content) = str_field("content") {
659        let before = before_state.unwrap_or("");
660        return Some(unified_diff(path, before, content));
661    }
662
663    // MultiEdit — multiple sequential edits on one file.
664    if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
665        if edits.is_empty() {
666            return None;
667        }
668        let mut parts: Vec<String> = Vec::new();
669        for (idx, edit) in edits.iter().enumerate() {
670            let old = edit
671                .get("old_string")
672                .and_then(|v| v.as_str())
673                .unwrap_or("");
674            let new = edit
675                .get("new_string")
676                .and_then(|v| v.as_str())
677                .unwrap_or("");
678            let header = format!("# edit {}/{}", idx + 1, edits.len());
679            parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
680        }
681        return Some(parts.join("\n"));
682    }
683
684    // Unused today, but keeps `tool_name` addressable for future per-tool
685    // branches (e.g. NotebookEdit may one day need cell-scoped diffs).
686    let _ = tool_name;
687    None
688}
689
690/// Produce a minimal unified-diff string using `similar::TextDiff`.
691///
692/// Always emits a `--- a/{path}` / `+++ b/{path}` header even when one side is
693/// empty so downstream renderers can anchor the change to the file it touched.
694///
695/// Any leading `/` on `path` is stripped before splicing into the header —
696/// git-style `a/` and `b/` prefixes already denote the repo root, so an
697/// absolute path like `/abs/file.rs` would otherwise emit `--- a//abs/file.rs`,
698/// which breaks `patch(1)` and other consumers that parse the header.
699pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
700    use similar::TextDiff;
701    let diff = TextDiff::from_lines(before, after);
702    let display = path.trim_start_matches('/');
703    let mut out = String::new();
704    out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
705    out.push_str(
706        &diff
707            .unified_diff()
708            .context_radius(3)
709            .header("", "")
710            .to_string(),
711    );
712    out
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
719
720    fn base_turn(id: &str, role: Role) -> Turn {
721        Turn {
722            id: id.to_string(),
723            parent_id: None,
724            group_id: None,
725            role,
726            timestamp: "2026-01-01T00:00:00Z".to_string(),
727            text: String::new(),
728            thinking: None,
729            tool_uses: vec![],
730            model: None,
731            stop_reason: None,
732            token_usage: None,
733            attributed_token_usage: None,
734            environment: None,
735            delegations: vec![],
736            file_mutations: Vec::new(),
737        }
738    }
739
740    fn view_with(turns: Vec<Turn>) -> ConversationView {
741        ConversationView {
742            id: "abcdef012345".to_string(),
743            turns,
744            provider_id: Some("pi".to_string()),
745            ..Default::default()
746        }
747    }
748
749    fn conv_change(step: &Step) -> &StructuralChange {
750        let key = step
751            .change
752            .keys()
753            .find(|k| k.contains("://"))
754            .expect("conversation artifact key present");
755        step.change[key].structural.as_ref().unwrap()
756    }
757
758    #[test]
759    fn test_duplicate_id_identical_content_is_dropped() {
760        // A byte-identical re-emission of the same id collapses to one step.
761        let mut first = base_turn("dup", Role::User);
762        first.text = "same".into();
763        let mid = base_turn("mid", Role::Assistant);
764        let mut second = base_turn("dup", Role::User);
765        second.text = "same".into();
766        let view = view_with(vec![first, mid, second]);
767
768        let path = derive_path(&view, &DeriveConfig::default());
769        let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
770        assert_eq!(ids, vec!["dup", "mid"], "identical re-emission is dropped");
771    }
772
773    #[test]
774    fn test_duplicate_id_different_content_is_renamed() {
775        // The same id with DIFFERENT content keeps both steps: the later one is
776        // re-IDed to `<id>#<n>` so the path stays unique and no data is lost.
777        let mut first = base_turn("dup", Role::User);
778        first.text = "original".into();
779        let mid = base_turn("mid", Role::Assistant);
780        let mut second = base_turn("dup", Role::User);
781        second.text = "replayed".into();
782        let view = view_with(vec![first, mid, second]);
783
784        let path = derive_path(&view, &DeriveConfig::default());
785        let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
786        assert_eq!(ids, vec!["dup", "mid", "dup#2"]);
787        assert_eq!(
788            conv_change(&path.steps[0]).extra["text"],
789            serde_json::json!("original")
790        );
791        assert_eq!(
792            conv_change(&path.steps[2]).extra["text"],
793            serde_json::json!("replayed")
794        );
795    }
796
797    #[test]
798    fn test_renamed_duplicate_keeps_parent_references_correct() {
799        // Resolving collisions inline (as steps are emitted) — not as a
800        // post-pass — keeps parent references correct: a later turn whose
801        // parent_id matches a renamed duplicate resolves to the RENAMED step,
802        // not the first occurrence that kept the original id.
803        let mut first = base_turn("dup", Role::User);
804        first.text = "original".into();
805        let mut second = base_turn("dup", Role::User); // re-IDed to dup#2
806        second.text = "replayed".into();
807        let mut child = base_turn("child", Role::Assistant);
808        child.parent_id = Some("dup".into());
809        let view = view_with(vec![first, second, child]);
810
811        let path = derive_path(&view, &DeriveConfig::default());
812        let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
813        assert_eq!(ids, vec!["dup", "dup#2", "child"]);
814        assert_eq!(
815            path.steps[2].step.parents,
816            vec!["dup#2".to_string()],
817            "child parents on the renamed later duplicate, not the first `dup`"
818        );
819    }
820
821    #[test]
822    fn test_duplicate_event_ids_are_resolved_to_unique_ids() {
823        // The blocking case: Claude Code reuses `uuid` on attachment lines, so
824        // two distinct events arrive with the same id. derive_path must still
825        // yield unique step ids (consumers key on them, e.g. a UNIQUE index).
826        let a = base_turn("t1", Role::User);
827        let mut view = view_with(vec![a]);
828        for v in ["v1", "v2"] {
829            view.events.push(crate::ConversationEvent {
830                id: "evt".into(), // same id, different content
831                timestamp: "2026-01-01T00:00:00Z".into(),
832                parent_id: None,
833                event_type: "attachment".into(),
834                data: std::collections::HashMap::from([("k".to_string(), serde_json::json!(v))]),
835            });
836        }
837
838        let path = derive_path(&view, &DeriveConfig::default());
839        let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
840        let unique: std::collections::HashSet<&&str> = ids.iter().collect();
841        assert_eq!(unique.len(), ids.len(), "step ids must be unique: {ids:?}");
842        assert!(
843            ids.contains(&"evt") && ids.contains(&"evt#2"),
844            "both events survive with distinct ids: {ids:?}"
845        );
846    }
847
848    #[test]
849    fn test_empty_view() {
850        let view = view_with(vec![]);
851        let path = derive_path(&view, &DeriveConfig::default());
852        assert!(path.steps.is_empty());
853        assert_eq!(path.path.head, "");
854    }
855
856    #[test]
857    fn test_meta_kind_is_convo() {
858        let view = view_with(vec![base_turn("t1", Role::User)]);
859        let path = derive_path(&view, &DeriveConfig::default());
860        assert_eq!(
861            path.meta.as_ref().unwrap().kind.as_deref(),
862            Some(PATH_KIND_AGENT_CODING_SESSION)
863        );
864        // ...and survives a JSON round-trip.
865        let json = serde_json::to_string(&path).unwrap();
866        assert!(
867            json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#)
868        );
869    }
870
871    #[test]
872    fn test_token_usage_breakdowns_round_trip() {
873        use std::collections::BTreeMap;
874        // A Turn whose token_usage carries breakdowns should derive into a
875        // Path and extract back out with the breakdowns intact.
876        let mut breakdowns = BTreeMap::new();
877        breakdowns.insert(
878            "output".to_string(),
879            BTreeMap::from([("reasoning".to_string(), 450u32)]),
880        );
881        let mut turn = base_turn("t1", Role::Assistant);
882        turn.model = Some("claude-opus-4-7".into());
883        turn.token_usage = Some(TokenUsage {
884            input_tokens: Some(100),
885            output_tokens: Some(900),
886            breakdowns: breakdowns.clone(),
887            ..Default::default()
888        });
889        let view = view_with(vec![turn]);
890
891        let path = derive_path(&view, &DeriveConfig::default());
892        let extracted = crate::extract::extract_conversation(&path);
893
894        let usage = extracted.turns[0]
895            .token_usage
896            .as_ref()
897            .expect("token_usage survives round-trip");
898        assert_eq!(usage.input_tokens, Some(100));
899        assert_eq!(usage.output_tokens, Some(900));
900        assert_eq!(usage.breakdowns, breakdowns);
901        assert_eq!(usage.breakdowns["output"]["reasoning"], 450);
902    }
903
904    #[test]
905    fn test_token_usage_empty_breakdowns_omitted_in_json() {
906        // skip_serializing_if guarantees no "breakdowns" key for the empty map,
907        // keeping the wire format byte-compatible with pre-breakdowns producers.
908        let usage = TokenUsage {
909            input_tokens: Some(10),
910            output_tokens: Some(20),
911            ..Default::default()
912        };
913        let json = serde_json::to_string(&usage).unwrap();
914        assert!(
915            !json.contains("breakdowns"),
916            "empty breakdowns must be omitted, got: {json}"
917        );
918    }
919
920    #[test]
921    fn test_token_usage_absent_breakdowns_defaults_empty() {
922        // Deserializing an old-style token_usage object with no breakdowns key
923        // yields an empty map (serde default).
924        let usage: TokenUsage =
925            serde_json::from_str(r#"{"input_tokens":10,"output_tokens":20}"#).unwrap();
926        assert!(usage.breakdowns.is_empty());
927    }
928
929    #[test]
930    fn test_single_user_turn() {
931        let mut turn = base_turn("t1", Role::User);
932        turn.text = "hello".into();
933        let view = view_with(vec![turn]);
934        let path = derive_path(&view, &DeriveConfig::default());
935        assert_eq!(path.steps.len(), 1);
936        assert_eq!(path.steps[0].step.actor, "human:user");
937        assert_eq!(path.steps[0].step.id, "t1");
938    }
939
940    #[test]
941    fn test_single_assistant_turn() {
942        let mut turn = base_turn("t1", Role::Assistant);
943        turn.model = Some("claude-opus-4-7".into());
944        let view = view_with(vec![turn]);
945        let path = derive_path(&view, &DeriveConfig::default());
946        assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
947    }
948
949    #[test]
950    fn test_assistant_without_model() {
951        let turn = base_turn("t1", Role::Assistant);
952        let view = view_with(vec![turn]);
953        let path = derive_path(&view, &DeriveConfig::default());
954        assert_eq!(path.steps[0].step.actor, "agent:unknown");
955    }
956
957    #[test]
958    fn test_system_role() {
959        let turn = base_turn("t1", Role::System);
960        let view = view_with(vec![turn]);
961        let path = derive_path(&view, &DeriveConfig::default());
962        assert_eq!(path.steps[0].step.actor, "tool:pi");
963    }
964
965    #[test]
966    fn test_other_role() {
967        let turn = base_turn("t1", Role::Other("tool".into()));
968        let view = view_with(vec![turn]);
969        let path = derive_path(&view, &DeriveConfig::default());
970        assert_eq!(path.steps[0].step.actor, "tool:pi");
971    }
972
973    #[test]
974    fn test_parent_id_preserved() {
975        let t1 = base_turn("t1", Role::User);
976        let mut t2 = base_turn("t2", Role::Assistant);
977        t2.parent_id = Some("t1".into());
978        t2.model = Some("m".into());
979        let view = view_with(vec![t1, t2]);
980        let path = derive_path(&view, &DeriveConfig::default());
981        assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]);
982    }
983
984    #[test]
985    fn derived_path_validates_against_base_schema() {
986        let user = base_turn("t1", Role::User);
987        let mut assistant = base_turn("t2", Role::Assistant);
988        assistant.parent_id = Some("t1".into());
989        assistant.model = Some("gpt-5.5".into());
990        let system = base_turn("t3", Role::System);
991        let other = base_turn("t4", Role::Other("bash".into()));
992
993        let mut view = view_with(vec![user, assistant, system, other]);
994        view.events.push(crate::ConversationEvent {
995            id: "e1".into(),
996            timestamp: "2026-01-01T00:00:00Z".into(),
997            parent_id: None,
998            event_type: "attachment".into(),
999            data: HashMap::new(),
1000        });
1001
1002        let path = derive_path(&view, &DeriveConfig::default());
1003        let graph = serde_json::json!({
1004            "graph": { "id": "g1" },
1005            "paths": [serde_json::to_value(&path).unwrap()],
1006        });
1007
1008        let schema: serde_json::Value = serde_json::from_str(toolpath::SCHEMA_JSON).unwrap();
1009        let validator = jsonschema::validator_for(&schema).unwrap();
1010        let errors: Vec<String> = validator
1011            .iter_errors(&graph)
1012            .map(|e| format!("at {}: {e}", e.instance_path()))
1013            .collect();
1014        assert!(
1015            errors.is_empty(),
1016            "base-schema violations:\n{}",
1017            errors.join("\n")
1018        );
1019    }
1020
1021    #[test]
1022    fn derived_path_conforms_to_agent_coding_session_kind() {
1023        // derive_path stamps meta.kind = agent-coding-session, so its output
1024        // must satisfy that kind's schema. This view exercises every shape
1025        // the kind constrains: each turn role, a tool call with a result, a
1026        // file mutation, a delegation, token usage, environment, and an event.
1027        let mut user = base_turn("t1", Role::User);
1028        user.text = "implement the feature".into();
1029
1030        let mut assistant = base_turn("t2", Role::Assistant);
1031        assistant.parent_id = Some("t1".into());
1032        assistant.group_id = Some("msg_t2".into());
1033        assistant.model = Some("gpt-5.5".into());
1034        assistant.text = "on it".into();
1035        assistant.thinking = Some("plan the edit".into());
1036        assistant.stop_reason = Some("tool_use".into());
1037        assistant.token_usage = Some(TokenUsage {
1038            input_tokens: Some(100),
1039            output_tokens: Some(20),
1040            cache_read_tokens: Some(50),
1041            cache_write_tokens: None,
1042            ..Default::default()
1043        });
1044        assistant.attributed_token_usage = Some(TokenUsage {
1045            output_tokens: Some(20),
1046            ..Default::default()
1047        });
1048        assistant.environment = Some(EnvironmentSnapshot {
1049            working_dir: Some("/repo".into()),
1050            vcs_branch: Some("main".into()),
1051            vcs_revision: None,
1052        });
1053        assistant.tool_uses = vec![ToolInvocation {
1054            id: "call-1".into(),
1055            name: "write_file".into(),
1056            input: serde_json::json!({ "file_path": "a.rs", "content": "fn main() {}" }),
1057            result: Some(ToolResult {
1058                content: "ok".into(),
1059                is_error: false,
1060            }),
1061            category: Some(crate::ToolCategory::FileWrite),
1062        }];
1063        assistant.file_mutations = vec![crate::FileMutation {
1064            path: "a.rs".into(),
1065            tool_id: Some("call-1".into()),
1066            operation: Some("add".into()),
1067            raw_diff: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
1068            before: None,
1069            after: Some("fn main() {}".into()),
1070            rename_to: None,
1071        }];
1072        assistant.delegations = vec![DelegatedWork {
1073            agent_id: "sub-1".into(),
1074            prompt: "do the subtask".into(),
1075            turns: vec![],
1076            result: Some("done".into()),
1077        }];
1078
1079        let mut system = base_turn("t3", Role::System);
1080        system.parent_id = Some("t2".into());
1081        system.text = "system note".into();
1082
1083        let mut other = base_turn("t4", Role::Other("tool".into()));
1084        other.parent_id = Some("t3".into());
1085        other.text = "tool output".into();
1086
1087        let mut view = view_with(vec![user, assistant, system, other]);
1088        view.events.push(crate::ConversationEvent {
1089            id: "e1".into(),
1090            timestamp: "2026-01-01T00:00:00Z".into(),
1091            parent_id: None,
1092            event_type: "attachment".into(),
1093            data: HashMap::new(),
1094        });
1095
1096        let path = derive_path(&view, &DeriveConfig::default());
1097        assert_eq!(
1098            path.meta.as_ref().and_then(|m| m.kind.as_deref()),
1099            Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION),
1100            "derive_path must stamp the agent-coding-session kind"
1101        );
1102
1103        let schema_src = std::fs::read_to_string(concat!(
1104            env!("CARGO_MANIFEST_DIR"),
1105            "/../path-cli/kinds/agent-coding-session/v1.1.0/schema.json"
1106        ))
1107        .expect("read kind schema");
1108        let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap();
1109        let validator = jsonschema::validator_for(&schema).unwrap();
1110        let value = serde_json::to_value(&path).unwrap();
1111        let errors: Vec<String> = validator
1112            .iter_errors(&value)
1113            .map(|e| format!("at {}: {e}", e.instance_path()))
1114            .collect();
1115        assert!(
1116            errors.is_empty(),
1117            "kind-schema violations:\n{}",
1118            errors.join("\n")
1119        );
1120    }
1121
1122    fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
1123        ToolInvocation {
1124            id: id.to_string(),
1125            name: name.to_string(),
1126            input,
1127            result: None,
1128            category: Some(ToolCategory::FileWrite),
1129        }
1130    }
1131
1132    #[test]
1133    fn test_tool_use_filewrite_with_file_path_field() {
1134        let mut turn = base_turn("t1", Role::Assistant);
1135        turn.tool_uses = vec![fw_tool(
1136            "Write",
1137            "tu1",
1138            serde_json::json!({"file_path": "src/main.rs"}),
1139        )];
1140        let view = view_with(vec![turn]);
1141        let path = derive_path(&view, &DeriveConfig::default());
1142        assert!(path.steps[0].change.contains_key("src/main.rs"));
1143        let sc = path.steps[0].change["src/main.rs"]
1144            .structural
1145            .as_ref()
1146            .unwrap();
1147        assert_eq!(sc.change_type, "file.write");
1148        assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
1149        assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
1150    }
1151
1152    #[test]
1153    fn test_tool_use_filewrite_with_path_field() {
1154        let mut turn = base_turn("t1", Role::Assistant);
1155        turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
1156        let view = view_with(vec![turn]);
1157        let path = derive_path(&view, &DeriveConfig::default());
1158        assert!(path.steps[0].change.contains_key("a.rs"));
1159    }
1160
1161    #[test]
1162    fn test_tool_use_filewrite_with_filename_field() {
1163        let mut turn = base_turn("t1", Role::Assistant);
1164        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
1165        let view = view_with(vec![turn]);
1166        let path = derive_path(&view, &DeriveConfig::default());
1167        assert!(path.steps[0].change.contains_key("b.rs"));
1168    }
1169
1170    #[test]
1171    fn test_tool_use_filewrite_with_file_field() {
1172        let mut turn = base_turn("t1", Role::Assistant);
1173        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
1174        let view = view_with(vec![turn]);
1175        let path = derive_path(&view, &DeriveConfig::default());
1176        assert!(path.steps[0].change.contains_key("c.rs"));
1177    }
1178
1179    #[test]
1180    fn test_tool_use_filewrite_no_recognized_field() {
1181        let mut turn = base_turn("t1", Role::Assistant);
1182        turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
1183        let view = view_with(vec![turn]);
1184        let path = derive_path(&view, &DeriveConfig::default());
1185        assert_eq!(path.steps[0].change.len(), 1);
1186        let sc = conv_change(&path.steps[0]);
1187        assert!(sc.extra.contains_key("tool_uses"));
1188    }
1189
1190    #[test]
1191    fn test_tool_use_non_filewrite_ignored() {
1192        let mut turn = base_turn("t1", Role::Assistant);
1193        turn.tool_uses = vec![ToolInvocation {
1194            id: "tu1".into(),
1195            name: "Read".into(),
1196            input: serde_json::json!({"file_path": "x.rs"}),
1197            result: None,
1198            category: Some(ToolCategory::FileRead),
1199        }];
1200        let view = view_with(vec![turn]);
1201        let path = derive_path(&view, &DeriveConfig::default());
1202        assert!(!path.steps[0].change.contains_key("x.rs"));
1203        assert_eq!(path.steps[0].change.len(), 1);
1204    }
1205
1206    #[test]
1207    fn test_tool_use_edit_emits_unified_diff() {
1208        let mut turn = base_turn("t1", Role::Assistant);
1209        turn.tool_uses = vec![fw_tool(
1210            "Edit",
1211            "tu1",
1212            serde_json::json!({
1213                "file_path": "src/login.rs",
1214                "old_string": "validate_token()",
1215                "new_string": "validate_token_v2()",
1216            }),
1217        )];
1218        let view = view_with(vec![turn]);
1219        let path = derive_path(&view, &DeriveConfig::default());
1220        let ch = &path.steps[0].change["src/login.rs"];
1221        let raw = ch.raw.as_deref().expect("edit should emit unified diff");
1222        assert!(raw.contains("--- a/src/login.rs"));
1223        assert!(raw.contains("+++ b/src/login.rs"));
1224        assert!(raw.contains("-validate_token()"));
1225        assert!(raw.contains("+validate_token_v2()"));
1226        let sc = ch.structural.as_ref().unwrap();
1227        assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
1228        assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
1229    }
1230
1231    #[test]
1232    fn test_tool_use_write_emits_full_content_diff() {
1233        let mut turn = base_turn("t1", Role::Assistant);
1234        turn.tool_uses = vec![fw_tool(
1235            "Write",
1236            "tu1",
1237            serde_json::json!({
1238                "file_path": "hello.txt",
1239                "content": "hi\nthere\n",
1240            }),
1241        )];
1242        let view = view_with(vec![turn]);
1243        let path = derive_path(&view, &DeriveConfig::default());
1244        let ch = &path.steps[0].change["hello.txt"];
1245        let raw = ch.raw.as_deref().expect("write should emit diff");
1246        assert!(raw.contains("+hi"));
1247        assert!(raw.contains("+there"));
1248        let sc = ch.structural.as_ref().unwrap();
1249        assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
1250        assert!(!sc.extra.contains_key("before"));
1251    }
1252
1253    #[test]
1254    fn test_file_write_diff_write_without_before_state_is_addition_only() {
1255        // Backwards-compatible fallback: `None` → diff against "".
1256        let input = serde_json::json!({
1257            "file_path": "hello.txt",
1258            "content": "hi\nthere\n",
1259        });
1260        let raw =
1261            file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
1262        assert!(raw.contains("+hi"));
1263        assert!(raw.contains("+there"));
1264        // No `-` lines — nothing was there before.
1265        assert!(
1266            !raw.lines()
1267                .any(|l| l.starts_with('-') && !l.starts_with("---"))
1268        );
1269    }
1270
1271    #[test]
1272    fn test_file_write_diff_write_with_before_state_shows_replacement() {
1273        let input = serde_json::json!({
1274            "file_path": "hello.txt",
1275            "content": "hi\nthere\n",
1276        });
1277        let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
1278            .expect("write should emit diff");
1279        // Before content should appear as removals.
1280        assert!(raw.contains("-bye"));
1281        assert!(raw.contains("-friend"));
1282        // After content should appear as additions.
1283        assert!(raw.contains("+hi"));
1284        assert!(raw.contains("+there"));
1285    }
1286
1287    #[test]
1288    fn test_file_write_diff_before_state_ignored_for_edit_shape() {
1289        // `Edit` has its own `old_string`; supplied before_state should
1290        // be ignored.
1291        let input = serde_json::json!({
1292            "file_path": "a.rs",
1293            "old_string": "foo",
1294            "new_string": "bar",
1295        });
1296        let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
1297            .expect("edit should emit diff");
1298        assert!(raw.contains("-foo"));
1299        assert!(raw.contains("+bar"));
1300        assert!(!raw.contains("something else entirely"));
1301    }
1302
1303    #[test]
1304    fn test_unified_diff_strips_leading_slash_on_absolute_path() {
1305        // Regression for #36: headers for absolute paths must not contain `a//`.
1306        let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
1307        assert!(
1308            raw.contains("--- a/abs/path.rs\n"),
1309            "missing stripped --- header: {raw}"
1310        );
1311        assert!(
1312            raw.contains("+++ b/abs/path.rs\n"),
1313            "missing stripped +++ header: {raw}"
1314        );
1315        assert!(
1316            !raw.contains("a//"),
1317            "header should not contain doubled slash: {raw}"
1318        );
1319        assert!(
1320            !raw.contains("b//"),
1321            "header should not contain doubled slash: {raw}"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_unified_diff_preserves_relative_path() {
1327        // Relative paths (no leading slash) are unchanged — only a single
1328        // leading `/` is stripped.
1329        let raw = unified_diff("src/login.rs", "a\n", "b\n");
1330        assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
1331        assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
1332    }
1333
1334    #[test]
1335    fn test_tool_use_multiedit_emits_per_hunk_diff() {
1336        let mut turn = base_turn("t1", Role::Assistant);
1337        turn.tool_uses = vec![fw_tool(
1338            "MultiEdit",
1339            "tu1",
1340            serde_json::json!({
1341                "file_path": "m.rs",
1342                "edits": [
1343                    {"old_string": "foo", "new_string": "bar"},
1344                    {"old_string": "baz", "new_string": "qux"},
1345                ],
1346            }),
1347        )];
1348        let view = view_with(vec![turn]);
1349        let path = derive_path(&view, &DeriveConfig::default());
1350        let ch = &path.steps[0].change["m.rs"];
1351        let raw = ch.raw.as_deref().expect("multiedit should emit diff");
1352        assert!(raw.contains("# edit 1/2"));
1353        assert!(raw.contains("# edit 2/2"));
1354        assert!(raw.contains("-foo"));
1355        assert!(raw.contains("+bar"));
1356        assert!(raw.contains("-baz"));
1357        assert!(raw.contains("+qux"));
1358    }
1359
1360    #[test]
1361    fn test_thinking_included_when_enabled() {
1362        let mut turn = base_turn("t1", Role::Assistant);
1363        turn.thinking = Some("hmm".into());
1364        let view = view_with(vec![turn]);
1365        let path = derive_path(&view, &DeriveConfig::default());
1366        let sc = conv_change(&path.steps[0]);
1367        assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
1368    }
1369
1370    #[test]
1371    fn test_thinking_omitted_when_disabled() {
1372        let mut turn = base_turn("t1", Role::Assistant);
1373        turn.thinking = Some("hmm".into());
1374        let view = view_with(vec![turn]);
1375        let cfg = DeriveConfig {
1376            include_thinking: false,
1377            ..Default::default()
1378        };
1379        let path = derive_path(&view, &cfg);
1380        let sc = conv_change(&path.steps[0]);
1381        assert!(!sc.extra.contains_key("thinking"));
1382    }
1383
1384    #[test]
1385    fn test_tool_uses_included_when_enabled() {
1386        let mut turn = base_turn("t1", Role::Assistant);
1387        turn.tool_uses = vec![ToolInvocation {
1388            id: "tu1".into(),
1389            name: "Read".into(),
1390            input: serde_json::json!({}),
1391            result: Some(ToolResult {
1392                content: "x".into(),
1393                is_error: false,
1394            }),
1395            category: Some(ToolCategory::FileRead),
1396        }];
1397        let view = view_with(vec![turn]);
1398        let path = derive_path(&view, &DeriveConfig::default());
1399        let sc = conv_change(&path.steps[0]);
1400        assert!(sc.extra.contains_key("tool_uses"));
1401    }
1402
1403    #[test]
1404    fn test_tool_uses_omitted_when_disabled() {
1405        let mut turn = base_turn("t1", Role::Assistant);
1406        turn.tool_uses = vec![ToolInvocation {
1407            id: "tu1".into(),
1408            name: "Read".into(),
1409            input: serde_json::json!({}),
1410            result: None,
1411            category: Some(ToolCategory::FileRead),
1412        }];
1413        let view = view_with(vec![turn]);
1414        let cfg = DeriveConfig {
1415            include_tool_uses: false,
1416            ..Default::default()
1417        };
1418        let path = derive_path(&view, &cfg);
1419        let sc = conv_change(&path.steps[0]);
1420        assert!(!sc.extra.contains_key("tool_uses"));
1421    }
1422
1423    #[test]
1424    fn test_base_uri_from_working_dir() {
1425        let mut turn = base_turn("t1", Role::User);
1426        turn.environment = Some(EnvironmentSnapshot {
1427            working_dir: Some("/Users/alex/proj".into()),
1428            ..Default::default()
1429        });
1430        let view = view_with(vec![turn]);
1431        let path = derive_path(&view, &DeriveConfig::default());
1432        assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
1433    }
1434
1435    #[test]
1436    fn test_base_uri_from_config_override() {
1437        let mut turn = base_turn("t1", Role::User);
1438        turn.environment = Some(EnvironmentSnapshot {
1439            working_dir: Some("/Users/alex/proj".into()),
1440            ..Default::default()
1441        });
1442        let view = view_with(vec![turn]);
1443        let cfg = DeriveConfig {
1444            base_uri: Some("github:org/repo".into()),
1445            ..Default::default()
1446        };
1447        let path = derive_path(&view, &cfg);
1448        assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
1449    }
1450
1451    #[test]
1452    fn test_base_uri_absent_when_no_source() {
1453        let turn = base_turn("t1", Role::User);
1454        let view = view_with(vec![turn]);
1455        let path = derive_path(&view, &DeriveConfig::default());
1456        assert!(path.path.base.is_none());
1457    }
1458
1459    #[test]
1460    fn test_path_id_from_config_override() {
1461        let view = view_with(vec![]);
1462        let cfg = DeriveConfig {
1463            path_id: Some("my-custom-id".into()),
1464            ..Default::default()
1465        };
1466        let path = derive_path(&view, &cfg);
1467        assert_eq!(path.path.id, "my-custom-id");
1468    }
1469
1470    #[test]
1471    fn test_path_id_default_format() {
1472        let view = view_with(vec![]);
1473        let path = derive_path(&view, &DeriveConfig::default());
1474        assert_eq!(path.path.id, "path-pi-abcdef01");
1475    }
1476
1477    #[test]
1478    fn test_files_changed_in_meta() {
1479        let mut view = view_with(vec![]);
1480        view.files_changed = vec!["a.rs".into(), "b.rs".into()];
1481        let path = derive_path(&view, &DeriveConfig::default());
1482        let meta = path.meta.unwrap();
1483        assert_eq!(
1484            meta.extra["files_changed"],
1485            serde_json::json!(["a.rs", "b.rs"])
1486        );
1487    }
1488
1489    #[test]
1490    fn test_actors_in_meta() {
1491        let u = base_turn("t1", Role::User);
1492        let mut a = base_turn("t2", Role::Assistant);
1493        a.model = Some("claude-opus-4-7".into());
1494        let view = view_with(vec![u, a]);
1495        let path = derive_path(&view, &DeriveConfig::default());
1496        let actors = path.meta.unwrap().actors.unwrap();
1497        assert!(actors.contains_key("human:user"));
1498        assert!(actors.contains_key("agent:claude-opus-4-7"));
1499        let agent = &actors["agent:claude-opus-4-7"];
1500        assert_eq!(agent.provider.as_deref(), Some("pi"));
1501        assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
1502        let human = &actors["human:user"];
1503        assert_eq!(human.name.as_deref(), Some("user"));
1504    }
1505
1506    #[test]
1507    fn test_head_is_last_step_id() {
1508        let turns = vec![
1509            base_turn("t1", Role::User),
1510            base_turn("t2", Role::User),
1511            base_turn("t3", Role::User),
1512        ];
1513        let view = view_with(turns);
1514        let path = derive_path(&view, &DeriveConfig::default());
1515        assert_eq!(path.path.head, "t3");
1516    }
1517
1518    #[test]
1519    fn test_token_usage_in_extras() {
1520        let mut turn = base_turn("t1", Role::Assistant);
1521        turn.token_usage = Some(TokenUsage {
1522            input_tokens: Some(100),
1523            output_tokens: Some(50),
1524            cache_read_tokens: None,
1525            cache_write_tokens: None,
1526            ..Default::default()
1527        });
1528        let view = view_with(vec![turn]);
1529        let path = derive_path(&view, &DeriveConfig::default());
1530        let sc = conv_change(&path.steps[0]);
1531        assert!(sc.extra.contains_key("token_usage"));
1532        assert_eq!(
1533            sc.extra["token_usage"]["input_tokens"],
1534            serde_json::json!(100)
1535        );
1536    }
1537
1538    fn usage(output: u32) -> TokenUsage {
1539        TokenUsage {
1540            input_tokens: Some(6),
1541            output_tokens: Some(output),
1542            cache_read_tokens: Some(14_842),
1543            cache_write_tokens: Some(429_831),
1544            ..Default::default()
1545        }
1546    }
1547
1548    #[test]
1549    fn test_message_group_carries_usage_once_on_last_step() {
1550        // Three turns split from one provider message (Claude Code repeats
1551        // message.usage on every content-block line), then one singleton
1552        // message. Usage must land exactly once per group_id group — on
1553        // the group's last step — and group_id on every grouped step.
1554        let mut turns: Vec<Turn> = (1..=3)
1555            .map(|i| {
1556                let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1557                t.group_id = Some("msg_01".into());
1558                t.token_usage = Some(usage(997));
1559                t
1560            })
1561            .collect();
1562        let mut t4 = base_turn("t4", Role::Assistant);
1563        t4.group_id = Some("msg_02".into());
1564        t4.token_usage = Some(usage(11));
1565        turns.push(t4);
1566
1567        let view = view_with(turns);
1568        let path = derive_path(&view, &DeriveConfig::default());
1569        let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1570
1571        assert!(!changes[0].extra.contains_key("token_usage"));
1572        assert!(!changes[1].extra.contains_key("token_usage"));
1573        assert_eq!(
1574            changes[2].extra["token_usage"]["output_tokens"],
1575            serde_json::json!(997)
1576        );
1577        assert_eq!(
1578            changes[3].extra["token_usage"]["output_tokens"],
1579            serde_json::json!(11)
1580        );
1581        for c in &changes[..3] {
1582            assert_eq!(c.extra["group_id"], serde_json::json!("msg_01"));
1583        }
1584        assert_eq!(changes[3].extra["group_id"], serde_json::json!("msg_02"));
1585    }
1586
1587    #[test]
1588    fn test_turn_without_group_id_is_its_own_accounting_unit() {
1589        // Providers that never split a message (gemini, pi, opencode)
1590        // leave group_id unset; every turn keeps its own usage.
1591        let mut turns = Vec::new();
1592        for i in 1..=2 {
1593            let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1594            t.token_usage = Some(usage(50 + i));
1595            turns.push(t);
1596        }
1597        let view = view_with(turns);
1598        let path = derive_path(&view, &DeriveConfig::default());
1599        for (i, step) in path.steps.iter().enumerate() {
1600            let sc = conv_change(step);
1601            assert_eq!(
1602                sc.extra["token_usage"]["output_tokens"],
1603                serde_json::json!(51 + i as u64)
1604            );
1605            assert!(!sc.extra.contains_key("group_id"));
1606        }
1607    }
1608
1609    #[test]
1610    fn test_message_grouping_is_consecutive_only() {
1611        // A group_id reappearing after an intervening message starts a
1612        // new group (defensive: source formats never interleave, but the
1613        // rule is defined over consecutive runs in document order).
1614        let mk = |id: &str, msg: &str, out: u32| {
1615            let mut t = base_turn(id, Role::Assistant);
1616            t.group_id = Some(msg.into());
1617            t.token_usage = Some(usage(out));
1618            t
1619        };
1620        let view = view_with(vec![
1621            mk("t1", "msg_01", 100),
1622            mk("t2", "msg_02", 200),
1623            mk("t3", "msg_01", 300),
1624        ]);
1625        let path = derive_path(&view, &DeriveConfig::default());
1626        let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1627        assert_eq!(
1628            changes[0].extra["token_usage"]["output_tokens"],
1629            serde_json::json!(100)
1630        );
1631        assert_eq!(
1632            changes[1].extra["token_usage"]["output_tokens"],
1633            serde_json::json!(200)
1634        );
1635        assert_eq!(
1636            changes[2].extra["token_usage"]["output_tokens"],
1637            serde_json::json!(300)
1638        );
1639    }
1640
1641    #[test]
1642    fn test_delegations_in_extras() {
1643        let mut turn = base_turn("t1", Role::Assistant);
1644        turn.delegations = vec![DelegatedWork {
1645            agent_id: "sub-1".into(),
1646            prompt: "do a thing".into(),
1647            turns: vec![],
1648            result: None,
1649        }];
1650        let view = view_with(vec![turn]);
1651        let path = derive_path(&view, &DeriveConfig::default());
1652        let sc = conv_change(&path.steps[0]);
1653        assert!(sc.extra.contains_key("delegations"));
1654        assert_eq!(
1655            sc.extra["delegations"][0]["agent_id"],
1656            serde_json::json!("sub-1")
1657        );
1658    }
1659
1660    #[test]
1661    fn test_title_from_config() {
1662        let view = view_with(vec![]);
1663        let cfg = DeriveConfig {
1664            title: Some("My Session".into()),
1665            ..Default::default()
1666        };
1667        let path = derive_path(&view, &cfg);
1668        assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1669    }
1670
1671    #[test]
1672    fn test_title_default_when_unset() {
1673        let view = view_with(vec![]);
1674        let path = derive_path(&view, &DeriveConfig::default());
1675        assert_eq!(
1676            path.meta.unwrap().title.as_deref(),
1677            Some("pi session: abcdef01")
1678        );
1679    }
1680
1681    #[test]
1682    fn test_serde_roundtrip() {
1683        let mut t1 = base_turn("t1", Role::User);
1684        t1.text = "hello".into();
1685        t1.environment = Some(EnvironmentSnapshot {
1686            working_dir: Some("/proj".into()),
1687            ..Default::default()
1688        });
1689        let mut t2 = base_turn("t2", Role::Assistant);
1690        t2.parent_id = Some("t1".into());
1691        t2.model = Some("m".into());
1692        t2.tool_uses = vec![fw_tool(
1693            "Write",
1694            "tu1",
1695            serde_json::json!({"file_path": "x.rs"}),
1696        )];
1697
1698        let mut view = view_with(vec![t1, t2]);
1699        view.files_changed = vec!["x.rs".into()];
1700
1701        let path = derive_path(&view, &DeriveConfig::default());
1702        let json = serde_json::to_string(&path).unwrap();
1703        let back: Path = serde_json::from_str(&json).unwrap();
1704        assert_eq!(back.path.id, path.path.id);
1705        assert_eq!(back.path.head, path.path.head);
1706        assert_eq!(back.steps.len(), 2);
1707        assert_eq!(back.steps[1].step.parents, vec!["t1".to_string()]);
1708        assert!(back.steps[1].change.contains_key("x.rs"));
1709    }
1710}