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