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            group_id: None,
290            role: Role::User,
291            timestamp: millis_to_iso(msg.time_created),
292            text,
293            thinking: None,
294            tool_uses: Vec::new(),
295            model: None,
296            stop_reason: None,
297            token_usage: None,
298            attributed_token_usage: None,
299            environment,
300            delegations: Vec::new(),
301            file_mutations: Vec::new(),
302        });
303    }
304
305    fn handle_assistant_message(&mut self, msg: &Message, a: &AssistantMessage) {
306        let mut text_chunks: Vec<String> = Vec::new();
307        let mut thinking_chunks: Vec<String> = Vec::new();
308        let mut tool_uses: Vec<ToolInvocation> = Vec::new();
309        let mut snapshots: Vec<String> = Vec::new();
310        let mut delegations: Vec<DelegatedWork> = Vec::new();
311        let mut step_usage = TokenUsage::default();
312        let mut step_usage_set = false;
313        let mut stop_reason: Option<String> = None;
314
315        for p in &msg.parts {
316            match &p.data {
317                PartData::Text(t) => {
318                    if !t.text.is_empty() {
319                        text_chunks.push(t.text.clone());
320                    }
321                }
322                PartData::Reasoning(r) => {
323                    if !r.text.is_empty() {
324                        thinking_chunks.push(r.text.clone());
325                    }
326                }
327                PartData::Tool(tp) => {
328                    tool_uses.push(to_invocation(
329                        tp,
330                        &mut self.files_changed_order,
331                        &mut self.files_changed_seen,
332                    ));
333                }
334                PartData::StepStart(s) => {
335                    if let Some(sh) = &s.snapshot
336                        && snapshots.last().is_none_or(|l| l != sh)
337                    {
338                        snapshots.push(sh.clone());
339                    }
340                }
341                PartData::StepFinish(sf) => {
342                    if let Some(sh) = &sf.snapshot
343                        && snapshots.last().is_none_or(|l| l != sh)
344                    {
345                        snapshots.push(sh.clone());
346                    }
347                    accumulate_tokens(&mut step_usage, &sf.tokens);
348                    step_usage_set = true;
349                    stop_reason = Some(sf.reason.clone());
350                }
351                PartData::Snapshot(s) => {
352                    if snapshots.last().is_none_or(|l| l != &s.snapshot) {
353                        snapshots.push(s.snapshot.clone());
354                    }
355                }
356                PartData::Patch(pp) => {
357                    for f in &pp.files {
358                        if self.files_changed_seen.insert(f.clone()) {
359                            self.files_changed_order.push(f.clone());
360                        }
361                    }
362                }
363                PartData::Subtask(st) => {
364                    delegations.push(DelegatedWork {
365                        agent_id: st.agent.clone(),
366                        prompt: st.prompt.clone(),
367                        turns: Vec::new(),
368                        result: None,
369                    });
370                }
371                PartData::File(f) => {
372                    self.events.push(ConversationEvent {
373                        id: format!("file-{}", p.id),
374                        timestamp: millis_to_iso(p.time_created),
375                        parent_id: Some(msg.id.clone()),
376                        event_type: "part.file".into(),
377                        data: to_data_map(&serde_json::to_value(f).unwrap_or(Value::Null)),
378                    });
379                }
380                PartData::Agent(ag) => {
381                    self.events.push(ConversationEvent {
382                        id: format!("agent-{}", p.id),
383                        timestamp: millis_to_iso(p.time_created),
384                        parent_id: Some(msg.id.clone()),
385                        event_type: "part.agent".into(),
386                        data: to_data_map(&serde_json::to_value(ag).unwrap_or(Value::Null)),
387                    });
388                }
389                PartData::Retry(r) => {
390                    self.events.push(ConversationEvent {
391                        id: format!("retry-{}", p.id),
392                        timestamp: millis_to_iso(p.time_created),
393                        parent_id: Some(msg.id.clone()),
394                        event_type: "part.retry".into(),
395                        data: to_data_map(&serde_json::to_value(r).unwrap_or(Value::Null)),
396                    });
397                }
398                PartData::Compaction(c) => {
399                    self.events.push(ConversationEvent {
400                        id: format!("compaction-{}", p.id),
401                        timestamp: millis_to_iso(p.time_created),
402                        parent_id: Some(msg.id.clone()),
403                        event_type: "part.compaction".into(),
404                        data: to_data_map(&serde_json::to_value(c).unwrap_or(Value::Null)),
405                    });
406                }
407                PartData::Unknown => {
408                    self.events.push(ConversationEvent {
409                        id: format!("unknown-{}", p.id),
410                        timestamp: millis_to_iso(p.time_created),
411                        parent_id: Some(msg.id.clone()),
412                        event_type: "part.unknown".into(),
413                        data: HashMap::new(),
414                    });
415                }
416            }
417        }
418
419        // Prefer step-summed tokens over the message-level snapshot —
420        // the step deltas capture the real per-step work. Absent or
421        // all-zero counters mean "spend unknown", not "a zero-cost API
422        // call": decode to None, never Some(zeros) (foreign-source
423        // projections write zero placeholders into required fields).
424        let token_usage = if step_usage_set && !is_usage_zero(&step_usage) {
425            Some(step_usage.clone())
426        } else {
427            let u = tokens_to_convo(&a.tokens);
428            if is_usage_zero(&u) { None } else { Some(u) }
429        };
430
431        if let Some(u) = token_usage.as_ref() {
432            accumulate_total(&mut self.total_usage, u);
433            self.total_usage_set = true;
434        }
435
436        let environment = Some(EnvironmentSnapshot {
437            working_dir: Some(a.path.cwd.to_string_lossy().to_string()),
438            vcs_branch: None,
439            vcs_revision: None,
440        });
441
442        // Compute `file_mutations` for this turn inline:
443        //   1. If we have a snapshot repo AND a snapshot pair (prev_after,
444        //      this turn's last snapshot), walk the git2 tree↔tree diff
445        //      and add a FileMutation per touched file.
446        //   2. For any file-write tool whose path wasn't covered by the
447        //      snapshot diff, add a tool-input-derived FileMutation
448        //      (catches gitignored paths and the no-repo case).
449        let file_mutations = self.compute_turn_mutations(&snapshots, &tool_uses);
450
451        self.turns.push(Turn {
452            id: msg.id.clone(),
453            parent_id: if a.parent_id.is_empty() {
454                None
455            } else {
456                Some(a.parent_id.clone())
457            },
458            group_id: None,
459            role: Role::Assistant,
460            timestamp: millis_to_iso(msg.time_created),
461            text: text_chunks.join("\n\n"),
462            thinking: if thinking_chunks.is_empty() {
463                None
464            } else {
465                Some(thinking_chunks.join("\n\n"))
466            },
467            tool_uses,
468            model: if a.model_id.is_empty() {
469                None
470            } else {
471                Some(a.model_id.clone())
472            },
473            stop_reason: stop_reason.or_else(|| a.finish.clone()),
474            token_usage,
475            attributed_token_usage: None,
476            environment,
477            delegations,
478            file_mutations,
479        });
480    }
481
482    fn compute_turn_mutations(
483        &mut self,
484        snapshots: &[String],
485        tool_uses: &[ToolInvocation],
486    ) -> Vec<FileMutation> {
487        let mut out: Vec<FileMutation> = Vec::new();
488        let mut covered: std::collections::HashSet<String> = std::collections::HashSet::new();
489
490        // Snapshot diff (when repo + pair available).
491        if let (Some(repo), Some(first), Some(last)) = (
492            self.snapshot_repo.as_ref(),
493            snapshots.first(),
494            snapshots.last(),
495        ) {
496            let before = self
497                .prev_snapshot_after
498                .clone()
499                .unwrap_or_else(|| first.clone());
500            let after = last.clone();
501            self.prev_snapshot_after = Some(after.clone());
502            if before != after {
503                match diff_trees(repo, &before, &after) {
504                    Ok(mutations) => {
505                        for fm in mutations {
506                            covered.insert(fm.path.clone());
507                            out.push(fm);
508                        }
509                    }
510                    Err(e) => {
511                        eprintln!(
512                            "Warning: snapshot diff {}..{} failed: {}",
513                            &before[..before.len().min(8)],
514                            &after[..after.len().min(8)],
515                            e
516                        );
517                    }
518                }
519            }
520        } else if let Some(last) = snapshots.last() {
521            // Track even when we can't diff, so subsequent turns still
522            // chain off the right `before`.
523            self.prev_snapshot_after = Some(last.clone());
524        }
525
526        // Tool-input fallback for file-write tools whose paths aren't
527        // already covered by a snapshot-diff mutation.
528        for tu in tool_uses {
529            let Some(path) = tool_input_file_path(tu) else {
530                continue;
531            };
532            if covered.contains(&path) {
533                continue;
534            }
535            covered.insert(path.clone());
536            out.push(FileMutation {
537                path,
538                tool_id: Some(tu.id.clone()),
539                operation: Some(tool_to_operation(&tu.name).to_string()),
540                ..Default::default()
541            });
542        }
543
544        out
545    }
546}
547
548fn concat_text_parts(parts: &[Part]) -> String {
549    let mut chunks = Vec::new();
550    for p in parts {
551        if let PartData::Text(t) = &p.data
552            && !t.text.is_empty()
553            && !t.ignored.unwrap_or(false)
554        {
555            chunks.push(t.text.clone());
556        }
557    }
558    chunks.join("\n\n")
559}
560
561fn to_invocation(
562    tp: &crate::types::ToolPart,
563    files_changed_order: &mut Vec<String>,
564    files_changed_seen: &mut std::collections::HashSet<String>,
565) -> ToolInvocation {
566    let input = tp.state.input().cloned().unwrap_or(Value::Null);
567    let result = match &tp.state {
568        ToolState::Completed(c) => Some(ToolResult {
569            content: c.output.clone(),
570            is_error: false,
571        }),
572        ToolState::Error(e) => Some(ToolResult {
573            content: e.error.clone(),
574            is_error: true,
575        }),
576        _ => None,
577    };
578
579    // Opportunistically collect files-changed from tool inputs.
580    if matches!(tp.tool.as_str(), "edit" | "write" | "multiedit" | "patch")
581        && let Some(path) = input
582            .get("filePath")
583            .or_else(|| input.get("file_path"))
584            .or_else(|| input.get("path"))
585            .and_then(|v| v.as_str())
586        && files_changed_seen.insert(path.to_string())
587    {
588        files_changed_order.push(path.to_string());
589    }
590
591    ToolInvocation {
592        id: tp.call_id.clone(),
593        name: tp.tool.clone(),
594        input,
595        result,
596        category: tool_category(&tp.tool),
597    }
598}
599
600fn accumulate_tokens(total: &mut TokenUsage, step: &Tokens) {
601    add_u32(&mut total.input_tokens, step.input as u32);
602    // opencode reports `reasoning` as a SEPARATE additive category
603    // (`total == input + output + reasoning + cache`), unlike Claude/OpenAI
604    // where reasoning is already inside `output`. Fold it into output_tokens
605    // so the IR's `output` means "all generated tokens" consistently and the
606    // session total isn't under-counted.
607    add_u32(&mut total.output_tokens, (step.output + step.reasoning) as u32);
608    add_u32(&mut total.cache_read_tokens, step.cache.read as u32);
609    add_u32(&mut total.cache_write_tokens, step.cache.write as u32);
610    // Memoize the reasoning slice we just folded into output. It's
611    // INFORMATIONAL (never summed into the total — output already counts
612    // it); the invariant is Σ(inner) = reasoning ≤ output. Accumulates
613    // across step-finish parts exactly like output does.
614    add_reasoning_breakdown(total, step.reasoning as u32);
615}
616
617/// Add `reasoning` to `breakdowns["output"]["reasoning"]`, creating the
618/// nested maps as needed. No-op when `reasoning` is 0 so a zero-reasoning
619/// turn keeps an empty `breakdowns` map (omitted from serialization).
620fn add_reasoning_breakdown(usage: &mut TokenUsage, reasoning: u32) {
621    if reasoning == 0 {
622        return;
623    }
624    let slot = usage
625        .breakdowns
626        .entry("output".to_string())
627        .or_default()
628        .entry("reasoning".to_string())
629        .or_insert(0);
630    *slot = slot.saturating_add(reasoning);
631}
632
633fn add_u32(slot: &mut Option<u32>, delta: u32) {
634    if delta == 0 {
635        return;
636    }
637    *slot = Some(slot.unwrap_or(0).saturating_add(delta));
638}
639
640fn tokens_to_convo(t: &Tokens) -> TokenUsage {
641    let mut usage = TokenUsage {
642        input_tokens: if t.input == 0 {
643            None
644        } else {
645            Some(t.input as u32)
646        },
647        // Fold reasoning into output (additive in opencode — see
648        // `accumulate_tokens`).
649        output_tokens: if t.output + t.reasoning == 0 {
650            None
651        } else {
652            Some((t.output + t.reasoning) as u32)
653        },
654        cache_read_tokens: if t.cache.read == 0 {
655            None
656        } else {
657            Some(t.cache.read as u32)
658        },
659        cache_write_tokens: if t.cache.write == 0 {
660            None
661        } else {
662            Some(t.cache.write as u32)
663        },
664        ..Default::default()
665    };
666    // Memoize the reasoning slice folded into output (no-op when 0).
667    add_reasoning_breakdown(&mut usage, t.reasoning as u32);
668    usage
669}
670
671fn is_usage_zero(u: &TokenUsage) -> bool {
672    u.input_tokens.is_none()
673        && u.output_tokens.is_none()
674        && u.cache_read_tokens.is_none()
675        && u.cache_write_tokens.is_none()
676}
677
678fn accumulate_total(total: &mut TokenUsage, delta: &TokenUsage) {
679    if let Some(v) = delta.input_tokens {
680        add_u32(&mut total.input_tokens, v);
681    }
682    if let Some(v) = delta.output_tokens {
683        add_u32(&mut total.output_tokens, v);
684    }
685    if let Some(v) = delta.cache_read_tokens {
686        add_u32(&mut total.cache_read_tokens, v);
687    }
688    if let Some(v) = delta.cache_write_tokens {
689        add_u32(&mut total.cache_write_tokens, v);
690    }
691}
692
693fn millis_to_iso(ms: i64) -> String {
694    Utc.timestamp_millis_opt(ms)
695        .single()
696        .map(|t| t.to_rfc3339())
697        .unwrap_or_else(|| ms.to_string())
698}
699
700fn to_data_map(v: &Value) -> HashMap<String, Value> {
701    match v {
702        Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
703        _ => {
704            let mut m = HashMap::new();
705            m.insert("value".into(), v.clone());
706            m
707        }
708    }
709}
710
711// ── ConversationProvider trait impl ─────────────────────────────────
712
713impl ConversationProvider for OpencodeConvo {
714    fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
715        let metas = self
716            .list_sessions()
717            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
718        Ok(metas.into_iter().map(|m| m.id).collect())
719    }
720
721    fn load_conversation(
722        &self,
723        _project: &str,
724        conversation_id: &str,
725    ) -> toolpath_convo::Result<ConversationView> {
726        let s = self
727            .read_session(conversation_id)
728            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
729        Ok(to_view(&s))
730    }
731
732    fn load_metadata(
733        &self,
734        _project: &str,
735        conversation_id: &str,
736    ) -> toolpath_convo::Result<ConversationMeta> {
737        let m = self
738            .io
739            .read_metadata(conversation_id)
740            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
741        Ok(ConversationMeta {
742            id: m.id,
743            started_at: m.started_at,
744            last_activity: m.last_activity,
745            message_count: m.message_count,
746            file_path: Some(m.directory),
747            predecessor: None,
748            successor: None,
749        })
750    }
751
752    fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
753        let metas = self
754            .list_sessions()
755            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
756        Ok(metas
757            .into_iter()
758            .map(|m| ConversationMeta {
759                id: m.id,
760                started_at: m.started_at,
761                last_activity: m.last_activity,
762                message_count: m.message_count,
763                file_path: Some(m.directory),
764                predecessor: None,
765                successor: None,
766            })
767            .collect())
768    }
769}
770
771// ── Snapshot diff helpers ──────────────────────────────────────────────
772
773fn tool_input_file_path(tu: &ToolInvocation) -> Option<String> {
774    tu.input
775        .get("filePath")
776        .or_else(|| tu.input.get("file_path"))
777        .or_else(|| tu.input.get("path"))
778        .and_then(|v| v.as_str())
779        .map(str::to_string)
780}
781
782fn tool_to_operation(name: &str) -> &'static str {
783    match name {
784        "write" => "add",
785        "edit" | "multiedit" | "patch" => "update",
786        "delete" | "rm" => "delete",
787        _ => "touch",
788    }
789}
790
791fn diff_trees(
792    repo: &git2::Repository,
793    before: &str,
794    after: &str,
795) -> std::result::Result<Vec<FileMutation>, git2::Error> {
796    let before_obj = repo.revparse_single(before)?;
797    let after_obj = repo.revparse_single(after)?;
798    let before_tree = before_obj.peel_to_tree()?;
799    let after_tree = after_obj.peel_to_tree()?;
800
801    let mut opts = git2::DiffOptions::new();
802    opts.context_lines(3);
803    opts.include_ignored(false);
804    opts.ignore_submodules(true);
805    let diff = repo.diff_tree_to_tree(Some(&before_tree), Some(&after_tree), Some(&mut opts))?;
806
807    use std::path::PathBuf;
808    let mut by_path: HashMap<PathBuf, (String, &'static str, Option<PathBuf>)> = HashMap::new();
809
810    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
811        let Some(new_path) = delta.new_file().path() else {
812            if let Some(old) = delta.old_file().path() {
813                let buf = by_path
814                    .entry(old.to_path_buf())
815                    .or_insert_with(|| (String::new(), "delete", None));
816                append_diff_line(&mut buf.0, line);
817            }
818            return true;
819        };
820        let op = classify_delta(&delta);
821        let entry = by_path.entry(new_path.to_path_buf()).or_insert_with(|| {
822            (
823                String::new(),
824                op,
825                delta.old_file().path().map(|p| p.to_path_buf()),
826            )
827        });
828        append_diff_line(&mut entry.0, line);
829        true
830    })?;
831
832    let mut out: Vec<FileMutation> = by_path
833        .into_iter()
834        .map(|(path, (raw_diff, op, old_path))| FileMutation {
835            path: path.to_string_lossy().into_owned(),
836            tool_id: None,
837            operation: Some(op.to_string()),
838            raw_diff: if raw_diff.is_empty() {
839                None
840            } else {
841                Some(raw_diff)
842            },
843            before: None,
844            after: None,
845            rename_to: if op == "rename" {
846                old_path.map(|p| p.to_string_lossy().into_owned())
847            } else {
848                None
849            },
850        })
851        .collect();
852    out.sort_by(|a, b| a.path.cmp(&b.path));
853    Ok(out)
854}
855
856fn classify_delta(delta: &git2::DiffDelta) -> &'static str {
857    use git2::Delta;
858    match delta.status() {
859        Delta::Added => "add",
860        Delta::Deleted => "delete",
861        Delta::Modified => "update",
862        Delta::Renamed => "rename",
863        Delta::Copied => "copy",
864        Delta::Typechange => "update",
865        _ => "update",
866    }
867}
868
869fn append_diff_line(buf: &mut String, line: git2::DiffLine<'_>) {
870    use git2::DiffLineType;
871    let prefix = match line.origin_value() {
872        DiffLineType::Context => " ",
873        DiffLineType::Addition => "+",
874        DiffLineType::Deletion => "-",
875        DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => "",
876        _ => "",
877    };
878    buf.push_str(prefix);
879    if let Ok(s) = std::str::from_utf8(line.content()) {
880        buf.push_str(s);
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use rusqlite::Connection;
888    use std::fs;
889    use tempfile::TempDir;
890
891    fn setup(body_sql: &str) -> (TempDir, OpencodeConvo) {
892        let temp = TempDir::new().unwrap();
893        let data = temp.path().join(".local/share/opencode");
894        fs::create_dir_all(&data).unwrap();
895        let conn = Connection::open(data.join("opencode.db")).unwrap();
896        conn.execute_batch(&format!(
897            r#"
898            CREATE TABLE project (
899              id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
900              icon_url text, icon_color text,
901              time_created integer NOT NULL, time_updated integer NOT NULL,
902              time_initialized integer, sandboxes text NOT NULL, commands text
903            );
904            CREATE TABLE session (
905              id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
906              slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
907              version text NOT NULL, share_url text,
908              summary_additions integer, summary_deletions integer,
909              summary_files integer, summary_diffs text, revert text, permission text,
910              time_created integer NOT NULL, time_updated integer NOT NULL,
911              time_compacting integer, time_archived integer, workspace_id text
912            );
913            CREATE TABLE message (
914              id text PRIMARY KEY, session_id text NOT NULL,
915              time_created integer NOT NULL, time_updated integer NOT NULL,
916              data text NOT NULL
917            );
918            CREATE TABLE part (
919              id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
920              time_created integer NOT NULL, time_updated integer NOT NULL,
921              data text NOT NULL
922            );
923            {body_sql}
924        "#
925        ))
926        .unwrap();
927        drop(conn);
928        let resolver = PathResolver::new()
929            .with_home(temp.path())
930            .with_data_dir(&data);
931        (temp, OpencodeConvo::with_resolver(resolver))
932    }
933
934    const BASIC_SQL: &str = r#"
935        INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
936          VALUES ('proj', '/tmp/proj', 1000, 3000, '[]');
937        INSERT INTO session (id, project_id, slug, directory, title, version,
938                             time_created, time_updated)
939          VALUES ('ses_x', 'proj', 'slug', '/tmp/proj', 'T', '1.3.10', 1000, 3000);
940        INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
941          ('m1','ses_x',1001,1001,
942           '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
943          ('m2','ses_x',1002,1100,
944           '{"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"}');
945        INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
946          ('p1','m1','ses_x',1001,1001,'{"type":"text","text":"make a pickle"}'),
947          ('p2','m2','ses_x',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
948          ('p3','m2','ses_x',1003,1003,'{"type":"reasoning","text":"I should write main.cpp","time":{"start":1003,"end":1004}}'),
949          ('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}}}'),
950          ('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}}}'),
951          ('p6','m2','ses_x',1009,1009,'{"type":"text","text":"done!"}'),
952          ('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}');
953    "#;
954
955    #[test]
956    fn basic_view_shape() {
957        let (_t, mgr) = setup(BASIC_SQL);
958        let s = mgr.read_session("ses_x").unwrap();
959        let view = to_view(&s);
960
961        assert_eq!(view.id, "ses_x");
962        assert_eq!(view.provider_id.as_deref(), Some("opencode"));
963        assert_eq!(view.turns.len(), 2);
964        assert_eq!(view.turns[0].role, Role::User);
965        assert_eq!(view.turns[0].text, "make a pickle");
966        assert_eq!(view.turns[1].role, Role::Assistant);
967        assert_eq!(view.turns[1].text, "done!");
968        assert_eq!(
969            view.turns[1].thinking.as_deref(),
970            Some("I should write main.cpp")
971        );
972    }
973
974    #[test]
975    fn tool_invocations_paired() {
976        let (_t, mgr) = setup(BASIC_SQL);
977        let view = to_view(&mgr.read_session("ses_x").unwrap());
978        let assistant = &view.turns[1];
979        assert_eq!(assistant.tool_uses.len(), 2);
980        let bash = &assistant.tool_uses[0];
981        assert_eq!(bash.name, "bash");
982        assert_eq!(bash.category, Some(ToolCategory::Shell));
983        assert_eq!(bash.result.as_ref().unwrap().content, "files\n");
984        let write = &assistant.tool_uses[1];
985        assert_eq!(write.name, "write");
986        assert_eq!(write.category, Some(ToolCategory::FileWrite));
987    }
988
989    #[test]
990    fn files_changed_from_tool_input() {
991        let (_t, mgr) = setup(BASIC_SQL);
992        let view = to_view(&mgr.read_session("ses_x").unwrap());
993        assert_eq!(view.files_changed, vec!["/tmp/proj/main.cpp".to_string()]);
994    }
995
996    #[test]
997    fn step_finish_drives_token_usage() {
998        let (_t, mgr) = setup(BASIC_SQL);
999        let view = to_view(&mgr.read_session("ses_x").unwrap());
1000        let u = view.turns[1].token_usage.as_ref().unwrap();
1001        assert_eq!(u.input_tokens, Some(100));
1002        // output (20) + reasoning (5): opencode reports reasoning as a
1003        // separate additive category, folded into output here.
1004        assert_eq!(u.output_tokens, Some(25));
1005        assert_eq!(u.cache_read_tokens, Some(10));
1006
1007        // The reasoning slice (5) is also memoized under
1008        // breakdowns["output"]["reasoning"] — it's the SAME number folded
1009        // into output, so Σ(inner) = 5 ≤ output (25).
1010        assert_eq!(u.breakdowns.get("output").and_then(|m| m.get("reasoning")), Some(&5u32));
1011
1012        let total = view.total_usage.as_ref().unwrap();
1013        assert_eq!(total.input_tokens, Some(100));
1014        assert_eq!(total.output_tokens, Some(25));
1015    }
1016
1017    #[test]
1018    fn zero_reasoning_yields_no_breakdowns() {
1019        // output present but reasoning 0 → token_usage exists, but no
1020        // breakdowns entry (empty map, omitted from serialization).
1021        let body = r#"
1022            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
1023              VALUES ('p','/p',1,2,'[]');
1024            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1025              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1026            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1027              ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":10,"output":20,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
1028            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1029              ('p1','m','s',1,1,'{"type":"step-finish","reason":"stop","tokens":{"input":10,"output":20,"reasoning":0,"cache":{"read":0,"write":0}}}');
1030        "#;
1031        let (_t, mgr) = setup(body);
1032        let view = to_view(&mgr.read_session("s").unwrap());
1033        let u = view.turns[0].token_usage.as_ref().unwrap();
1034        assert_eq!(u.output_tokens, Some(20));
1035        assert!(u.breakdowns.is_empty());
1036    }
1037
1038    #[test]
1039    fn reasoning_accumulates_across_step_finishes() {
1040        // Two step-finish parts in one turn: reasoning 5 then 7 → output
1041        // total folds 12, and breakdowns["output"]["reasoning"] == 12.
1042        let body = r#"
1043            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
1044              VALUES ('p','/p',1,2,'[]');
1045            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1046              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1047            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1048              ('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}}');
1049            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1050              ('p1','m','s',1,1,'{"type":"step-finish","reason":"tool-calls","tokens":{"input":10,"output":20,"reasoning":5,"cache":{"read":0,"write":0}}}'),
1051              ('p2','m','s',2,2,'{"type":"step-finish","reason":"stop","tokens":{"input":3,"output":4,"reasoning":7,"cache":{"read":0,"write":0}}}');
1052        "#;
1053        let (_t, mgr) = setup(body);
1054        let view = to_view(&mgr.read_session("s").unwrap());
1055        let u = view.turns[0].token_usage.as_ref().unwrap();
1056        // output total: (20+5) + (4+7) = 36; reasoning slice: 5+7 = 12.
1057        assert_eq!(u.output_tokens, Some(36));
1058        assert_eq!(u.breakdowns.get("output").and_then(|m| m.get("reasoning")), Some(&12u32));
1059    }
1060
1061    #[test]
1062    fn all_zero_usage_yields_none_and_no_breakdowns() {
1063        let body = r#"
1064            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
1065              VALUES ('p','/p',1,2,'[]');
1066            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1067              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1068            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1069              ('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}}');
1070            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1071              ('p1','m','s',1,1,'{"type":"step-finish","reason":"stop","tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}}}');
1072        "#;
1073        let (_t, mgr) = setup(body);
1074        let view = to_view(&mgr.read_session("s").unwrap());
1075        assert!(view.turns[0].token_usage.is_none());
1076    }
1077
1078    #[test]
1079    fn tool_error_becomes_tool_result_error() {
1080        let body = r#"
1081            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
1082              VALUES ('p', '/p', 1, 2, '[]');
1083            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1084              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1085            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1086              ('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}}');
1087            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1088              ('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}}}');
1089        "#;
1090        let (_t, mgr) = setup(body);
1091        let view = to_view(&mgr.read_session("s").unwrap());
1092        let tool = &view.turns[0].tool_uses[0];
1093        let r = tool.result.as_ref().unwrap();
1094        assert!(r.is_error);
1095        assert_eq!(r.content, "exit 1");
1096    }
1097
1098    #[test]
1099    fn compaction_becomes_event() {
1100        let body = r#"
1101            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
1102              VALUES ('p','/p',1,2,'[]');
1103            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1104              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1105            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1106              ('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}}');
1107            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1108              ('p1','m','s',1,1,'{"type":"compaction","auto":true,"overflow":false}');
1109        "#;
1110        let (_t, mgr) = setup(body);
1111        let view = to_view(&mgr.read_session("s").unwrap());
1112        assert!(
1113            view.events
1114                .iter()
1115                .any(|e| e.event_type == "part.compaction")
1116        );
1117    }
1118
1119    #[test]
1120    fn unknown_part_type_becomes_event() {
1121        let body = r#"
1122            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('p','/p',1,2,'[]');
1123            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1124              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1125            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1126              ('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}}');
1127            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1128              ('p1','m','s',1,1,'{"type":"future-thing","foo":"bar"}');
1129        "#;
1130        let (_t, mgr) = setup(body);
1131        let view = to_view(&mgr.read_session("s").unwrap());
1132        assert!(view.events.iter().any(|e| e.event_type == "part.unknown"));
1133    }
1134
1135    #[test]
1136    fn tool_category_mapping() {
1137        assert_eq!(tool_category("bash"), Some(ToolCategory::Shell));
1138        assert_eq!(tool_category("edit"), Some(ToolCategory::FileWrite));
1139        assert_eq!(tool_category("write"), Some(ToolCategory::FileWrite));
1140        assert_eq!(tool_category("read"), Some(ToolCategory::FileRead));
1141        assert_eq!(tool_category("grep"), Some(ToolCategory::FileSearch));
1142        assert_eq!(tool_category("webfetch"), Some(ToolCategory::Network));
1143        assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
1144        assert_eq!(tool_category("mcp__x__y"), None);
1145    }
1146
1147    #[test]
1148    fn provider_trait_list_and_load() {
1149        let (_t, mgr) = setup(BASIC_SQL);
1150        let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1151        assert_eq!(ids, vec!["ses_x".to_string()]);
1152        let v = ConversationProvider::load_conversation(&mgr, "", "ses_x").unwrap();
1153        assert_eq!(v.turns.len(), 2);
1154    }
1155}