1use 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#[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 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
102pub 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 None
119 }
120 }
121}
122
123pub 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 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 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
566impl 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}