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