Skip to main content

toolpath_opencode/
derive.rs

1//! Derive Toolpath documents from opencode sessions.
2//!
3//! Each `Turn` becomes a `Step`. Every step's `change` map carries:
4//!
5//! - One entry at `opencode://<session-id>` with a
6//!   `conversation.append` structural op describing the turn's text,
7//!   thinking, and tool-call summaries.
8//! - Sibling entries for each file touched between the turn's
9//!   snapshot endpoints. When the snapshot git repo is on disk,
10//!   `ArtifactChange.raw` is the real unified diff from git. Otherwise
11//!   we fall back to file paths reported by tool inputs with no
12//!   `raw` perspective.
13
14use crate::paths::PathResolver;
15use crate::provider::{to_view, tool_category};
16use crate::types::Session;
17use serde_json::{Map, Value, json};
18use std::collections::HashMap;
19use std::path::{Path as StdPath, PathBuf};
20use toolpath::v1::{
21    ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
22    StepIdentity, StructuralChange,
23};
24use toolpath_convo::{ConversationView, Role, Turn};
25
26/// Configuration for deriving a Toolpath `Path` from an opencode
27/// session.
28#[derive(Debug, Clone, Default)]
29pub struct DeriveConfig {
30    /// Override `path.base.uri`. Defaults to `file://<session.directory>`.
31    pub project_path: Option<String>,
32    /// Disable snapshot-based file diff extraction even when the
33    /// snapshot repo is on disk. Useful for tests / offline runs.
34    pub no_snapshot_diffs: bool,
35}
36
37/// Derive a `Path` from a loaded opencode `Session`.
38pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
39    let view = to_view(session);
40    derive_path_from_view(session, &view, config, &PathResolver::new())
41}
42
43/// Like [`derive_path`] but with a custom `PathResolver` (useful for
44/// tests with a temp data directory).
45pub fn derive_path_with_resolver(
46    session: &Session,
47    config: &DeriveConfig,
48    resolver: &PathResolver,
49) -> Path {
50    let view = to_view(session);
51    derive_path_from_view(session, &view, config, resolver)
52}
53
54/// Derive a `Path` from multiple sessions.
55pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
56    sessions.iter().map(|s| derive_path(s, config)).collect()
57}
58
59fn derive_path_from_view(
60    session: &Session,
61    view: &ConversationView,
62    config: &DeriveConfig,
63    resolver: &PathResolver,
64) -> Path {
65    let session_short: String = session
66        .id
67        .trim_start_matches("ses_")
68        .chars()
69        .take(8)
70        .collect();
71    let path_id = format!("path-opencode-{}", session_short);
72    let convo_artifact = format!("opencode://{}", session.id);
73
74    // Open the snapshot git repo if present. A single open for the
75    // whole derive is fine — git2 is thread-local enough for our needs.
76    let snapshot_repo: Option<git2::Repository> = if config.no_snapshot_diffs {
77        None
78    } else {
79        resolver
80            .snapshot_gitdir(&session.project_id, &session.directory)
81            .ok()
82            .and_then(|gd| git2::Repository::open(gd).ok())
83    };
84
85    let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
86    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
87    let mut last_step_id: Option<String> = None;
88    let mut prev_snapshot_after: Option<String> = None;
89    let mut all_files: Vec<String> = Vec::new();
90    let mut files_seen = std::collections::HashSet::<String>::new();
91
92    for (turn_idx, turn) in view.turns.iter().enumerate() {
93        let Some(step) = build_step(
94            turn_idx,
95            turn,
96            &convo_artifact,
97            last_step_id.as_deref(),
98            &mut actors,
99            &snapshot_repo,
100            &mut prev_snapshot_after,
101            &mut all_files,
102            &mut files_seen,
103        ) else {
104            continue;
105        };
106        last_step_id = Some(step.step.id.clone());
107        steps.push(step);
108    }
109
110    let head = last_step_id.unwrap_or_else(|| "empty".to_string());
111
112    // Base: CLI-override wins; otherwise session.directory; fall back
113    // to the first turn's working_dir.
114    let base_uri = config
115        .project_path
116        .clone()
117        .or_else(|| Some(session.directory.to_string_lossy().to_string()))
118        .map(|p| {
119            if p.starts_with('/') {
120                format!("file://{}", p)
121            } else {
122                p
123            }
124        });
125    // Base ref: first-root-commit SHA (== project_id) is a stable
126    // ancestor identifier.
127    let base_ref = Some(session.project_id.clone());
128    let base = base_uri.map(|uri| Base {
129        uri,
130        ref_str: base_ref,
131    });
132
133    // Top-level path meta: actors, title, source, opencode metadata.
134    let mut path_extra: HashMap<String, Value> = HashMap::new();
135    let mut oc: Map<String, Value> = Map::new();
136    oc.insert("session_id".into(), Value::String(session.id.clone()));
137    oc.insert(
138        "project_id".into(),
139        Value::String(session.project_id.clone()),
140    );
141    oc.insert("slug".into(), Value::String(session.slug.clone()));
142    oc.insert("version".into(), Value::String(session.version.clone()));
143    if let Some(total) = view.total_usage.as_ref() {
144        oc.insert(
145            "total_tokens".into(),
146            serde_json::to_value(total).unwrap_or(Value::Null),
147        );
148    }
149    if !all_files.is_empty() {
150        oc.insert(
151            "files_changed".into(),
152            Value::Array(all_files.iter().map(|p| Value::String(p.clone())).collect()),
153        );
154    }
155    path_extra.insert("opencode".into(), Value::Object(oc));
156
157    Path {
158        path: PathIdentity {
159            id: path_id,
160            base,
161            head,
162            graph_ref: None,
163        },
164        steps,
165        meta: Some(PathMeta {
166            title: Some(format!("opencode session: {}", session.title)),
167            source: Some("opencode".to_string()),
168            actors: if actors.is_empty() {
169                None
170            } else {
171                Some(actors)
172            },
173            extra: path_extra,
174            ..Default::default()
175        }),
176    }
177}
178
179#[allow(clippy::too_many_arguments)]
180fn build_step(
181    turn_idx: usize,
182    turn: &Turn,
183    convo_artifact: &str,
184    parent_id: Option<&str>,
185    actors: &mut HashMap<String, ActorDefinition>,
186    snapshot_repo: &Option<git2::Repository>,
187    prev_snapshot_after: &mut Option<String>,
188    all_files: &mut Vec<String>,
189    files_seen: &mut std::collections::HashSet<String>,
190) -> Option<Step> {
191    // Skip empty carrier turns.
192    if turn.text.is_empty() && turn.tool_uses.is_empty() && turn.thinking.is_none() {
193        return None;
194    }
195
196    let (actor, role_str) = resolve_actor(turn, actors);
197
198    let mut convo_extra: HashMap<String, Value> = HashMap::new();
199    convo_extra.insert("role".into(), json!(role_str));
200    if !turn.text.is_empty() {
201        convo_extra.insert("text".into(), json!(turn.text));
202    }
203    if let Some(th) = turn.thinking.as_deref()
204        && !th.is_empty()
205    {
206        convo_extra.insert("thinking".into(), json!(th));
207    }
208    if !turn.tool_uses.is_empty() {
209        let calls: Vec<Value> = turn
210            .tool_uses
211            .iter()
212            .map(|tu| {
213                json!({
214                    "name": tu.name,
215                    "call_id": tu.id,
216                    "category": tu.category,
217                    "summary": tool_call_summary(tu),
218                    "status": if let Some(r) = tu.result.as_ref() {
219                        if r.is_error { "error" } else { "success" }
220                    } else { "pending" },
221                })
222            })
223            .collect();
224        convo_extra.insert("tool_calls".into(), Value::Array(calls));
225    }
226    if let Some(u) = turn.token_usage.as_ref() {
227        convo_extra.insert("token_usage".into(), json!(u));
228    }
229    if let Some(sr) = turn.stop_reason.as_deref()
230        && !sr.is_empty()
231    {
232        convo_extra.insert("stop_reason".into(), json!(sr));
233    }
234
235    let convo_change = ArtifactChange {
236        raw: None,
237        structural: Some(StructuralChange {
238            change_type: "conversation.append".to_string(),
239            extra: convo_extra,
240        }),
241    };
242
243    let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
244    changes.insert(convo_artifact.to_string(), convo_change);
245
246    // Extract snapshot pair (before, after) for this turn.
247    let snapshots = turn
248        .extra
249        .get("opencode")
250        .and_then(|oc| oc.get("snapshots"))
251        .and_then(|v| v.as_array())
252        .map(|arr| {
253            arr.iter()
254                .filter_map(|v| v.as_str().map(str::to_string))
255                .collect::<Vec<_>>()
256        })
257        .unwrap_or_default();
258    let (before, after) = match (snapshots.first(), snapshots.last()) {
259        (Some(first), Some(last)) => {
260            // The "before" state is whichever is earlier: the snapshot
261            // the previous turn ended on, or the first snapshot of
262            // this turn (which usually match). Prefer the prior-turn's
263            // ending snapshot — it captures the pre-step state even
264            // when this turn's first step-start is missing.
265            let b = prev_snapshot_after.clone().unwrap_or_else(|| first.clone());
266            (Some(b), Some(last.clone()))
267        }
268        _ => (None, None),
269    };
270
271    // First pass: pull real unified diffs from the snapshot repo for
272    // files opencode could see (i.e. not gitignored).
273    if let (Some(b), Some(a), Some(repo)) = (&before, &after, snapshot_repo.as_ref())
274        && b != a
275    {
276        match diff_trees(repo, b, a) {
277            Ok(file_changes) => {
278                for (file_path, artifact_change) in file_changes {
279                    if files_seen.insert(file_path.clone()) {
280                        all_files.push(file_path.clone());
281                    }
282                    changes.insert(file_path, artifact_change);
283                }
284            }
285            Err(e) => {
286                eprintln!(
287                    "Warning: snapshot diff {}..{} failed: {}",
288                    &b[..b.len().min(8)],
289                    &a[..a.len().min(8)],
290                    e
291                );
292            }
293        }
294    }
295
296    // Second pass: catch files opencode could NOT see in the snapshot
297    // repo — either because there's no snapshot repo, no snapshot pair,
298    // or the pair's diff was empty (common when the target files are
299    // under a .gitignored path). Use tool inputs as the best available
300    // evidence; no `raw` perspective, but the path and operation still
301    // land on the step.
302    for tu in &turn.tool_uses {
303        let Some(path) = tool_input_file_path(tu) else {
304            continue;
305        };
306        if changes.contains_key(&path) {
307            continue;
308        }
309        if files_seen.insert(path.clone()) {
310            all_files.push(path.clone());
311        }
312        let op = tool_to_operation(&tu.name);
313        let mut extra = HashMap::new();
314        extra.insert("operation".into(), json!(op));
315        extra.insert("tool".into(), json!(tu.name));
316        extra.insert(
317            "source".into(),
318            json!(if snapshot_repo.is_some() {
319                "tool_input_gitignored"
320            } else {
321                "tool_input"
322            }),
323        );
324        changes.insert(
325            path,
326            ArtifactChange {
327                raw: None,
328                structural: Some(StructuralChange {
329                    change_type: format!("opencode.{}", op),
330                    extra,
331                }),
332            },
333        );
334    }
335
336    // Advance prev_snapshot_after for the next turn.
337    if let Some(a) = &after {
338        *prev_snapshot_after = Some(a.clone());
339    }
340
341    let step_id = format!("step-{:04}", turn_idx + 1);
342    let parents = parent_id.map(|p| vec![p.to_string()]).unwrap_or_default();
343
344    Some(Step {
345        step: StepIdentity {
346            id: step_id,
347            parents,
348            actor,
349            timestamp: turn.timestamp.clone(),
350        },
351        change: changes,
352        meta: None,
353    })
354}
355
356fn resolve_actor(
357    turn: &Turn,
358    actors: &mut HashMap<String, ActorDefinition>,
359) -> (String, &'static str) {
360    match &turn.role {
361        Role::User => {
362            actors
363                .entry("human:user".to_string())
364                .or_insert_with(|| ActorDefinition {
365                    name: Some("User".to_string()),
366                    ..Default::default()
367                });
368            ("human:user".to_string(), "user")
369        }
370        Role::Assistant => {
371            let (key, model_str) = match &turn.model {
372                Some(m) if !m.is_empty() => (format!("agent:{}", m), m.clone()),
373                _ => ("agent:opencode".to_string(), "opencode".to_string()),
374            };
375            let provider = turn
376                .extra
377                .get("opencode")
378                .and_then(|oc| oc.get("providerID"))
379                .and_then(|v| v.as_str())
380                .map(str::to_string);
381            actors
382                .entry(key.clone())
383                .or_insert_with(|| ActorDefinition {
384                    name: Some("opencode".to_string()),
385                    provider: provider.clone(),
386                    model: Some(model_str.clone()),
387                    identities: provider
388                        .map(|p| {
389                            vec![Identity {
390                                system: p,
391                                id: model_str,
392                            }]
393                        })
394                        .unwrap_or_default(),
395                    ..Default::default()
396                });
397            (key, "assistant")
398        }
399        Role::System => {
400            actors
401                .entry("system:opencode".to_string())
402                .or_insert_with(|| ActorDefinition {
403                    name: Some("opencode system".to_string()),
404                    ..Default::default()
405                });
406            ("system:opencode".to_string(), "system")
407        }
408        Role::Other(s) => {
409            let key = format!("other:{}", s);
410            actors
411                .entry(key.clone())
412                .or_insert_with(|| ActorDefinition {
413                    name: Some(s.clone()),
414                    ..Default::default()
415                });
416            (key, "other")
417        }
418    }
419}
420
421fn tool_call_summary(tu: &toolpath_convo::ToolInvocation) -> String {
422    let pick = |k: &str| -> Option<String> {
423        tu.input.get(k).and_then(|v| v.as_str()).map(str::to_string)
424    };
425    let s = match tu.name.as_str() {
426        "bash" | "shell" | "exec" => pick("command").or_else(|| pick("cmd")),
427        "read" | "list" | "view" | "ls" => pick("filePath").or_else(|| pick("path")),
428        "write" | "edit" | "multiedit" | "patch" => pick("filePath")
429            .or_else(|| pick("file_path"))
430            .or_else(|| pick("path")),
431        "glob" | "grep" | "search" => pick("pattern").or_else(|| pick("query")),
432        "webfetch" | "fetch" => pick("url"),
433        "websearch" => pick("query"),
434        "task" | "agent" | "spawn_agent" => pick("prompt").or_else(|| pick("task")),
435        _ => None,
436    };
437    s.unwrap_or_default()
438}
439
440fn tool_input_file_path(tu: &toolpath_convo::ToolInvocation) -> Option<String> {
441    tu.input
442        .get("filePath")
443        .or_else(|| tu.input.get("file_path"))
444        .or_else(|| tu.input.get("path"))
445        .and_then(|v| v.as_str())
446        .map(str::to_string)
447}
448
449fn tool_to_operation(name: &str) -> &'static str {
450    match name {
451        "write" => "add",
452        "edit" | "multiedit" | "patch" => "update",
453        "delete" | "rm" => "delete",
454        _ => "touch",
455    }
456}
457
458fn diff_trees(
459    repo: &git2::Repository,
460    before: &str,
461    after: &str,
462) -> Result<Vec<(String, ArtifactChange)>, git2::Error> {
463    let before_obj = repo.revparse_single(before)?;
464    let after_obj = repo.revparse_single(after)?;
465    let before_tree = before_obj.peel_to_tree()?;
466    let after_tree = after_obj.peel_to_tree()?;
467
468    let mut opts = git2::DiffOptions::new();
469    opts.context_lines(3);
470    opts.include_ignored(false);
471    opts.ignore_submodules(true);
472    let diff = repo.diff_tree_to_tree(Some(&before_tree), Some(&after_tree), Some(&mut opts))?;
473
474    // Collect unified-diff text + typed op per file.
475    let mut by_path: HashMap<PathBuf, (String, &'static str, Option<PathBuf>)> = HashMap::new();
476
477    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
478        let Some(new_path) = delta.new_file().path() else {
479            // Handle delete: old_file path, no new
480            if let Some(old) = delta.old_file().path() {
481                let buf = by_path
482                    .entry(old.to_path_buf())
483                    .or_insert_with(|| (String::new(), "delete", None));
484                append_diff_line(&mut buf.0, line);
485            }
486            return true;
487        };
488        let op = classify_delta(&delta);
489        let entry = by_path.entry(new_path.to_path_buf()).or_insert_with(|| {
490            (
491                String::new(),
492                op,
493                delta.old_file().path().map(|p| p.to_path_buf()),
494            )
495        });
496        append_diff_line(&mut entry.0, line);
497        true
498    })?;
499
500    let mut out: Vec<(String, ArtifactChange)> = Vec::new();
501    for (path, (raw_diff, op, old_path)) in by_path {
502        let file_str = path.to_string_lossy().to_string();
503        let mut extra = HashMap::new();
504        extra.insert("operation".into(), json!(op));
505        if op == "rename"
506            && let Some(old) = &old_path
507        {
508            extra.insert("from".into(), json!(old.to_string_lossy()));
509        }
510        out.push((
511            file_str,
512            ArtifactChange {
513                raw: if raw_diff.is_empty() {
514                    None
515                } else {
516                    Some(raw_diff)
517                },
518                structural: Some(StructuralChange {
519                    change_type: format!("opencode.{}", op),
520                    extra,
521                }),
522            },
523        ));
524    }
525    // Stable ordering for reproducibility.
526    out.sort_by(|a, b| a.0.cmp(&b.0));
527    Ok(out)
528}
529
530fn classify_delta(delta: &git2::DiffDelta) -> &'static str {
531    use git2::Delta;
532    match delta.status() {
533        Delta::Added => "add",
534        Delta::Deleted => "delete",
535        Delta::Modified => "update",
536        Delta::Renamed => "rename",
537        Delta::Copied => "copy",
538        Delta::Typechange => "update",
539        _ => "update",
540    }
541}
542
543fn append_diff_line(buf: &mut String, line: git2::DiffLine<'_>) {
544    use git2::DiffLineType;
545    let prefix = match line.origin_value() {
546        DiffLineType::Context => " ",
547        DiffLineType::Addition => "+",
548        DiffLineType::Deletion => "-",
549        DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => "",
550        _ => "",
551    };
552    buf.push_str(prefix);
553    if let Ok(s) = std::str::from_utf8(line.content()) {
554        buf.push_str(s);
555    }
556}
557
558// Keep tool_category reachable — the match in provider.rs is what
559// populates categories, but consumers importing `derive` only may
560// want the classifier too.
561#[allow(dead_code)]
562fn _use_tool_category(name: &str) -> Option<toolpath_convo::ToolCategory> {
563    tool_category(name)
564}
565
566#[allow(dead_code)]
567fn _use_stdpath(_: &StdPath) {}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::OpencodeConvo;
573    use rusqlite::Connection;
574    use std::fs;
575    use tempfile::TempDir;
576    use toolpath::v1::Document;
577
578    fn fixture(body_sql: &str) -> (TempDir, OpencodeConvo, PathResolver) {
579        let temp = TempDir::new().unwrap();
580        let data = temp.path().join(".local/share/opencode");
581        fs::create_dir_all(&data).unwrap();
582        let conn = Connection::open(data.join("opencode.db")).unwrap();
583        conn.execute_batch(&format!(
584            r#"
585            CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
586                icon_url text, icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL,
587                time_initialized integer, sandboxes text NOT NULL, commands text);
588            CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
589                slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL,
590                share_url text, summary_additions integer, summary_deletions integer, summary_files integer,
591                summary_diffs text, revert text, permission text,
592                time_created integer NOT NULL, time_updated integer NOT NULL,
593                time_compacting integer, time_archived integer, workspace_id text);
594            CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL,
595                time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
596            CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
597                time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
598            {body_sql}
599        "#
600        ))
601        .unwrap();
602        drop(conn);
603        let resolver = PathResolver::new()
604            .with_home(temp.path())
605            .with_data_dir(&data);
606        (
607            temp,
608            OpencodeConvo::with_resolver(resolver.clone()),
609            resolver,
610        )
611    }
612
613    const BASIC_SQL: &str = r#"
614        INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
615          VALUES ('proj_sha', '/tmp/proj', 1000, 3000, '[]');
616        INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
617          VALUES ('ses_abc123', 'proj_sha', 'slug', '/tmp/proj', 'Build pickle', '1.3.10', 1000, 3000);
618        INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
619          ('m1','ses_abc123',1001,1001,
620           '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
621          ('m2','ses_abc123',1002,1100,
622           '{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/proj","root":"/tmp/proj"},"cost":0.01,"tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"claude-sonnet-4-6","providerID":"anthropic","time":{"created":1002,"completed":1100},"finish":"stop"}');
623        INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
624          ('p1','m1','ses_abc123',1001,1001,'{"type":"text","text":"make a pickle"}'),
625          ('p2','m2','ses_abc123',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
626          ('p3','m2','ses_abc123',1005,1005,'{"type":"tool","tool":"write","callID":"c1","state":{"status":"completed","input":{"filePath":"/tmp/proj/main.cpp","content":"int main(){}\n"},"output":"wrote","title":"Write","metadata":{"bytes":13},"time":{"start":1005,"end":1006}}}'),
627          ('p4','m2','ses_abc123',1007,1007,'{"type":"text","text":"done"}'),
628          ('p5','m2','ses_abc123',1010,1010,'{"type":"step-finish","reason":"stop","snapshot":"snap_b","tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0.01}');
629    "#;
630
631    #[test]
632    fn derive_basic_shape() {
633        let (_t, mgr, resolver) = fixture(BASIC_SQL);
634        let s = mgr.read_session("ses_abc123").unwrap();
635        let p = derive_path_with_resolver(
636            &s,
637            &DeriveConfig {
638                no_snapshot_diffs: true,
639                ..Default::default()
640            },
641            &resolver,
642        );
643
644        assert!(p.path.id.starts_with("path-opencode-"));
645        assert_eq!(p.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
646        assert_eq!(
647            p.path.base.as_ref().unwrap().ref_str.as_deref(),
648            Some("proj_sha")
649        );
650        // 2 messages → 2 steps.
651        assert_eq!(p.steps.len(), 2);
652        // Head matches last step.
653        assert_eq!(p.path.head, p.steps.last().unwrap().step.id);
654    }
655
656    #[test]
657    fn derive_validates() {
658        let (_t, mgr, resolver) = fixture(BASIC_SQL);
659        let s = mgr.read_session("ses_abc123").unwrap();
660        let p = derive_path_with_resolver(
661            &s,
662            &DeriveConfig {
663                no_snapshot_diffs: true,
664                ..Default::default()
665            },
666            &resolver,
667        );
668        let doc = Document::Path(p);
669        let json = doc.to_json().unwrap();
670        let parsed = Document::from_json(&json).unwrap();
671        if let Document::Path(pp) = parsed {
672            let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
673            assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
674        } else {
675            panic!("expected Path");
676        }
677    }
678
679    #[test]
680    fn derive_actors_populated() {
681        let (_t, mgr, resolver) = fixture(BASIC_SQL);
682        let s = mgr.read_session("ses_abc123").unwrap();
683        let p = derive_path_with_resolver(
684            &s,
685            &DeriveConfig {
686                no_snapshot_diffs: true,
687                ..Default::default()
688            },
689            &resolver,
690        );
691        let actors = p.meta.as_ref().unwrap().actors.as_ref().unwrap();
692        assert!(actors.contains_key("human:user"));
693        assert!(actors.contains_key("agent:claude-sonnet-4-6"));
694    }
695
696    #[test]
697    fn derive_fallback_file_artifact_from_tool() {
698        let (_t, mgr, resolver) = fixture(BASIC_SQL);
699        let s = mgr.read_session("ses_abc123").unwrap();
700        // With no_snapshot_diffs, derive uses the tool-input fallback
701        // to record which files were touched.
702        let p = derive_path_with_resolver(
703            &s,
704            &DeriveConfig {
705                no_snapshot_diffs: true,
706                ..Default::default()
707            },
708            &resolver,
709        );
710        let file_step = p
711            .steps
712            .iter()
713            .find(|s| s.change.contains_key("/tmp/proj/main.cpp"))
714            .expect("file artifact missing");
715        let change = &file_step.change["/tmp/proj/main.cpp"];
716        assert!(
717            change.raw.is_none(),
718            "no snapshot repo → no raw perspective"
719        );
720        assert_eq!(
721            change.structural.as_ref().unwrap().change_type,
722            "opencode.add"
723        );
724    }
725
726    #[test]
727    fn derive_uses_snapshot_git_when_available() {
728        // Build a real snapshot git repo on disk with two trees (before
729        // and after) and check that derive populates the raw unified diff.
730        let (_t, mgr, resolver) = fixture(BASIC_SQL);
731        let session = mgr.read_session("ses_abc123").unwrap();
732
733        let gitdir = resolver
734            .snapshot_gitdir(&session.project_id, &session.directory)
735            .unwrap();
736        fs::create_dir_all(&gitdir).unwrap();
737        let repo = git2::Repository::init_bare(&gitdir).unwrap();
738
739        // Build "before" tree with only a README.
740        let before_tree = {
741            let mut tb = repo.treebuilder(None).unwrap();
742            let blob = repo.blob(b"hello\n").unwrap();
743            tb.insert("README", blob, 0o100644).unwrap();
744            tb.write().unwrap()
745        };
746        // Build "after" tree with README + main.cpp.
747        let after_tree = {
748            let mut tb = repo.treebuilder(None).unwrap();
749            let readme = repo.blob(b"hello\n").unwrap();
750            tb.insert("README", readme, 0o100644).unwrap();
751            let main = repo.blob(b"int main(){ return 0; }\n").unwrap();
752            tb.insert("main.cpp", main, 0o100644).unwrap();
753            tb.write().unwrap()
754        };
755
756        // Rewrite the session's snapshot SHAs in the DB to point at
757        // these real trees. Easier: point snap_a/snap_b at them by
758        // writing refs.
759        repo.reference("refs/snapshots/snap_a", before_tree, true, "before")
760            .unwrap();
761        repo.reference("refs/snapshots/snap_b", after_tree, true, "after")
762            .unwrap();
763
764        // Edit the SQLite to replace snap_a/snap_b part data with
765        // strings that git2's revparse can resolve directly. Use the
766        // raw tree SHA hex strings.
767        let conn = rusqlite::Connection::open(resolver.db_path().unwrap()).unwrap();
768        conn.execute(
769            "UPDATE part SET data = REPLACE(data, 'snap_a', ?1) WHERE id = 'p2'",
770            rusqlite::params![before_tree.to_string()],
771        )
772        .unwrap();
773        conn.execute(
774            "UPDATE part SET data = REPLACE(data, 'snap_b', ?1) WHERE id = 'p5'",
775            rusqlite::params![after_tree.to_string()],
776        )
777        .unwrap();
778        drop(conn);
779
780        let session = mgr.read_session("ses_abc123").unwrap();
781        let p = derive_path_with_resolver(&session, &DeriveConfig::default(), &resolver);
782
783        let file_step = p
784            .steps
785            .iter()
786            .find(|s| s.change.contains_key("main.cpp"))
787            .expect("main.cpp artifact missing");
788        let change = &file_step.change["main.cpp"];
789        assert!(
790            change.raw.is_some(),
791            "raw unified diff should be populated from the snapshot repo"
792        );
793        assert!(
794            change
795                .raw
796                .as_ref()
797                .unwrap()
798                .contains("+int main(){ return 0; }"),
799            "diff must include the new content"
800        );
801        assert_eq!(
802            change.structural.as_ref().unwrap().change_type,
803            "opencode.add"
804        );
805    }
806}