Skip to main content

toolpath_opencode/
provider.rs

1//! Implementation of `toolpath-convo` traits for opencode sessions.
2//!
3//! Unlike Codex's streaming event model, opencode's parts are
4//! self-contained: each [`crate::types::ToolPart`] already carries both
5//! the tool input and the tool output/error in its [`ToolState`]. So the
6//! mapping is mostly a direct translation per part, with minimal
7//! cross-part assembly:
8//!
9//! 1. For each user [`Message`], emit a [`Turn`] with `role: User`
10//!    whose `text` is the concatenation of the message's text parts.
11//! 2. For each assistant [`Message`], emit a [`Turn`] with
12//!    `role: Assistant`:
13//!    - `text` ← concatenation of its `text` parts.
14//!    - `thinking` ← concatenation of its `reasoning` parts.
15//!    - `tool_uses` ← one [`ToolInvocation`] per `tool` part.
16//!    - `token_usage` ← summed across all `step-finish` parts (each
17//!      is a per-step delta). Falls back to the message-level
18//!      `tokens` field if no step-finish parts exist.
19//!    - `extra["opencode"]["snapshots"]` ← ordered list of snapshot
20//!      SHAs from `step-start`/`step-finish`/`snapshot` parts, used
21//!      by the derive layer to fetch file diffs.
22//!    - `extra["opencode"]["patches"]` ← any `patch` parts (their
23//!      `{hash, files}` records).
24//! 3. Non-turn parts land in `ConversationView.events`:
25//!    `compaction`, `retry`, unknown types.
26//! 4. `subtask` parts are captured on the turn's `delegations`
27//!    (empty-turn list — the sub-agent's own session lives under
28//!    its own id, linked by `session.parent_id`).
29
30use chrono::{TimeZone, Utc};
31use serde_json::Value;
32use std::collections::HashMap;
33
34use crate::error::Result;
35use crate::io::ConvoIO;
36use crate::paths::PathResolver;
37use crate::types::{
38    AssistantMessage, Message, MessageData, Part, PartData, Session, SessionMetadata, Tokens,
39    ToolState, UserMessage,
40};
41use toolpath_convo::{
42    ConversationEvent, ConversationMeta, ConversationProvider, ConversationView,
43    ConvoError as ConvoTraitError, DelegatedWork, EnvironmentSnapshot, FileMutation, ProducerInfo,
44    Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
45};
46
47/// Provider for opencode sessions.
48#[derive(Default)]
49pub struct OpencodeConvo {
50    io: ConvoIO,
51}
52
53impl OpencodeConvo {
54    pub fn new() -> Self {
55        Self { io: ConvoIO::new() }
56    }
57
58    pub fn with_resolver(resolver: PathResolver) -> Self {
59        Self {
60            io: ConvoIO::with_resolver(resolver),
61        }
62    }
63
64    pub fn io(&self) -> &ConvoIO {
65        &self.io
66    }
67
68    pub fn resolver(&self) -> &PathResolver {
69        self.io.resolver()
70    }
71
72    pub fn read_session(&self, session_id: &str) -> Result<Session> {
73        self.io.read_session(session_id)
74    }
75
76    pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
77        self.io.list_session_metadata(None)
78    }
79
80    pub fn most_recent_session(&self) -> Result<Option<Session>> {
81        let metas = self.list_sessions()?;
82        match metas.first() {
83            Some(m) => Ok(Some(self.read_session(&m.id)?)),
84            None => Ok(None),
85        }
86    }
87
88    /// Read every session. Expensive on large histories.
89    pub fn read_all_sessions(&self) -> Result<Vec<Session>> {
90        let metas = self.list_sessions()?;
91        let mut out = Vec::with_capacity(metas.len());
92        for m in metas {
93            match self.read_session(&m.id) {
94                Ok(s) => out.push(s),
95                Err(e) => eprintln!("Warning: could not read session {}: {}", m.id, e),
96            }
97        }
98        Ok(out)
99    }
100}
101
102// ── Tool classification ─────────────────────────────────────────────
103
104/// Map an opencode tool name to toolpath's category ontology.
105pub fn tool_category(name: &str) -> Option<ToolCategory> {
106    match name {
107        "read" | "list" | "view" | "ls" => Some(ToolCategory::FileRead),
108        "glob" | "grep" | "search" => Some(ToolCategory::FileSearch),
109        "write" | "edit" | "multiedit" | "patch" | "delete" => Some(ToolCategory::FileWrite),
110        "bash" | "shell" | "exec" | "terminal" => Some(ToolCategory::Shell),
111        "webfetch" | "websearch" | "web_fetch" | "web_search" | "fetch" => {
112            Some(ToolCategory::Network)
113        }
114        "task" | "agent" | "subagent" | "spawn_agent" => Some(ToolCategory::Delegation),
115        _ => {
116            // MCP tools use "mcp__<server>__<tool>" convention. We don't
117            // have enough info to categorize those; leave as None.
118            None
119        }
120    }
121}
122
123/// Reverse of `tool_category`: pick opencode's native tool name for a
124/// given category, disambiguating by `args` shape where needed
125/// (e.g. `edit` vs `write`, `glob` vs `grep`).
126pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
127    match category {
128        ToolCategory::Shell => Some("bash"),
129        ToolCategory::FileRead => Some("read"),
130        ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
131            "grep"
132        } else {
133            "glob"
134        }),
135        ToolCategory::FileWrite => Some(if args.get("old_string").is_some() {
136            "edit"
137        } else {
138            "write"
139        }),
140        ToolCategory::Network => Some(if args.get("url").is_some() {
141            "webfetch"
142        } else {
143            "websearch"
144        }),
145        ToolCategory::Delegation => Some("task"),
146    }
147}
148
149// ── Session → ConversationView ─────────────────────────────────────
150
151/// Convert a parsed opencode [`Session`] to the provider-agnostic
152/// [`ConversationView`] shape. File mutations from the snapshot git repo
153/// are not populated; use [`to_view_with_resolver`] when you have one.
154pub fn to_view(session: &Session) -> ConversationView {
155    to_view_with_resolver(session, &PathResolver::new())
156}
157
158/// Like [`to_view`] but opens opencode's snapshot git repository via the
159/// resolver and pre-resolves each turn's file mutations against the
160/// snapshot pair. Falls back silently when the repo isn't present.
161pub fn to_view_with_resolver(session: &Session, resolver: &PathResolver) -> ConversationView {
162    Builder::new(session).build_with_resolver(resolver)
163}
164
165struct Builder<'a> {
166    session: &'a Session,
167    turns: Vec<Turn>,
168    events: Vec<ConversationEvent>,
169    files_changed_order: Vec<String>,
170    files_changed_seen: std::collections::HashSet<String>,
171    total_usage: TokenUsage,
172    total_usage_set: bool,
173    /// Snapshot git repo, when one's been opened by `build_with_resolver`.
174    /// Used inline by `handle_assistant_message` to compute per-turn
175    /// `file_mutations` from snapshot tree↔tree diffs.
176    snapshot_repo: Option<git2::Repository>,
177    /// The previous assistant turn's ending snapshot SHA. Used as the
178    /// `before` of the next turn's snapshot pair so intermediate state
179    /// captures correctly.
180    prev_snapshot_after: Option<String>,
181}
182
183impl<'a> Builder<'a> {
184    fn new(session: &'a Session) -> Self {
185        Self {
186            session,
187            turns: Vec::new(),
188            events: Vec::new(),
189            files_changed_order: Vec::new(),
190            files_changed_seen: std::collections::HashSet::new(),
191            total_usage: TokenUsage::default(),
192            total_usage_set: false,
193            snapshot_repo: None,
194            prev_snapshot_after: None,
195        }
196    }
197
198    fn build_with_resolver(mut self, resolver: &PathResolver) -> ConversationView {
199        let session_version = self.session.version.clone();
200        let session_directory = self.session.directory.to_string_lossy().to_string();
201        let session_project_id = self.session.project_id.clone();
202        self.snapshot_repo = resolver
203            .snapshot_gitdir(&session_project_id, &self.session.directory)
204            .ok()
205            .and_then(|gd| git2::Repository::open(gd).ok());
206
207        let mut view = self.build();
208
209        // Producer + base.
210        view.producer = Some(ProducerInfo {
211            name: "opencode".into(),
212            version: Some(session_version),
213        });
214        view.base = Some(SessionBase {
215            working_dir: Some(session_directory),
216            vcs_revision: Some(session_project_id),
217            vcs_branch: None,
218            vcs_remote: None,
219        });
220
221        // opencode's wire format carries `parentID` on assistant messages
222        // pointing back at the previous user message — that's the natural
223        // chain. User messages legitimately have no parent. Don't
224        // synthesize anything here (would break the matrix idempotence:
225        // user turns would gain a synthetic parent that the projector
226        // can't preserve, causing parent_id graphs to diverge across
227        // iterations).
228
229        // Refresh files_changed so it matches what landed on turns.
230        let mut seen = std::collections::HashSet::new();
231        let mut ordered = Vec::new();
232        for turn in &view.turns {
233            for fm in &turn.file_mutations {
234                if seen.insert(fm.path.clone()) {
235                    ordered.push(fm.path.clone());
236                }
237            }
238        }
239        view.files_changed = ordered;
240        view
241    }
242
243    fn build(mut self) -> ConversationView {
244        for msg in &self.session.messages {
245            match &msg.data {
246                MessageData::User(u) => self.handle_user_message(msg, u),
247                MessageData::Assistant(a) => self.handle_assistant_message(msg, a),
248                MessageData::Other => {
249                    self.events.push(ConversationEvent {
250                        id: format!("msg-other-{}", msg.id),
251                        timestamp: millis_to_iso(msg.time_created),
252                        parent_id: None,
253                        event_type: "message.other".into(),
254                        data: HashMap::new(),
255                    });
256                }
257            }
258        }
259
260        ConversationView {
261            id: self.session.id.clone(),
262            started_at: Utc.timestamp_millis_opt(self.session.time_created).single(),
263            last_activity: Utc.timestamp_millis_opt(self.session.time_updated).single(),
264            turns: self.turns,
265            total_usage: if self.total_usage_set {
266                Some(self.total_usage)
267            } else {
268                None
269            },
270            provider_id: Some("opencode".into()),
271            files_changed: self.files_changed_order,
272            session_ids: vec![self.session.id.clone()],
273            events: self.events,
274            ..Default::default()
275        }
276    }
277
278    fn handle_user_message(&mut self, msg: &Message, _u: &UserMessage) {
279        let text = concat_text_parts(&msg.parts);
280        let environment = Some(EnvironmentSnapshot {
281            working_dir: Some(self.session.directory.to_string_lossy().to_string()),
282            vcs_branch: None,
283            vcs_revision: None,
284        });
285
286        self.turns.push(Turn {
287            id: msg.id.clone(),
288            parent_id: None,
289            role: Role::User,
290            timestamp: millis_to_iso(msg.time_created),
291            text,
292            thinking: None,
293            tool_uses: Vec::new(),
294            model: None,
295            stop_reason: None,
296            token_usage: None,
297            environment,
298            delegations: Vec::new(),
299            file_mutations: Vec::new(),
300        });
301    }
302
303    fn handle_assistant_message(&mut self, msg: &Message, a: &AssistantMessage) {
304        let mut text_chunks: Vec<String> = Vec::new();
305        let mut thinking_chunks: Vec<String> = Vec::new();
306        let mut tool_uses: Vec<ToolInvocation> = Vec::new();
307        let mut snapshots: Vec<String> = Vec::new();
308        let mut delegations: Vec<DelegatedWork> = Vec::new();
309        let mut step_usage = TokenUsage::default();
310        let mut step_usage_set = false;
311        let mut stop_reason: Option<String> = None;
312
313        for p in &msg.parts {
314            match &p.data {
315                PartData::Text(t) => {
316                    if !t.text.is_empty() {
317                        text_chunks.push(t.text.clone());
318                    }
319                }
320                PartData::Reasoning(r) => {
321                    if !r.text.is_empty() {
322                        thinking_chunks.push(r.text.clone());
323                    }
324                }
325                PartData::Tool(tp) => {
326                    tool_uses.push(to_invocation(
327                        tp,
328                        &mut self.files_changed_order,
329                        &mut self.files_changed_seen,
330                    ));
331                }
332                PartData::StepStart(s) => {
333                    if let Some(sh) = &s.snapshot
334                        && snapshots.last().is_none_or(|l| l != sh)
335                    {
336                        snapshots.push(sh.clone());
337                    }
338                }
339                PartData::StepFinish(sf) => {
340                    if let Some(sh) = &sf.snapshot
341                        && snapshots.last().is_none_or(|l| l != sh)
342                    {
343                        snapshots.push(sh.clone());
344                    }
345                    accumulate_tokens(&mut step_usage, &sf.tokens);
346                    step_usage_set = true;
347                    stop_reason = Some(sf.reason.clone());
348                }
349                PartData::Snapshot(s) => {
350                    if snapshots.last().is_none_or(|l| l != &s.snapshot) {
351                        snapshots.push(s.snapshot.clone());
352                    }
353                }
354                PartData::Patch(pp) => {
355                    for f in &pp.files {
356                        if self.files_changed_seen.insert(f.clone()) {
357                            self.files_changed_order.push(f.clone());
358                        }
359                    }
360                }
361                PartData::Subtask(st) => {
362                    delegations.push(DelegatedWork {
363                        agent_id: st.agent.clone(),
364                        prompt: st.prompt.clone(),
365                        turns: Vec::new(),
366                        result: None,
367                    });
368                }
369                PartData::File(f) => {
370                    self.events.push(ConversationEvent {
371                        id: format!("file-{}", p.id),
372                        timestamp: millis_to_iso(p.time_created),
373                        parent_id: Some(msg.id.clone()),
374                        event_type: "part.file".into(),
375                        data: to_data_map(&serde_json::to_value(f).unwrap_or(Value::Null)),
376                    });
377                }
378                PartData::Agent(ag) => {
379                    self.events.push(ConversationEvent {
380                        id: format!("agent-{}", p.id),
381                        timestamp: millis_to_iso(p.time_created),
382                        parent_id: Some(msg.id.clone()),
383                        event_type: "part.agent".into(),
384                        data: to_data_map(&serde_json::to_value(ag).unwrap_or(Value::Null)),
385                    });
386                }
387                PartData::Retry(r) => {
388                    self.events.push(ConversationEvent {
389                        id: format!("retry-{}", p.id),
390                        timestamp: millis_to_iso(p.time_created),
391                        parent_id: Some(msg.id.clone()),
392                        event_type: "part.retry".into(),
393                        data: to_data_map(&serde_json::to_value(r).unwrap_or(Value::Null)),
394                    });
395                }
396                PartData::Compaction(c) => {
397                    self.events.push(ConversationEvent {
398                        id: format!("compaction-{}", p.id),
399                        timestamp: millis_to_iso(p.time_created),
400                        parent_id: Some(msg.id.clone()),
401                        event_type: "part.compaction".into(),
402                        data: to_data_map(&serde_json::to_value(c).unwrap_or(Value::Null)),
403                    });
404                }
405                PartData::Unknown => {
406                    self.events.push(ConversationEvent {
407                        id: format!("unknown-{}", p.id),
408                        timestamp: millis_to_iso(p.time_created),
409                        parent_id: Some(msg.id.clone()),
410                        event_type: "part.unknown".into(),
411                        data: HashMap::new(),
412                    });
413                }
414            }
415        }
416
417        // Prefer step-summed tokens over the message-level snapshot —
418        // the step deltas capture the real per-step work.
419        let token_usage = if step_usage_set {
420            Some(step_usage.clone())
421        } else {
422            let u = tokens_to_convo(&a.tokens);
423            if is_usage_zero(&u) { None } else { Some(u) }
424        };
425
426        if let Some(u) = token_usage.as_ref() {
427            accumulate_total(&mut self.total_usage, u);
428            self.total_usage_set = true;
429        }
430
431        let environment = Some(EnvironmentSnapshot {
432            working_dir: Some(a.path.cwd.to_string_lossy().to_string()),
433            vcs_branch: None,
434            vcs_revision: None,
435        });
436
437        // Compute `file_mutations` for this turn inline:
438        //   1. If we have a snapshot repo AND a snapshot pair (prev_after,
439        //      this turn's last snapshot), walk the git2 tree↔tree diff
440        //      and add a FileMutation per touched file.
441        //   2. For any file-write tool whose path wasn't covered by the
442        //      snapshot diff, add a tool-input-derived FileMutation
443        //      (catches gitignored paths and the no-repo case).
444        let file_mutations = self.compute_turn_mutations(&snapshots, &tool_uses);
445
446        self.turns.push(Turn {
447            id: msg.id.clone(),
448            parent_id: if a.parent_id.is_empty() {
449                None
450            } else {
451                Some(a.parent_id.clone())
452            },
453            role: Role::Assistant,
454            timestamp: millis_to_iso(msg.time_created),
455            text: text_chunks.join("\n\n"),
456            thinking: if thinking_chunks.is_empty() {
457                None
458            } else {
459                Some(thinking_chunks.join("\n\n"))
460            },
461            tool_uses,
462            model: if a.model_id.is_empty() {
463                None
464            } else {
465                Some(a.model_id.clone())
466            },
467            stop_reason: stop_reason.or_else(|| a.finish.clone()),
468            token_usage,
469            environment,
470            delegations,
471            file_mutations,
472        });
473    }
474
475    fn compute_turn_mutations(
476        &mut self,
477        snapshots: &[String],
478        tool_uses: &[ToolInvocation],
479    ) -> Vec<FileMutation> {
480        let mut out: Vec<FileMutation> = Vec::new();
481        let mut covered: std::collections::HashSet<String> = std::collections::HashSet::new();
482
483        // Snapshot diff (when repo + pair available).
484        if let (Some(repo), Some(first), Some(last)) = (
485            self.snapshot_repo.as_ref(),
486            snapshots.first(),
487            snapshots.last(),
488        ) {
489            let before = self
490                .prev_snapshot_after
491                .clone()
492                .unwrap_or_else(|| first.clone());
493            let after = last.clone();
494            self.prev_snapshot_after = Some(after.clone());
495            if before != after {
496                match diff_trees(repo, &before, &after) {
497                    Ok(mutations) => {
498                        for fm in mutations {
499                            covered.insert(fm.path.clone());
500                            out.push(fm);
501                        }
502                    }
503                    Err(e) => {
504                        eprintln!(
505                            "Warning: snapshot diff {}..{} failed: {}",
506                            &before[..before.len().min(8)],
507                            &after[..after.len().min(8)],
508                            e
509                        );
510                    }
511                }
512            }
513        } else if let Some(last) = snapshots.last() {
514            // Track even when we can't diff, so subsequent turns still
515            // chain off the right `before`.
516            self.prev_snapshot_after = Some(last.clone());
517        }
518
519        // Tool-input fallback for file-write tools whose paths aren't
520        // already covered by a snapshot-diff mutation.
521        for tu in tool_uses {
522            let Some(path) = tool_input_file_path(tu) else {
523                continue;
524            };
525            if covered.contains(&path) {
526                continue;
527            }
528            covered.insert(path.clone());
529            out.push(FileMutation {
530                path,
531                tool_id: Some(tu.id.clone()),
532                operation: Some(tool_to_operation(&tu.name).to_string()),
533                ..Default::default()
534            });
535        }
536
537        out
538    }
539}
540
541fn concat_text_parts(parts: &[Part]) -> String {
542    let mut chunks = Vec::new();
543    for p in parts {
544        if let PartData::Text(t) = &p.data
545            && !t.text.is_empty()
546            && !t.ignored.unwrap_or(false)
547        {
548            chunks.push(t.text.clone());
549        }
550    }
551    chunks.join("\n\n")
552}
553
554fn to_invocation(
555    tp: &crate::types::ToolPart,
556    files_changed_order: &mut Vec<String>,
557    files_changed_seen: &mut std::collections::HashSet<String>,
558) -> ToolInvocation {
559    let input = tp.state.input().cloned().unwrap_or(Value::Null);
560    let result = match &tp.state {
561        ToolState::Completed(c) => Some(ToolResult {
562            content: c.output.clone(),
563            is_error: false,
564        }),
565        ToolState::Error(e) => Some(ToolResult {
566            content: e.error.clone(),
567            is_error: true,
568        }),
569        _ => None,
570    };
571
572    // Opportunistically collect files-changed from tool inputs.
573    if matches!(tp.tool.as_str(), "edit" | "write" | "multiedit" | "patch")
574        && let Some(path) = input
575            .get("filePath")
576            .or_else(|| input.get("file_path"))
577            .or_else(|| input.get("path"))
578            .and_then(|v| v.as_str())
579        && files_changed_seen.insert(path.to_string())
580    {
581        files_changed_order.push(path.to_string());
582    }
583
584    ToolInvocation {
585        id: tp.call_id.clone(),
586        name: tp.tool.clone(),
587        input,
588        result,
589        category: tool_category(&tp.tool),
590    }
591}
592
593fn accumulate_tokens(total: &mut TokenUsage, step: &Tokens) {
594    add_u32(&mut total.input_tokens, step.input as u32);
595    add_u32(&mut total.output_tokens, step.output as u32);
596    add_u32(&mut total.cache_read_tokens, step.cache.read as u32);
597    add_u32(&mut total.cache_write_tokens, step.cache.write as u32);
598}
599
600fn add_u32(slot: &mut Option<u32>, delta: u32) {
601    if delta == 0 {
602        return;
603    }
604    *slot = Some(slot.unwrap_or(0).saturating_add(delta));
605}
606
607fn tokens_to_convo(t: &Tokens) -> TokenUsage {
608    TokenUsage {
609        input_tokens: if t.input == 0 {
610            None
611        } else {
612            Some(t.input as u32)
613        },
614        output_tokens: if t.output == 0 {
615            None
616        } else {
617            Some(t.output as u32)
618        },
619        cache_read_tokens: if t.cache.read == 0 {
620            None
621        } else {
622            Some(t.cache.read as u32)
623        },
624        cache_write_tokens: if t.cache.write == 0 {
625            None
626        } else {
627            Some(t.cache.write as u32)
628        },
629    }
630}
631
632fn is_usage_zero(u: &TokenUsage) -> bool {
633    u.input_tokens.is_none()
634        && u.output_tokens.is_none()
635        && u.cache_read_tokens.is_none()
636        && u.cache_write_tokens.is_none()
637}
638
639fn accumulate_total(total: &mut TokenUsage, delta: &TokenUsage) {
640    if let Some(v) = delta.input_tokens {
641        add_u32(&mut total.input_tokens, v);
642    }
643    if let Some(v) = delta.output_tokens {
644        add_u32(&mut total.output_tokens, v);
645    }
646    if let Some(v) = delta.cache_read_tokens {
647        add_u32(&mut total.cache_read_tokens, v);
648    }
649    if let Some(v) = delta.cache_write_tokens {
650        add_u32(&mut total.cache_write_tokens, v);
651    }
652}
653
654fn millis_to_iso(ms: i64) -> String {
655    Utc.timestamp_millis_opt(ms)
656        .single()
657        .map(|t| t.to_rfc3339())
658        .unwrap_or_else(|| ms.to_string())
659}
660
661fn to_data_map(v: &Value) -> HashMap<String, Value> {
662    match v {
663        Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
664        _ => {
665            let mut m = HashMap::new();
666            m.insert("value".into(), v.clone());
667            m
668        }
669    }
670}
671
672// ── ConversationProvider trait impl ─────────────────────────────────
673
674impl ConversationProvider for OpencodeConvo {
675    fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
676        let metas = self
677            .list_sessions()
678            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
679        Ok(metas.into_iter().map(|m| m.id).collect())
680    }
681
682    fn load_conversation(
683        &self,
684        _project: &str,
685        conversation_id: &str,
686    ) -> toolpath_convo::Result<ConversationView> {
687        let s = self
688            .read_session(conversation_id)
689            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
690        Ok(to_view(&s))
691    }
692
693    fn load_metadata(
694        &self,
695        _project: &str,
696        conversation_id: &str,
697    ) -> toolpath_convo::Result<ConversationMeta> {
698        let m = self
699            .io
700            .read_metadata(conversation_id)
701            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
702        Ok(ConversationMeta {
703            id: m.id,
704            started_at: m.started_at,
705            last_activity: m.last_activity,
706            message_count: m.message_count,
707            file_path: Some(m.directory),
708            predecessor: None,
709            successor: None,
710        })
711    }
712
713    fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
714        let metas = self
715            .list_sessions()
716            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
717        Ok(metas
718            .into_iter()
719            .map(|m| ConversationMeta {
720                id: m.id,
721                started_at: m.started_at,
722                last_activity: m.last_activity,
723                message_count: m.message_count,
724                file_path: Some(m.directory),
725                predecessor: None,
726                successor: None,
727            })
728            .collect())
729    }
730}
731
732// ── Snapshot diff helpers ──────────────────────────────────────────────
733
734fn tool_input_file_path(tu: &ToolInvocation) -> Option<String> {
735    tu.input
736        .get("filePath")
737        .or_else(|| tu.input.get("file_path"))
738        .or_else(|| tu.input.get("path"))
739        .and_then(|v| v.as_str())
740        .map(str::to_string)
741}
742
743fn tool_to_operation(name: &str) -> &'static str {
744    match name {
745        "write" => "add",
746        "edit" | "multiedit" | "patch" => "update",
747        "delete" | "rm" => "delete",
748        _ => "touch",
749    }
750}
751
752fn diff_trees(
753    repo: &git2::Repository,
754    before: &str,
755    after: &str,
756) -> std::result::Result<Vec<FileMutation>, git2::Error> {
757    let before_obj = repo.revparse_single(before)?;
758    let after_obj = repo.revparse_single(after)?;
759    let before_tree = before_obj.peel_to_tree()?;
760    let after_tree = after_obj.peel_to_tree()?;
761
762    let mut opts = git2::DiffOptions::new();
763    opts.context_lines(3);
764    opts.include_ignored(false);
765    opts.ignore_submodules(true);
766    let diff = repo.diff_tree_to_tree(Some(&before_tree), Some(&after_tree), Some(&mut opts))?;
767
768    use std::path::PathBuf;
769    let mut by_path: HashMap<PathBuf, (String, &'static str, Option<PathBuf>)> = HashMap::new();
770
771    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
772        let Some(new_path) = delta.new_file().path() else {
773            if let Some(old) = delta.old_file().path() {
774                let buf = by_path
775                    .entry(old.to_path_buf())
776                    .or_insert_with(|| (String::new(), "delete", None));
777                append_diff_line(&mut buf.0, line);
778            }
779            return true;
780        };
781        let op = classify_delta(&delta);
782        let entry = by_path.entry(new_path.to_path_buf()).or_insert_with(|| {
783            (
784                String::new(),
785                op,
786                delta.old_file().path().map(|p| p.to_path_buf()),
787            )
788        });
789        append_diff_line(&mut entry.0, line);
790        true
791    })?;
792
793    let mut out: Vec<FileMutation> = by_path
794        .into_iter()
795        .map(|(path, (raw_diff, op, old_path))| FileMutation {
796            path: path.to_string_lossy().into_owned(),
797            tool_id: None,
798            operation: Some(op.to_string()),
799            raw_diff: if raw_diff.is_empty() {
800                None
801            } else {
802                Some(raw_diff)
803            },
804            before: None,
805            after: None,
806            rename_to: if op == "rename" {
807                old_path.map(|p| p.to_string_lossy().into_owned())
808            } else {
809                None
810            },
811        })
812        .collect();
813    out.sort_by(|a, b| a.path.cmp(&b.path));
814    Ok(out)
815}
816
817fn classify_delta(delta: &git2::DiffDelta) -> &'static str {
818    use git2::Delta;
819    match delta.status() {
820        Delta::Added => "add",
821        Delta::Deleted => "delete",
822        Delta::Modified => "update",
823        Delta::Renamed => "rename",
824        Delta::Copied => "copy",
825        Delta::Typechange => "update",
826        _ => "update",
827    }
828}
829
830fn append_diff_line(buf: &mut String, line: git2::DiffLine<'_>) {
831    use git2::DiffLineType;
832    let prefix = match line.origin_value() {
833        DiffLineType::Context => " ",
834        DiffLineType::Addition => "+",
835        DiffLineType::Deletion => "-",
836        DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => "",
837        _ => "",
838    };
839    buf.push_str(prefix);
840    if let Ok(s) = std::str::from_utf8(line.content()) {
841        buf.push_str(s);
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use rusqlite::Connection;
849    use std::fs;
850    use tempfile::TempDir;
851
852    fn setup(body_sql: &str) -> (TempDir, OpencodeConvo) {
853        let temp = TempDir::new().unwrap();
854        let data = temp.path().join(".local/share/opencode");
855        fs::create_dir_all(&data).unwrap();
856        let conn = Connection::open(data.join("opencode.db")).unwrap();
857        conn.execute_batch(&format!(
858            r#"
859            CREATE TABLE project (
860              id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
861              icon_url text, icon_color text,
862              time_created integer NOT NULL, time_updated integer NOT NULL,
863              time_initialized integer, sandboxes text NOT NULL, commands text
864            );
865            CREATE TABLE session (
866              id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
867              slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
868              version text NOT NULL, share_url text,
869              summary_additions integer, summary_deletions integer,
870              summary_files integer, summary_diffs text, revert text, permission text,
871              time_created integer NOT NULL, time_updated integer NOT NULL,
872              time_compacting integer, time_archived integer, workspace_id text
873            );
874            CREATE TABLE message (
875              id text PRIMARY KEY, session_id text NOT NULL,
876              time_created integer NOT NULL, time_updated integer NOT NULL,
877              data text NOT NULL
878            );
879            CREATE TABLE part (
880              id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
881              time_created integer NOT NULL, time_updated integer NOT NULL,
882              data text NOT NULL
883            );
884            {body_sql}
885        "#
886        ))
887        .unwrap();
888        drop(conn);
889        let resolver = PathResolver::new()
890            .with_home(temp.path())
891            .with_data_dir(&data);
892        (temp, OpencodeConvo::with_resolver(resolver))
893    }
894
895    const BASIC_SQL: &str = r#"
896        INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
897          VALUES ('proj', '/tmp/proj', 1000, 3000, '[]');
898        INSERT INTO session (id, project_id, slug, directory, title, version,
899                             time_created, time_updated)
900          VALUES ('ses_x', 'proj', 'slug', '/tmp/proj', 'T', '1.3.10', 1000, 3000);
901        INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
902          ('m1','ses_x',1001,1001,
903           '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
904          ('m2','ses_x',1002,1100,
905           '{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/proj","root":"/tmp/proj"},"cost":0.01,"tokens":{"input":100,"output":20,"reasoning":5,"cache":{"read":10,"write":0}},"modelID":"claude","providerID":"anthropic","time":{"created":1002,"completed":1100},"finish":"stop"}');
906        INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
907          ('p1','m1','ses_x',1001,1001,'{"type":"text","text":"make a pickle"}'),
908          ('p2','m2','ses_x',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
909          ('p3','m2','ses_x',1003,1003,'{"type":"reasoning","text":"I should write main.cpp","time":{"start":1003,"end":1004}}'),
910          ('p4','m2','ses_x',1005,1005,'{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"files\n","title":"List","metadata":{"exit":0},"time":{"start":1005,"end":1006}}}'),
911          ('p5','m2','ses_x',1007,1007,'{"type":"tool","tool":"write","callID":"call_2","state":{"status":"completed","input":{"filePath":"/tmp/proj/main.cpp","content":"int main(){}\n"},"output":"wrote","title":"Write","metadata":{"bytes":13},"time":{"start":1007,"end":1008}}}'),
912          ('p6','m2','ses_x',1009,1009,'{"type":"text","text":"done!"}'),
913          ('p7','m2','ses_x',1010,1010,'{"type":"step-finish","reason":"stop","snapshot":"snap_b","tokens":{"input":100,"output":20,"reasoning":5,"cache":{"read":10,"write":0}},"cost":0.01}');
914    "#;
915
916    #[test]
917    fn basic_view_shape() {
918        let (_t, mgr) = setup(BASIC_SQL);
919        let s = mgr.read_session("ses_x").unwrap();
920        let view = to_view(&s);
921
922        assert_eq!(view.id, "ses_x");
923        assert_eq!(view.provider_id.as_deref(), Some("opencode"));
924        assert_eq!(view.turns.len(), 2);
925        assert_eq!(view.turns[0].role, Role::User);
926        assert_eq!(view.turns[0].text, "make a pickle");
927        assert_eq!(view.turns[1].role, Role::Assistant);
928        assert_eq!(view.turns[1].text, "done!");
929        assert_eq!(
930            view.turns[1].thinking.as_deref(),
931            Some("I should write main.cpp")
932        );
933    }
934
935    #[test]
936    fn tool_invocations_paired() {
937        let (_t, mgr) = setup(BASIC_SQL);
938        let view = to_view(&mgr.read_session("ses_x").unwrap());
939        let assistant = &view.turns[1];
940        assert_eq!(assistant.tool_uses.len(), 2);
941        let bash = &assistant.tool_uses[0];
942        assert_eq!(bash.name, "bash");
943        assert_eq!(bash.category, Some(ToolCategory::Shell));
944        assert_eq!(bash.result.as_ref().unwrap().content, "files\n");
945        let write = &assistant.tool_uses[1];
946        assert_eq!(write.name, "write");
947        assert_eq!(write.category, Some(ToolCategory::FileWrite));
948    }
949
950    #[test]
951    fn files_changed_from_tool_input() {
952        let (_t, mgr) = setup(BASIC_SQL);
953        let view = to_view(&mgr.read_session("ses_x").unwrap());
954        assert_eq!(view.files_changed, vec!["/tmp/proj/main.cpp".to_string()]);
955    }
956
957    #[test]
958    fn step_finish_drives_token_usage() {
959        let (_t, mgr) = setup(BASIC_SQL);
960        let view = to_view(&mgr.read_session("ses_x").unwrap());
961        let u = view.turns[1].token_usage.as_ref().unwrap();
962        assert_eq!(u.input_tokens, Some(100));
963        assert_eq!(u.output_tokens, Some(20));
964        assert_eq!(u.cache_read_tokens, Some(10));
965
966        let total = view.total_usage.as_ref().unwrap();
967        assert_eq!(total.input_tokens, Some(100));
968        assert_eq!(total.output_tokens, Some(20));
969    }
970
971    #[test]
972    fn tool_error_becomes_tool_result_error() {
973        let body = r#"
974            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
975              VALUES ('p', '/p', 1, 2, '[]');
976            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
977              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
978            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
979              ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
980            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
981              ('p1','m','s',1,1,'{"type":"tool","tool":"bash","callID":"c","state":{"status":"error","input":{"command":"false"},"error":"exit 1","time":{"start":1,"end":2}}}');
982        "#;
983        let (_t, mgr) = setup(body);
984        let view = to_view(&mgr.read_session("s").unwrap());
985        let tool = &view.turns[0].tool_uses[0];
986        let r = tool.result.as_ref().unwrap();
987        assert!(r.is_error);
988        assert_eq!(r.content, "exit 1");
989    }
990
991    #[test]
992    fn compaction_becomes_event() {
993        let body = r#"
994            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
995              VALUES ('p','/p',1,2,'[]');
996            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
997              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
998            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
999              ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
1000            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1001              ('p1','m','s',1,1,'{"type":"compaction","auto":true,"overflow":false}');
1002        "#;
1003        let (_t, mgr) = setup(body);
1004        let view = to_view(&mgr.read_session("s").unwrap());
1005        assert!(
1006            view.events
1007                .iter()
1008                .any(|e| e.event_type == "part.compaction")
1009        );
1010    }
1011
1012    #[test]
1013    fn unknown_part_type_becomes_event() {
1014        let body = r#"
1015            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('p','/p',1,2,'[]');
1016            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1017              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1018            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1019              ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
1020            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1021              ('p1','m','s',1,1,'{"type":"future-thing","foo":"bar"}');
1022        "#;
1023        let (_t, mgr) = setup(body);
1024        let view = to_view(&mgr.read_session("s").unwrap());
1025        assert!(view.events.iter().any(|e| e.event_type == "part.unknown"));
1026    }
1027
1028    #[test]
1029    fn tool_category_mapping() {
1030        assert_eq!(tool_category("bash"), Some(ToolCategory::Shell));
1031        assert_eq!(tool_category("edit"), Some(ToolCategory::FileWrite));
1032        assert_eq!(tool_category("write"), Some(ToolCategory::FileWrite));
1033        assert_eq!(tool_category("read"), Some(ToolCategory::FileRead));
1034        assert_eq!(tool_category("grep"), Some(ToolCategory::FileSearch));
1035        assert_eq!(tool_category("webfetch"), Some(ToolCategory::Network));
1036        assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
1037        assert_eq!(tool_category("mcp__x__y"), None);
1038    }
1039
1040    #[test]
1041    fn provider_trait_list_and_load() {
1042        let (_t, mgr) = setup(BASIC_SQL);
1043        let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1044        assert_eq!(ids, vec!["ses_x".to_string()]);
1045        let v = ConversationProvider::load_conversation(&mgr, "", "ses_x").unwrap();
1046        assert_eq!(v.turns.len(), 2);
1047    }
1048}