1use 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#[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 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
149pub fn to_view(session: &Session) -> ConversationView {
155 to_view_with_resolver(session, &PathResolver::new())
156}
157
158pub 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_repo: Option<git2::Repository>,
177 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 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 let mut seen = std::collections::HashSet::new();
231 let mut ordered = Vec::new();
232 for turn in &view.turns {
233 for fm in &turn.file_mutations {
234 if seen.insert(fm.path.clone()) {
235 ordered.push(fm.path.clone());
236 }
237 }
238 }
239 view.files_changed = ordered;
240 view
241 }
242
243 fn build(mut self) -> ConversationView {
244 for msg in &self.session.messages {
245 match &msg.data {
246 MessageData::User(u) => self.handle_user_message(msg, u),
247 MessageData::Assistant(a) => self.handle_assistant_message(msg, a),
248 MessageData::Other => {
249 self.events.push(ConversationEvent {
250 id: format!("msg-other-{}", msg.id),
251 timestamp: millis_to_iso(msg.time_created),
252 parent_id: None,
253 event_type: "message.other".into(),
254 data: HashMap::new(),
255 });
256 }
257 }
258 }
259
260 ConversationView {
261 id: self.session.id.clone(),
262 started_at: Utc.timestamp_millis_opt(self.session.time_created).single(),
263 last_activity: Utc.timestamp_millis_opt(self.session.time_updated).single(),
264 turns: self.turns,
265 total_usage: if self.total_usage_set {
266 Some(self.total_usage)
267 } else {
268 None
269 },
270 provider_id: Some("opencode".into()),
271 files_changed: self.files_changed_order,
272 session_ids: vec![self.session.id.clone()],
273 events: self.events,
274 ..Default::default()
275 }
276 }
277
278 fn handle_user_message(&mut self, msg: &Message, _u: &UserMessage) {
279 let text = concat_text_parts(&msg.parts);
280 let environment = Some(EnvironmentSnapshot {
281 working_dir: Some(self.session.directory.to_string_lossy().to_string()),
282 vcs_branch: None,
283 vcs_revision: None,
284 });
285
286 self.turns.push(Turn {
287 id: msg.id.clone(),
288 parent_id: None,
289 role: Role::User,
290 timestamp: millis_to_iso(msg.time_created),
291 text,
292 thinking: None,
293 tool_uses: Vec::new(),
294 model: None,
295 stop_reason: None,
296 token_usage: None,
297 environment,
298 delegations: Vec::new(),
299 file_mutations: Vec::new(),
300 });
301 }
302
303 fn handle_assistant_message(&mut self, msg: &Message, a: &AssistantMessage) {
304 let mut text_chunks: Vec<String> = Vec::new();
305 let mut thinking_chunks: Vec<String> = Vec::new();
306 let mut tool_uses: Vec<ToolInvocation> = Vec::new();
307 let mut snapshots: Vec<String> = Vec::new();
308 let mut delegations: Vec<DelegatedWork> = Vec::new();
309 let mut step_usage = TokenUsage::default();
310 let mut step_usage_set = false;
311 let mut stop_reason: Option<String> = None;
312
313 for p in &msg.parts {
314 match &p.data {
315 PartData::Text(t) => {
316 if !t.text.is_empty() {
317 text_chunks.push(t.text.clone());
318 }
319 }
320 PartData::Reasoning(r) => {
321 if !r.text.is_empty() {
322 thinking_chunks.push(r.text.clone());
323 }
324 }
325 PartData::Tool(tp) => {
326 tool_uses.push(to_invocation(
327 tp,
328 &mut self.files_changed_order,
329 &mut self.files_changed_seen,
330 ));
331 }
332 PartData::StepStart(s) => {
333 if let Some(sh) = &s.snapshot
334 && snapshots.last().is_none_or(|l| l != sh)
335 {
336 snapshots.push(sh.clone());
337 }
338 }
339 PartData::StepFinish(sf) => {
340 if let Some(sh) = &sf.snapshot
341 && snapshots.last().is_none_or(|l| l != sh)
342 {
343 snapshots.push(sh.clone());
344 }
345 accumulate_tokens(&mut step_usage, &sf.tokens);
346 step_usage_set = true;
347 stop_reason = Some(sf.reason.clone());
348 }
349 PartData::Snapshot(s) => {
350 if snapshots.last().is_none_or(|l| l != &s.snapshot) {
351 snapshots.push(s.snapshot.clone());
352 }
353 }
354 PartData::Patch(pp) => {
355 for f in &pp.files {
356 if self.files_changed_seen.insert(f.clone()) {
357 self.files_changed_order.push(f.clone());
358 }
359 }
360 }
361 PartData::Subtask(st) => {
362 delegations.push(DelegatedWork {
363 agent_id: st.agent.clone(),
364 prompt: st.prompt.clone(),
365 turns: Vec::new(),
366 result: None,
367 });
368 }
369 PartData::File(f) => {
370 self.events.push(ConversationEvent {
371 id: format!("file-{}", p.id),
372 timestamp: millis_to_iso(p.time_created),
373 parent_id: Some(msg.id.clone()),
374 event_type: "part.file".into(),
375 data: to_data_map(&serde_json::to_value(f).unwrap_or(Value::Null)),
376 });
377 }
378 PartData::Agent(ag) => {
379 self.events.push(ConversationEvent {
380 id: format!("agent-{}", p.id),
381 timestamp: millis_to_iso(p.time_created),
382 parent_id: Some(msg.id.clone()),
383 event_type: "part.agent".into(),
384 data: to_data_map(&serde_json::to_value(ag).unwrap_or(Value::Null)),
385 });
386 }
387 PartData::Retry(r) => {
388 self.events.push(ConversationEvent {
389 id: format!("retry-{}", p.id),
390 timestamp: millis_to_iso(p.time_created),
391 parent_id: Some(msg.id.clone()),
392 event_type: "part.retry".into(),
393 data: to_data_map(&serde_json::to_value(r).unwrap_or(Value::Null)),
394 });
395 }
396 PartData::Compaction(c) => {
397 self.events.push(ConversationEvent {
398 id: format!("compaction-{}", p.id),
399 timestamp: millis_to_iso(p.time_created),
400 parent_id: Some(msg.id.clone()),
401 event_type: "part.compaction".into(),
402 data: to_data_map(&serde_json::to_value(c).unwrap_or(Value::Null)),
403 });
404 }
405 PartData::Unknown => {
406 self.events.push(ConversationEvent {
407 id: format!("unknown-{}", p.id),
408 timestamp: millis_to_iso(p.time_created),
409 parent_id: Some(msg.id.clone()),
410 event_type: "part.unknown".into(),
411 data: HashMap::new(),
412 });
413 }
414 }
415 }
416
417 let token_usage = if step_usage_set {
420 Some(step_usage.clone())
421 } else {
422 let u = tokens_to_convo(&a.tokens);
423 if is_usage_zero(&u) { None } else { Some(u) }
424 };
425
426 if let Some(u) = token_usage.as_ref() {
427 accumulate_total(&mut self.total_usage, u);
428 self.total_usage_set = true;
429 }
430
431 let environment = Some(EnvironmentSnapshot {
432 working_dir: Some(a.path.cwd.to_string_lossy().to_string()),
433 vcs_branch: None,
434 vcs_revision: None,
435 });
436
437 let file_mutations = self.compute_turn_mutations(&snapshots, &tool_uses);
445
446 self.turns.push(Turn {
447 id: msg.id.clone(),
448 parent_id: if a.parent_id.is_empty() {
449 None
450 } else {
451 Some(a.parent_id.clone())
452 },
453 role: Role::Assistant,
454 timestamp: millis_to_iso(msg.time_created),
455 text: text_chunks.join("\n\n"),
456 thinking: if thinking_chunks.is_empty() {
457 None
458 } else {
459 Some(thinking_chunks.join("\n\n"))
460 },
461 tool_uses,
462 model: if a.model_id.is_empty() {
463 None
464 } else {
465 Some(a.model_id.clone())
466 },
467 stop_reason: stop_reason.or_else(|| a.finish.clone()),
468 token_usage,
469 environment,
470 delegations,
471 file_mutations,
472 });
473 }
474
475 fn compute_turn_mutations(
476 &mut self,
477 snapshots: &[String],
478 tool_uses: &[ToolInvocation],
479 ) -> Vec<FileMutation> {
480 let mut out: Vec<FileMutation> = Vec::new();
481 let mut covered: std::collections::HashSet<String> = std::collections::HashSet::new();
482
483 if let (Some(repo), Some(first), Some(last)) = (
485 self.snapshot_repo.as_ref(),
486 snapshots.first(),
487 snapshots.last(),
488 ) {
489 let before = self
490 .prev_snapshot_after
491 .clone()
492 .unwrap_or_else(|| first.clone());
493 let after = last.clone();
494 self.prev_snapshot_after = Some(after.clone());
495 if before != after {
496 match diff_trees(repo, &before, &after) {
497 Ok(mutations) => {
498 for fm in mutations {
499 covered.insert(fm.path.clone());
500 out.push(fm);
501 }
502 }
503 Err(e) => {
504 eprintln!(
505 "Warning: snapshot diff {}..{} failed: {}",
506 &before[..before.len().min(8)],
507 &after[..after.len().min(8)],
508 e
509 );
510 }
511 }
512 }
513 } else if let Some(last) = snapshots.last() {
514 self.prev_snapshot_after = Some(last.clone());
517 }
518
519 for tu in tool_uses {
522 let Some(path) = tool_input_file_path(tu) else {
523 continue;
524 };
525 if covered.contains(&path) {
526 continue;
527 }
528 covered.insert(path.clone());
529 out.push(FileMutation {
530 path,
531 tool_id: Some(tu.id.clone()),
532 operation: Some(tool_to_operation(&tu.name).to_string()),
533 ..Default::default()
534 });
535 }
536
537 out
538 }
539}
540
541fn concat_text_parts(parts: &[Part]) -> String {
542 let mut chunks = Vec::new();
543 for p in parts {
544 if let PartData::Text(t) = &p.data
545 && !t.text.is_empty()
546 && !t.ignored.unwrap_or(false)
547 {
548 chunks.push(t.text.clone());
549 }
550 }
551 chunks.join("\n\n")
552}
553
554fn to_invocation(
555 tp: &crate::types::ToolPart,
556 files_changed_order: &mut Vec<String>,
557 files_changed_seen: &mut std::collections::HashSet<String>,
558) -> ToolInvocation {
559 let input = tp.state.input().cloned().unwrap_or(Value::Null);
560 let result = match &tp.state {
561 ToolState::Completed(c) => Some(ToolResult {
562 content: c.output.clone(),
563 is_error: false,
564 }),
565 ToolState::Error(e) => Some(ToolResult {
566 content: e.error.clone(),
567 is_error: true,
568 }),
569 _ => None,
570 };
571
572 if matches!(tp.tool.as_str(), "edit" | "write" | "multiedit" | "patch")
574 && let Some(path) = input
575 .get("filePath")
576 .or_else(|| input.get("file_path"))
577 .or_else(|| input.get("path"))
578 .and_then(|v| v.as_str())
579 && files_changed_seen.insert(path.to_string())
580 {
581 files_changed_order.push(path.to_string());
582 }
583
584 ToolInvocation {
585 id: tp.call_id.clone(),
586 name: tp.tool.clone(),
587 input,
588 result,
589 category: tool_category(&tp.tool),
590 }
591}
592
593fn accumulate_tokens(total: &mut TokenUsage, step: &Tokens) {
594 add_u32(&mut total.input_tokens, step.input as u32);
595 add_u32(&mut total.output_tokens, step.output as u32);
596 add_u32(&mut total.cache_read_tokens, step.cache.read as u32);
597 add_u32(&mut total.cache_write_tokens, step.cache.write as u32);
598}
599
600fn add_u32(slot: &mut Option<u32>, delta: u32) {
601 if delta == 0 {
602 return;
603 }
604 *slot = Some(slot.unwrap_or(0).saturating_add(delta));
605}
606
607fn tokens_to_convo(t: &Tokens) -> TokenUsage {
608 TokenUsage {
609 input_tokens: if t.input == 0 {
610 None
611 } else {
612 Some(t.input as u32)
613 },
614 output_tokens: if t.output == 0 {
615 None
616 } else {
617 Some(t.output as u32)
618 },
619 cache_read_tokens: if t.cache.read == 0 {
620 None
621 } else {
622 Some(t.cache.read as u32)
623 },
624 cache_write_tokens: if t.cache.write == 0 {
625 None
626 } else {
627 Some(t.cache.write as u32)
628 },
629 }
630}
631
632fn is_usage_zero(u: &TokenUsage) -> bool {
633 u.input_tokens.is_none()
634 && u.output_tokens.is_none()
635 && u.cache_read_tokens.is_none()
636 && u.cache_write_tokens.is_none()
637}
638
639fn accumulate_total(total: &mut TokenUsage, delta: &TokenUsage) {
640 if let Some(v) = delta.input_tokens {
641 add_u32(&mut total.input_tokens, v);
642 }
643 if let Some(v) = delta.output_tokens {
644 add_u32(&mut total.output_tokens, v);
645 }
646 if let Some(v) = delta.cache_read_tokens {
647 add_u32(&mut total.cache_read_tokens, v);
648 }
649 if let Some(v) = delta.cache_write_tokens {
650 add_u32(&mut total.cache_write_tokens, v);
651 }
652}
653
654fn millis_to_iso(ms: i64) -> String {
655 Utc.timestamp_millis_opt(ms)
656 .single()
657 .map(|t| t.to_rfc3339())
658 .unwrap_or_else(|| ms.to_string())
659}
660
661fn to_data_map(v: &Value) -> HashMap<String, Value> {
662 match v {
663 Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
664 _ => {
665 let mut m = HashMap::new();
666 m.insert("value".into(), v.clone());
667 m
668 }
669 }
670}
671
672impl ConversationProvider for OpencodeConvo {
675 fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
676 let metas = self
677 .list_sessions()
678 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
679 Ok(metas.into_iter().map(|m| m.id).collect())
680 }
681
682 fn load_conversation(
683 &self,
684 _project: &str,
685 conversation_id: &str,
686 ) -> toolpath_convo::Result<ConversationView> {
687 let s = self
688 .read_session(conversation_id)
689 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
690 Ok(to_view(&s))
691 }
692
693 fn load_metadata(
694 &self,
695 _project: &str,
696 conversation_id: &str,
697 ) -> toolpath_convo::Result<ConversationMeta> {
698 let m = self
699 .io
700 .read_metadata(conversation_id)
701 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
702 Ok(ConversationMeta {
703 id: m.id,
704 started_at: m.started_at,
705 last_activity: m.last_activity,
706 message_count: m.message_count,
707 file_path: Some(m.directory),
708 predecessor: None,
709 successor: None,
710 })
711 }
712
713 fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
714 let metas = self
715 .list_sessions()
716 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
717 Ok(metas
718 .into_iter()
719 .map(|m| ConversationMeta {
720 id: m.id,
721 started_at: m.started_at,
722 last_activity: m.last_activity,
723 message_count: m.message_count,
724 file_path: Some(m.directory),
725 predecessor: None,
726 successor: None,
727 })
728 .collect())
729 }
730}
731
732fn tool_input_file_path(tu: &ToolInvocation) -> Option<String> {
735 tu.input
736 .get("filePath")
737 .or_else(|| tu.input.get("file_path"))
738 .or_else(|| tu.input.get("path"))
739 .and_then(|v| v.as_str())
740 .map(str::to_string)
741}
742
743fn tool_to_operation(name: &str) -> &'static str {
744 match name {
745 "write" => "add",
746 "edit" | "multiedit" | "patch" => "update",
747 "delete" | "rm" => "delete",
748 _ => "touch",
749 }
750}
751
752fn diff_trees(
753 repo: &git2::Repository,
754 before: &str,
755 after: &str,
756) -> std::result::Result<Vec<FileMutation>, git2::Error> {
757 let before_obj = repo.revparse_single(before)?;
758 let after_obj = repo.revparse_single(after)?;
759 let before_tree = before_obj.peel_to_tree()?;
760 let after_tree = after_obj.peel_to_tree()?;
761
762 let mut opts = git2::DiffOptions::new();
763 opts.context_lines(3);
764 opts.include_ignored(false);
765 opts.ignore_submodules(true);
766 let diff = repo.diff_tree_to_tree(Some(&before_tree), Some(&after_tree), Some(&mut opts))?;
767
768 use std::path::PathBuf;
769 let mut by_path: HashMap<PathBuf, (String, &'static str, Option<PathBuf>)> = HashMap::new();
770
771 diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
772 let Some(new_path) = delta.new_file().path() else {
773 if let Some(old) = delta.old_file().path() {
774 let buf = by_path
775 .entry(old.to_path_buf())
776 .or_insert_with(|| (String::new(), "delete", None));
777 append_diff_line(&mut buf.0, line);
778 }
779 return true;
780 };
781 let op = classify_delta(&delta);
782 let entry = by_path.entry(new_path.to_path_buf()).or_insert_with(|| {
783 (
784 String::new(),
785 op,
786 delta.old_file().path().map(|p| p.to_path_buf()),
787 )
788 });
789 append_diff_line(&mut entry.0, line);
790 true
791 })?;
792
793 let mut out: Vec<FileMutation> = by_path
794 .into_iter()
795 .map(|(path, (raw_diff, op, old_path))| FileMutation {
796 path: path.to_string_lossy().into_owned(),
797 tool_id: None,
798 operation: Some(op.to_string()),
799 raw_diff: if raw_diff.is_empty() {
800 None
801 } else {
802 Some(raw_diff)
803 },
804 before: None,
805 after: None,
806 rename_to: if op == "rename" {
807 old_path.map(|p| p.to_string_lossy().into_owned())
808 } else {
809 None
810 },
811 })
812 .collect();
813 out.sort_by(|a, b| a.path.cmp(&b.path));
814 Ok(out)
815}
816
817fn classify_delta(delta: &git2::DiffDelta) -> &'static str {
818 use git2::Delta;
819 match delta.status() {
820 Delta::Added => "add",
821 Delta::Deleted => "delete",
822 Delta::Modified => "update",
823 Delta::Renamed => "rename",
824 Delta::Copied => "copy",
825 Delta::Typechange => "update",
826 _ => "update",
827 }
828}
829
830fn append_diff_line(buf: &mut String, line: git2::DiffLine<'_>) {
831 use git2::DiffLineType;
832 let prefix = match line.origin_value() {
833 DiffLineType::Context => " ",
834 DiffLineType::Addition => "+",
835 DiffLineType::Deletion => "-",
836 DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => "",
837 _ => "",
838 };
839 buf.push_str(prefix);
840 if let Ok(s) = std::str::from_utf8(line.content()) {
841 buf.push_str(s);
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use rusqlite::Connection;
849 use std::fs;
850 use tempfile::TempDir;
851
852 fn setup(body_sql: &str) -> (TempDir, OpencodeConvo) {
853 let temp = TempDir::new().unwrap();
854 let data = temp.path().join(".local/share/opencode");
855 fs::create_dir_all(&data).unwrap();
856 let conn = Connection::open(data.join("opencode.db")).unwrap();
857 conn.execute_batch(&format!(
858 r#"
859 CREATE TABLE project (
860 id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
861 icon_url text, icon_color text,
862 time_created integer NOT NULL, time_updated integer NOT NULL,
863 time_initialized integer, sandboxes text NOT NULL, commands text
864 );
865 CREATE TABLE session (
866 id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
867 slug text NOT NULL, directory text NOT NULL, title text NOT NULL,
868 version text NOT NULL, share_url text,
869 summary_additions integer, summary_deletions integer,
870 summary_files integer, summary_diffs text, revert text, permission text,
871 time_created integer NOT NULL, time_updated integer NOT NULL,
872 time_compacting integer, time_archived integer, workspace_id text
873 );
874 CREATE TABLE message (
875 id text PRIMARY KEY, session_id text NOT NULL,
876 time_created integer NOT NULL, time_updated integer NOT NULL,
877 data text NOT NULL
878 );
879 CREATE TABLE part (
880 id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
881 time_created integer NOT NULL, time_updated integer NOT NULL,
882 data text NOT NULL
883 );
884 {body_sql}
885 "#
886 ))
887 .unwrap();
888 drop(conn);
889 let resolver = PathResolver::new()
890 .with_home(temp.path())
891 .with_data_dir(&data);
892 (temp, OpencodeConvo::with_resolver(resolver))
893 }
894
895 const BASIC_SQL: &str = r#"
896 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
897 VALUES ('proj', '/tmp/proj', 1000, 3000, '[]');
898 INSERT INTO session (id, project_id, slug, directory, title, version,
899 time_created, time_updated)
900 VALUES ('ses_x', 'proj', 'slug', '/tmp/proj', 'T', '1.3.10', 1000, 3000);
901 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
902 ('m1','ses_x',1001,1001,
903 '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
904 ('m2','ses_x',1002,1100,
905 '{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/proj","root":"/tmp/proj"},"cost":0.01,"tokens":{"input":100,"output":20,"reasoning":5,"cache":{"read":10,"write":0}},"modelID":"claude","providerID":"anthropic","time":{"created":1002,"completed":1100},"finish":"stop"}');
906 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
907 ('p1','m1','ses_x',1001,1001,'{"type":"text","text":"make a pickle"}'),
908 ('p2','m2','ses_x',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
909 ('p3','m2','ses_x',1003,1003,'{"type":"reasoning","text":"I should write main.cpp","time":{"start":1003,"end":1004}}'),
910 ('p4','m2','ses_x',1005,1005,'{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"files\n","title":"List","metadata":{"exit":0},"time":{"start":1005,"end":1006}}}'),
911 ('p5','m2','ses_x',1007,1007,'{"type":"tool","tool":"write","callID":"call_2","state":{"status":"completed","input":{"filePath":"/tmp/proj/main.cpp","content":"int main(){}\n"},"output":"wrote","title":"Write","metadata":{"bytes":13},"time":{"start":1007,"end":1008}}}'),
912 ('p6','m2','ses_x',1009,1009,'{"type":"text","text":"done!"}'),
913 ('p7','m2','ses_x',1010,1010,'{"type":"step-finish","reason":"stop","snapshot":"snap_b","tokens":{"input":100,"output":20,"reasoning":5,"cache":{"read":10,"write":0}},"cost":0.01}');
914 "#;
915
916 #[test]
917 fn basic_view_shape() {
918 let (_t, mgr) = setup(BASIC_SQL);
919 let s = mgr.read_session("ses_x").unwrap();
920 let view = to_view(&s);
921
922 assert_eq!(view.id, "ses_x");
923 assert_eq!(view.provider_id.as_deref(), Some("opencode"));
924 assert_eq!(view.turns.len(), 2);
925 assert_eq!(view.turns[0].role, Role::User);
926 assert_eq!(view.turns[0].text, "make a pickle");
927 assert_eq!(view.turns[1].role, Role::Assistant);
928 assert_eq!(view.turns[1].text, "done!");
929 assert_eq!(
930 view.turns[1].thinking.as_deref(),
931 Some("I should write main.cpp")
932 );
933 }
934
935 #[test]
936 fn tool_invocations_paired() {
937 let (_t, mgr) = setup(BASIC_SQL);
938 let view = to_view(&mgr.read_session("ses_x").unwrap());
939 let assistant = &view.turns[1];
940 assert_eq!(assistant.tool_uses.len(), 2);
941 let bash = &assistant.tool_uses[0];
942 assert_eq!(bash.name, "bash");
943 assert_eq!(bash.category, Some(ToolCategory::Shell));
944 assert_eq!(bash.result.as_ref().unwrap().content, "files\n");
945 let write = &assistant.tool_uses[1];
946 assert_eq!(write.name, "write");
947 assert_eq!(write.category, Some(ToolCategory::FileWrite));
948 }
949
950 #[test]
951 fn files_changed_from_tool_input() {
952 let (_t, mgr) = setup(BASIC_SQL);
953 let view = to_view(&mgr.read_session("ses_x").unwrap());
954 assert_eq!(view.files_changed, vec!["/tmp/proj/main.cpp".to_string()]);
955 }
956
957 #[test]
958 fn step_finish_drives_token_usage() {
959 let (_t, mgr) = setup(BASIC_SQL);
960 let view = to_view(&mgr.read_session("ses_x").unwrap());
961 let u = view.turns[1].token_usage.as_ref().unwrap();
962 assert_eq!(u.input_tokens, Some(100));
963 assert_eq!(u.output_tokens, Some(20));
964 assert_eq!(u.cache_read_tokens, Some(10));
965
966 let total = view.total_usage.as_ref().unwrap();
967 assert_eq!(total.input_tokens, Some(100));
968 assert_eq!(total.output_tokens, Some(20));
969 }
970
971 #[test]
972 fn tool_error_becomes_tool_result_error() {
973 let body = r#"
974 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
975 VALUES ('p', '/p', 1, 2, '[]');
976 INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
977 VALUES ('s','p','slug','/p','T','1.0.0',1,2);
978 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
979 ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
980 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
981 ('p1','m','s',1,1,'{"type":"tool","tool":"bash","callID":"c","state":{"status":"error","input":{"command":"false"},"error":"exit 1","time":{"start":1,"end":2}}}');
982 "#;
983 let (_t, mgr) = setup(body);
984 let view = to_view(&mgr.read_session("s").unwrap());
985 let tool = &view.turns[0].tool_uses[0];
986 let r = tool.result.as_ref().unwrap();
987 assert!(r.is_error);
988 assert_eq!(r.content, "exit 1");
989 }
990
991 #[test]
992 fn compaction_becomes_event() {
993 let body = r#"
994 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
995 VALUES ('p','/p',1,2,'[]');
996 INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
997 VALUES ('s','p','slug','/p','T','1.0.0',1,2);
998 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
999 ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
1000 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1001 ('p1','m','s',1,1,'{"type":"compaction","auto":true,"overflow":false}');
1002 "#;
1003 let (_t, mgr) = setup(body);
1004 let view = to_view(&mgr.read_session("s").unwrap());
1005 assert!(
1006 view.events
1007 .iter()
1008 .any(|e| e.event_type == "part.compaction")
1009 );
1010 }
1011
1012 #[test]
1013 fn unknown_part_type_becomes_event() {
1014 let body = r#"
1015 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('p','/p',1,2,'[]');
1016 INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
1017 VALUES ('s','p','slug','/p','T','1.0.0',1,2);
1018 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
1019 ('m','s',1,1,'{"parentID":"","role":"assistant","mode":"b","agent":"b","path":{"cwd":"/p","root":"/p"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"m","providerID":"p","time":{"created":1}}');
1020 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
1021 ('p1','m','s',1,1,'{"type":"future-thing","foo":"bar"}');
1022 "#;
1023 let (_t, mgr) = setup(body);
1024 let view = to_view(&mgr.read_session("s").unwrap());
1025 assert!(view.events.iter().any(|e| e.event_type == "part.unknown"));
1026 }
1027
1028 #[test]
1029 fn tool_category_mapping() {
1030 assert_eq!(tool_category("bash"), Some(ToolCategory::Shell));
1031 assert_eq!(tool_category("edit"), Some(ToolCategory::FileWrite));
1032 assert_eq!(tool_category("write"), Some(ToolCategory::FileWrite));
1033 assert_eq!(tool_category("read"), Some(ToolCategory::FileRead));
1034 assert_eq!(tool_category("grep"), Some(ToolCategory::FileSearch));
1035 assert_eq!(tool_category("webfetch"), Some(ToolCategory::Network));
1036 assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
1037 assert_eq!(tool_category("mcp__x__y"), None);
1038 }
1039
1040 #[test]
1041 fn provider_trait_list_and_load() {
1042 let (_t, mgr) = setup(BASIC_SQL);
1043 let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1044 assert_eq!(ids, vec!["ses_x".to_string()]);
1045 let v = ConversationProvider::load_conversation(&mgr, "", "ses_x").unwrap();
1046 assert_eq!(v.turns.len(), 2);
1047 }
1048}