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