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