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 [`ToolPart`] already carries both the tool
5//! 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::{Map, 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, Role, TokenUsage,
44    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// ── Session → ConversationView ─────────────────────────────────────
124
125/// Convert a parsed opencode [`Session`] to the provider-agnostic
126/// [`ConversationView`] shape.
127pub fn to_view(session: &Session) -> ConversationView {
128    Builder::new(session).build()
129}
130
131struct Builder<'a> {
132    session: &'a Session,
133    turns: Vec<Turn>,
134    events: Vec<ConversationEvent>,
135    files_changed_order: Vec<String>,
136    files_changed_seen: std::collections::HashSet<String>,
137    total_usage: TokenUsage,
138    total_usage_set: bool,
139}
140
141impl<'a> Builder<'a> {
142    fn new(session: &'a Session) -> Self {
143        Self {
144            session,
145            turns: Vec::new(),
146            events: Vec::new(),
147            files_changed_order: Vec::new(),
148            files_changed_seen: std::collections::HashSet::new(),
149            total_usage: TokenUsage::default(),
150            total_usage_set: false,
151        }
152    }
153
154    fn build(mut self) -> ConversationView {
155        for msg in &self.session.messages {
156            match &msg.data {
157                MessageData::User(u) => self.handle_user_message(msg, u),
158                MessageData::Assistant(a) => self.handle_assistant_message(msg, a),
159                MessageData::Other => {
160                    self.events.push(ConversationEvent {
161                        id: format!("msg-other-{}", msg.id),
162                        timestamp: millis_to_iso(msg.time_created),
163                        parent_id: None,
164                        event_type: "message.other".into(),
165                        data: HashMap::new(),
166                    });
167                }
168            }
169        }
170
171        ConversationView {
172            id: self.session.id.clone(),
173            started_at: Utc.timestamp_millis_opt(self.session.time_created).single(),
174            last_activity: Utc.timestamp_millis_opt(self.session.time_updated).single(),
175            turns: self.turns,
176            total_usage: if self.total_usage_set {
177                Some(self.total_usage)
178            } else {
179                None
180            },
181            provider_id: Some("opencode".into()),
182            files_changed: self.files_changed_order,
183            session_ids: vec![self.session.id.clone()],
184            events: self.events,
185        }
186    }
187
188    fn handle_user_message(&mut self, msg: &Message, u: &UserMessage) {
189        let text = concat_text_parts(&msg.parts);
190        let environment = Some(EnvironmentSnapshot {
191            working_dir: Some(self.session.directory.to_string_lossy().to_string()),
192            vcs_branch: None,
193            vcs_revision: None,
194        });
195        let mut extra: HashMap<String, Value> = HashMap::new();
196        let mut opencode_extra = Map::new();
197        opencode_extra.insert("agent".into(), Value::String(u.agent.clone()));
198        opencode_extra.insert(
199            "model".into(),
200            serde_json::to_value(&u.model).unwrap_or(Value::Null),
201        );
202        if let Some(tools) = &u.tools {
203            opencode_extra.insert(
204                "tools".into(),
205                serde_json::to_value(tools).unwrap_or(Value::Null),
206            );
207        }
208        if let Some(system) = &u.system
209            && !system.is_empty()
210        {
211            opencode_extra.insert("system".into(), Value::String(system.clone()));
212        }
213        if !opencode_extra.is_empty() {
214            extra.insert("opencode".into(), Value::Object(opencode_extra));
215        }
216
217        self.turns.push(Turn {
218            id: msg.id.clone(),
219            parent_id: None,
220            role: Role::User,
221            timestamp: millis_to_iso(msg.time_created),
222            text,
223            thinking: None,
224            tool_uses: Vec::new(),
225            model: None,
226            stop_reason: None,
227            token_usage: None,
228            environment,
229            delegations: Vec::new(),
230            extra,
231        });
232    }
233
234    fn handle_assistant_message(&mut self, msg: &Message, a: &AssistantMessage) {
235        let mut text_chunks: Vec<String> = Vec::new();
236        let mut thinking_chunks: Vec<String> = Vec::new();
237        let mut tool_uses: Vec<ToolInvocation> = Vec::new();
238        let mut snapshots: Vec<String> = Vec::new();
239        let mut patches: Vec<Value> = Vec::new();
240        let mut delegations: Vec<DelegatedWork> = Vec::new();
241        let mut step_usage = TokenUsage::default();
242        let mut step_usage_set = false;
243        let mut step_cost_total = 0.0_f64;
244        let mut stop_reason: Option<String> = None;
245
246        for p in &msg.parts {
247            match &p.data {
248                PartData::Text(t) => {
249                    if !t.text.is_empty() {
250                        text_chunks.push(t.text.clone());
251                    }
252                }
253                PartData::Reasoning(r) => {
254                    if !r.text.is_empty() {
255                        thinking_chunks.push(r.text.clone());
256                    }
257                }
258                PartData::Tool(tp) => {
259                    tool_uses.push(to_invocation(
260                        tp,
261                        &mut self.files_changed_order,
262                        &mut self.files_changed_seen,
263                    ));
264                }
265                PartData::StepStart(s) => {
266                    if let Some(sh) = &s.snapshot
267                        && snapshots.last().is_none_or(|l| l != sh)
268                    {
269                        snapshots.push(sh.clone());
270                    }
271                }
272                PartData::StepFinish(sf) => {
273                    if let Some(sh) = &sf.snapshot
274                        && snapshots.last().is_none_or(|l| l != sh)
275                    {
276                        snapshots.push(sh.clone());
277                    }
278                    accumulate_tokens(&mut step_usage, &sf.tokens);
279                    step_usage_set = true;
280                    step_cost_total += sf.cost;
281                    stop_reason = Some(sf.reason.clone());
282                }
283                PartData::Snapshot(s) => {
284                    if snapshots.last().is_none_or(|l| l != &s.snapshot) {
285                        snapshots.push(s.snapshot.clone());
286                    }
287                }
288                PartData::Patch(pp) => {
289                    patches.push(serde_json::json!({
290                        "hash": pp.hash,
291                        "files": pp.files,
292                    }));
293                    for f in &pp.files {
294                        if self.files_changed_seen.insert(f.clone()) {
295                            self.files_changed_order.push(f.clone());
296                        }
297                    }
298                }
299                PartData::Subtask(st) => {
300                    delegations.push(DelegatedWork {
301                        agent_id: st.agent.clone(),
302                        prompt: st.prompt.clone(),
303                        turns: Vec::new(),
304                        result: None,
305                    });
306                }
307                PartData::File(f) => {
308                    self.events.push(ConversationEvent {
309                        id: format!("file-{}", p.id),
310                        timestamp: millis_to_iso(p.time_created),
311                        parent_id: Some(msg.id.clone()),
312                        event_type: "part.file".into(),
313                        data: to_data_map(&serde_json::to_value(f).unwrap_or(Value::Null)),
314                    });
315                }
316                PartData::Agent(ag) => {
317                    self.events.push(ConversationEvent {
318                        id: format!("agent-{}", p.id),
319                        timestamp: millis_to_iso(p.time_created),
320                        parent_id: Some(msg.id.clone()),
321                        event_type: "part.agent".into(),
322                        data: to_data_map(&serde_json::to_value(ag).unwrap_or(Value::Null)),
323                    });
324                }
325                PartData::Retry(r) => {
326                    self.events.push(ConversationEvent {
327                        id: format!("retry-{}", p.id),
328                        timestamp: millis_to_iso(p.time_created),
329                        parent_id: Some(msg.id.clone()),
330                        event_type: "part.retry".into(),
331                        data: to_data_map(&serde_json::to_value(r).unwrap_or(Value::Null)),
332                    });
333                }
334                PartData::Compaction(c) => {
335                    self.events.push(ConversationEvent {
336                        id: format!("compaction-{}", p.id),
337                        timestamp: millis_to_iso(p.time_created),
338                        parent_id: Some(msg.id.clone()),
339                        event_type: "part.compaction".into(),
340                        data: to_data_map(&serde_json::to_value(c).unwrap_or(Value::Null)),
341                    });
342                }
343                PartData::Unknown => {
344                    self.events.push(ConversationEvent {
345                        id: format!("unknown-{}", p.id),
346                        timestamp: millis_to_iso(p.time_created),
347                        parent_id: Some(msg.id.clone()),
348                        event_type: "part.unknown".into(),
349                        data: HashMap::new(),
350                    });
351                }
352            }
353        }
354
355        // Prefer step-summed tokens over the message-level snapshot —
356        // the step deltas capture the real per-step work.
357        let token_usage = if step_usage_set {
358            Some(step_usage.clone())
359        } else {
360            let u = tokens_to_convo(&a.tokens);
361            if is_usage_zero(&u) { None } else { Some(u) }
362        };
363
364        if let Some(u) = token_usage.as_ref() {
365            accumulate_total(&mut self.total_usage, u);
366            self.total_usage_set = true;
367        }
368
369        let environment = Some(EnvironmentSnapshot {
370            working_dir: Some(a.path.cwd.to_string_lossy().to_string()),
371            vcs_branch: None,
372            vcs_revision: None,
373        });
374
375        let mut extra: HashMap<String, Value> = HashMap::new();
376        let mut opencode_extra: Map<String, Value> = Map::new();
377        opencode_extra.insert("agent".into(), Value::String(a.agent.clone()));
378        opencode_extra.insert("providerID".into(), Value::String(a.provider_id.clone()));
379        opencode_extra.insert("modelID".into(), Value::String(a.model_id.clone()));
380        opencode_extra.insert("cost_step_total".into(), json_num(step_cost_total));
381        opencode_extra.insert("cost_message".into(), json_num(a.cost));
382        if !snapshots.is_empty() {
383            opencode_extra.insert(
384                "snapshots".into(),
385                Value::Array(snapshots.into_iter().map(Value::String).collect()),
386            );
387        }
388        if !patches.is_empty() {
389            opencode_extra.insert("patches".into(), Value::Array(patches));
390        }
391        if let Some(v) = &a.variant {
392            opencode_extra.insert("variant".into(), Value::String(v.clone()));
393        }
394        if let Some(err) = &a.error {
395            opencode_extra.insert("error".into(), err.clone());
396        }
397        extra.insert("opencode".into(), Value::Object(opencode_extra));
398
399        self.turns.push(Turn {
400            id: msg.id.clone(),
401            parent_id: if a.parent_id.is_empty() {
402                None
403            } else {
404                Some(a.parent_id.clone())
405            },
406            role: Role::Assistant,
407            timestamp: millis_to_iso(msg.time_created),
408            text: text_chunks.join("\n\n"),
409            thinking: if thinking_chunks.is_empty() {
410                None
411            } else {
412                Some(thinking_chunks.join("\n\n"))
413            },
414            tool_uses,
415            model: if a.model_id.is_empty() {
416                None
417            } else {
418                Some(a.model_id.clone())
419            },
420            stop_reason: stop_reason.or_else(|| a.finish.clone()),
421            token_usage,
422            environment,
423            delegations,
424            extra,
425        });
426    }
427}
428
429fn concat_text_parts(parts: &[Part]) -> String {
430    let mut chunks = Vec::new();
431    for p in parts {
432        if let PartData::Text(t) = &p.data
433            && !t.text.is_empty()
434            && !t.ignored.unwrap_or(false)
435        {
436            chunks.push(t.text.clone());
437        }
438    }
439    chunks.join("\n\n")
440}
441
442fn to_invocation(
443    tp: &crate::types::ToolPart,
444    files_changed_order: &mut Vec<String>,
445    files_changed_seen: &mut std::collections::HashSet<String>,
446) -> ToolInvocation {
447    let input = tp.state.input().cloned().unwrap_or(Value::Null);
448    let result = match &tp.state {
449        ToolState::Completed(c) => Some(ToolResult {
450            content: c.output.clone(),
451            is_error: false,
452        }),
453        ToolState::Error(e) => Some(ToolResult {
454            content: e.error.clone(),
455            is_error: true,
456        }),
457        _ => None,
458    };
459
460    // Opportunistically collect files-changed from tool inputs.
461    if matches!(tp.tool.as_str(), "edit" | "write" | "multiedit" | "patch")
462        && let Some(path) = input
463            .get("filePath")
464            .or_else(|| input.get("file_path"))
465            .or_else(|| input.get("path"))
466            .and_then(|v| v.as_str())
467        && files_changed_seen.insert(path.to_string())
468    {
469        files_changed_order.push(path.to_string());
470    }
471
472    ToolInvocation {
473        id: tp.call_id.clone(),
474        name: tp.tool.clone(),
475        input,
476        result,
477        category: tool_category(&tp.tool),
478    }
479}
480
481fn accumulate_tokens(total: &mut TokenUsage, step: &Tokens) {
482    add_u32(&mut total.input_tokens, step.input as u32);
483    add_u32(&mut total.output_tokens, step.output as u32);
484    add_u32(&mut total.cache_read_tokens, step.cache.read as u32);
485    add_u32(&mut total.cache_write_tokens, step.cache.write as u32);
486}
487
488fn add_u32(slot: &mut Option<u32>, delta: u32) {
489    if delta == 0 {
490        return;
491    }
492    *slot = Some(slot.unwrap_or(0).saturating_add(delta));
493}
494
495fn tokens_to_convo(t: &Tokens) -> TokenUsage {
496    TokenUsage {
497        input_tokens: if t.input == 0 {
498            None
499        } else {
500            Some(t.input as u32)
501        },
502        output_tokens: if t.output == 0 {
503            None
504        } else {
505            Some(t.output as u32)
506        },
507        cache_read_tokens: if t.cache.read == 0 {
508            None
509        } else {
510            Some(t.cache.read as u32)
511        },
512        cache_write_tokens: if t.cache.write == 0 {
513            None
514        } else {
515            Some(t.cache.write as u32)
516        },
517    }
518}
519
520fn is_usage_zero(u: &TokenUsage) -> bool {
521    u.input_tokens.is_none()
522        && u.output_tokens.is_none()
523        && u.cache_read_tokens.is_none()
524        && u.cache_write_tokens.is_none()
525}
526
527fn accumulate_total(total: &mut TokenUsage, delta: &TokenUsage) {
528    if let Some(v) = delta.input_tokens {
529        add_u32(&mut total.input_tokens, v);
530    }
531    if let Some(v) = delta.output_tokens {
532        add_u32(&mut total.output_tokens, v);
533    }
534    if let Some(v) = delta.cache_read_tokens {
535        add_u32(&mut total.cache_read_tokens, v);
536    }
537    if let Some(v) = delta.cache_write_tokens {
538        add_u32(&mut total.cache_write_tokens, v);
539    }
540}
541
542fn millis_to_iso(ms: i64) -> String {
543    Utc.timestamp_millis_opt(ms)
544        .single()
545        .map(|t| t.to_rfc3339())
546        .unwrap_or_else(|| ms.to_string())
547}
548
549fn to_data_map(v: &Value) -> HashMap<String, Value> {
550    match v {
551        Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
552        _ => {
553            let mut m = HashMap::new();
554            m.insert("value".into(), v.clone());
555            m
556        }
557    }
558}
559
560fn json_num(v: f64) -> Value {
561    serde_json::Number::from_f64(v)
562        .map(Value::Number)
563        .unwrap_or(Value::Null)
564}
565
566// ── ConversationProvider trait impl ─────────────────────────────────
567
568impl ConversationProvider for OpencodeConvo {
569    fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
570        let metas = self
571            .list_sessions()
572            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
573        Ok(metas.into_iter().map(|m| m.id).collect())
574    }
575
576    fn load_conversation(
577        &self,
578        _project: &str,
579        conversation_id: &str,
580    ) -> toolpath_convo::Result<ConversationView> {
581        let s = self
582            .read_session(conversation_id)
583            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
584        Ok(to_view(&s))
585    }
586
587    fn load_metadata(
588        &self,
589        _project: &str,
590        conversation_id: &str,
591    ) -> toolpath_convo::Result<ConversationMeta> {
592        let m = self
593            .io
594            .read_metadata(conversation_id)
595            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
596        Ok(ConversationMeta {
597            id: m.id,
598            started_at: m.started_at,
599            last_activity: m.last_activity,
600            message_count: m.message_count,
601            file_path: Some(m.directory),
602            predecessor: None,
603            successor: None,
604        })
605    }
606
607    fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
608        let metas = self
609            .list_sessions()
610            .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
611        Ok(metas
612            .into_iter()
613            .map(|m| ConversationMeta {
614                id: m.id,
615                started_at: m.started_at,
616                last_activity: m.last_activity,
617                message_count: m.message_count,
618                file_path: Some(m.directory),
619                predecessor: None,
620                successor: None,
621            })
622            .collect())
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use rusqlite::Connection;
630    use std::fs;
631    use tempfile::TempDir;
632
633    fn setup(body_sql: &str) -> (TempDir, OpencodeConvo) {
634        let temp = TempDir::new().unwrap();
635        let data = temp.path().join(".local/share/opencode");
636        fs::create_dir_all(&data).unwrap();
637        let conn = Connection::open(data.join("opencode.db")).unwrap();
638        conn.execute_batch(&format!(
639            r#"
640            CREATE TABLE project (
641              id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
642              icon_url text, icon_color text,
643              time_created integer NOT NULL, time_updated integer NOT NULL,
644              time_initialized integer, sandboxes text NOT NULL, commands text
645            );
646            CREATE TABLE session (
647              id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
648              slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
649              version text NOT NULL, share_url text,
650              summary_additions integer, summary_deletions integer,
651              summary_files integer, summary_diffs text, revert text, permission text,
652              time_created integer NOT NULL, time_updated integer NOT NULL,
653              time_compacting integer, time_archived integer, workspace_id text
654            );
655            CREATE TABLE message (
656              id text PRIMARY KEY, session_id text NOT NULL,
657              time_created integer NOT NULL, time_updated integer NOT NULL,
658              data text NOT NULL
659            );
660            CREATE TABLE part (
661              id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
662              time_created integer NOT NULL, time_updated integer NOT NULL,
663              data text NOT NULL
664            );
665            {body_sql}
666        "#
667        ))
668        .unwrap();
669        drop(conn);
670        let resolver = PathResolver::new()
671            .with_home(temp.path())
672            .with_data_dir(&data);
673        (temp, OpencodeConvo::with_resolver(resolver))
674    }
675
676    const BASIC_SQL: &str = r#"
677        INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
678          VALUES ('proj', '/tmp/proj', 1000, 3000, '[]');
679        INSERT INTO session (id, project_id, slug, directory, title, version,
680                             time_created, time_updated)
681          VALUES ('ses_x', 'proj', 'slug', '/tmp/proj', 'T', '1.3.10', 1000, 3000);
682        INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
683          ('m1','ses_x',1001,1001,
684           '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
685          ('m2','ses_x',1002,1100,
686           '{"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"}');
687        INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
688          ('p1','m1','ses_x',1001,1001,'{"type":"text","text":"make a pickle"}'),
689          ('p2','m2','ses_x',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
690          ('p3','m2','ses_x',1003,1003,'{"type":"reasoning","text":"I should write main.cpp","time":{"start":1003,"end":1004}}'),
691          ('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}}}'),
692          ('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}}}'),
693          ('p6','m2','ses_x',1009,1009,'{"type":"text","text":"done!"}'),
694          ('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}');
695    "#;
696
697    #[test]
698    fn basic_view_shape() {
699        let (_t, mgr) = setup(BASIC_SQL);
700        let s = mgr.read_session("ses_x").unwrap();
701        let view = to_view(&s);
702
703        assert_eq!(view.id, "ses_x");
704        assert_eq!(view.provider_id.as_deref(), Some("opencode"));
705        assert_eq!(view.turns.len(), 2);
706        assert_eq!(view.turns[0].role, Role::User);
707        assert_eq!(view.turns[0].text, "make a pickle");
708        assert_eq!(view.turns[1].role, Role::Assistant);
709        assert_eq!(view.turns[1].text, "done!");
710        assert_eq!(
711            view.turns[1].thinking.as_deref(),
712            Some("I should write main.cpp")
713        );
714    }
715
716    #[test]
717    fn tool_invocations_paired() {
718        let (_t, mgr) = setup(BASIC_SQL);
719        let view = to_view(&mgr.read_session("ses_x").unwrap());
720        let assistant = &view.turns[1];
721        assert_eq!(assistant.tool_uses.len(), 2);
722        let bash = &assistant.tool_uses[0];
723        assert_eq!(bash.name, "bash");
724        assert_eq!(bash.category, Some(ToolCategory::Shell));
725        assert_eq!(bash.result.as_ref().unwrap().content, "files\n");
726        let write = &assistant.tool_uses[1];
727        assert_eq!(write.name, "write");
728        assert_eq!(write.category, Some(ToolCategory::FileWrite));
729    }
730
731    #[test]
732    fn snapshots_surface_on_assistant_extra() {
733        let (_t, mgr) = setup(BASIC_SQL);
734        let view = to_view(&mgr.read_session("ses_x").unwrap());
735        let assistant = &view.turns[1];
736        let snaps = assistant.extra["opencode"]["snapshots"].as_array().unwrap();
737        assert_eq!(
738            snaps,
739            &[
740                Value::String("snap_a".into()),
741                Value::String("snap_b".into())
742            ]
743        );
744    }
745
746    #[test]
747    fn files_changed_from_tool_input() {
748        let (_t, mgr) = setup(BASIC_SQL);
749        let view = to_view(&mgr.read_session("ses_x").unwrap());
750        assert_eq!(view.files_changed, vec!["/tmp/proj/main.cpp".to_string()]);
751    }
752
753    #[test]
754    fn step_finish_drives_token_usage() {
755        let (_t, mgr) = setup(BASIC_SQL);
756        let view = to_view(&mgr.read_session("ses_x").unwrap());
757        let u = view.turns[1].token_usage.as_ref().unwrap();
758        assert_eq!(u.input_tokens, Some(100));
759        assert_eq!(u.output_tokens, Some(20));
760        assert_eq!(u.cache_read_tokens, Some(10));
761
762        let total = view.total_usage.as_ref().unwrap();
763        assert_eq!(total.input_tokens, Some(100));
764        assert_eq!(total.output_tokens, Some(20));
765    }
766
767    #[test]
768    fn tool_error_becomes_tool_result_error() {
769        let body = r#"
770            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
771              VALUES ('p', '/p', 1, 2, '[]');
772            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
773              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
774            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
775              ('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}}');
776            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
777              ('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}}}');
778        "#;
779        let (_t, mgr) = setup(body);
780        let view = to_view(&mgr.read_session("s").unwrap());
781        let tool = &view.turns[0].tool_uses[0];
782        let r = tool.result.as_ref().unwrap();
783        assert!(r.is_error);
784        assert_eq!(r.content, "exit 1");
785    }
786
787    #[test]
788    fn compaction_becomes_event() {
789        let body = r#"
790            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
791              VALUES ('p','/p',1,2,'[]');
792            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
793              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
794            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
795              ('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}}');
796            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
797              ('p1','m','s',1,1,'{"type":"compaction","auto":true,"overflow":false}');
798        "#;
799        let (_t, mgr) = setup(body);
800        let view = to_view(&mgr.read_session("s").unwrap());
801        assert!(
802            view.events
803                .iter()
804                .any(|e| e.event_type == "part.compaction")
805        );
806    }
807
808    #[test]
809    fn unknown_part_type_becomes_event() {
810        let body = r#"
811            INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('p','/p',1,2,'[]');
812            INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
813              VALUES ('s','p','slug','/p','T','1.0.0',1,2);
814            INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
815              ('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}}');
816            INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
817              ('p1','m','s',1,1,'{"type":"future-thing","foo":"bar"}');
818        "#;
819        let (_t, mgr) = setup(body);
820        let view = to_view(&mgr.read_session("s").unwrap());
821        assert!(view.events.iter().any(|e| e.event_type == "part.unknown"));
822    }
823
824    #[test]
825    fn tool_category_mapping() {
826        assert_eq!(tool_category("bash"), Some(ToolCategory::Shell));
827        assert_eq!(tool_category("edit"), Some(ToolCategory::FileWrite));
828        assert_eq!(tool_category("write"), Some(ToolCategory::FileWrite));
829        assert_eq!(tool_category("read"), Some(ToolCategory::FileRead));
830        assert_eq!(tool_category("grep"), Some(ToolCategory::FileSearch));
831        assert_eq!(tool_category("webfetch"), Some(ToolCategory::Network));
832        assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
833        assert_eq!(tool_category("mcp__x__y"), None);
834    }
835
836    #[test]
837    fn provider_trait_list_and_load() {
838        let (_t, mgr) = setup(BASIC_SQL);
839        let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
840        assert_eq!(ids, vec!["ses_x".to_string()]);
841        let v = ConversationProvider::load_conversation(&mgr, "", "ses_x").unwrap();
842        assert_eq!(v.turns.len(), 2);
843    }
844}