1use serde_json::Value;
32
33use crate::error::Result;
34use crate::io::CursorIO;
35use crate::paths::PathResolver;
36use crate::reader::CONTENT_PREFIX;
37use crate::types::{
38 Bubble, CursorSession, CursorSessionMetadata, ToolFormerData, BUBBLE_TYPE_ASSISTANT,
39 BUBBLE_TYPE_USER, TOOL_EDIT_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2, tool_name_for_id,
40};
41use toolpath_convo::{
42 ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError,
43 EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory,
44 ToolInvocation, ToolResult, Turn, unified_diff,
45};
46
47pub const PROVIDER_ID: &str = "cursor";
50
51#[derive(Default)]
53pub struct CursorConvo {
54 io: CursorIO,
55}
56
57impl CursorConvo {
58 pub fn new() -> Self {
59 Self { io: CursorIO::new() }
60 }
61
62 pub fn with_resolver(resolver: PathResolver) -> Self {
63 Self {
64 io: CursorIO::with_resolver(resolver),
65 }
66 }
67
68 pub fn io(&self) -> &CursorIO {
69 &self.io
70 }
71
72 pub fn resolver(&self) -> &PathResolver {
73 self.io.resolver()
74 }
75
76 pub fn read_session(&self, composer_id: &str) -> Result<CursorSession> {
77 self.io.read_session(composer_id)
78 }
79
80 pub fn list_sessions(&self) -> Result<Vec<CursorSessionMetadata>> {
81 self.io.list_session_metadata()
82 }
83
84 pub fn most_recent_session(&self) -> Result<Option<CursorSession>> {
85 let metas = self.list_sessions()?;
86 let pick = metas
91 .iter()
92 .max_by_key(|m| m.last_activity.unwrap_or_else(chrono::DateTime::<chrono::Utc>::default));
93 match pick {
94 Some(m) => Ok(Some(self.read_session(&m.id)?)),
95 None => Ok(None),
96 }
97 }
98
99 pub fn read_all_sessions(&self) -> Result<Vec<CursorSession>> {
102 let metas = self.list_sessions()?;
103 let mut out = Vec::with_capacity(metas.len());
104 for m in metas {
105 match self.read_session(&m.id) {
106 Ok(s) => out.push(s),
107 Err(e) => eprintln!("Warning: could not read composer {}: {}", m.id, e),
108 }
109 }
110 Ok(out)
111 }
112}
113
114pub fn tool_category(tool: u32, name: &str) -> Option<ToolCategory> {
143 if let Some(canonical) = tool_name_for_id(tool)
152 && let Some(cat) = category_by_name(canonical)
153 {
154 return Some(cat);
155 }
156 category_by_name(name)
157}
158
159fn category_by_name(name: &str) -> Option<ToolCategory> {
160 match name {
161 "run_terminal_command_v2"
167 | "run_terminal_commands"
168 | "run_terminal_cmd"
169 | "run_test"
170 | "write_shell_stdin"
171 | "Shell"
172 | "shell" => Some(ToolCategory::Shell),
173
174 "edit_file_v2"
182 | "edit_file"
183 | "edit"
184 | "Edit"
185 | "Write"
186 | "StrReplace"
187 | "delete_file"
188 | "delete"
189 | "new_edit"
190 | "new_file"
191 | "save_file"
192 | "reapply"
193 | "undo_edit"
194 | "apply_agent_diff"
195 | "create_rm_files"
196 | "add_test"
197 | "delete_test"
198 | "fix_lints"
199 | "fix_lints_subagent" => Some(ToolCategory::FileWrite),
200
201 "read_file_v2"
212 | "read_file"
213 | "read"
214 | "Read"
215 | "read_chunk"
216 | "list_dir"
217 | "list_dir_v2"
218 | "ls"
219 | "read_project"
220 | "get_project_structure"
221 | "get_symbols"
222 | "get_tests"
223 | "gotodef"
224 | "summarize_code"
225 | "read_lints"
226 | "read_with_linter"
227 | "read_semsearch_files"
228 | "blame_by_file_path" => Some(ToolCategory::FileRead),
229
230 "glob_file_search"
234 | "Glob"
235 | "glob"
236 | "ripgrep_raw_search"
237 | "ripgrep_search"
238 | "grep_search"
239 | "grep"
240 | "Grep"
241 | "search"
242 | "search_symbols"
243 | "semantic_search"
244 | "semantic_search_full"
245 | "sem_search"
246 | "deep_search"
247 | "deep_search_subagent"
248 | "tool_call_file_search" => Some(ToolCategory::FileSearch),
249
250 "web_search"
256 | "web_fetch"
257 | "fetch_pull_request"
258 | "fetch"
259 | "call_mcp_tool" => Some(ToolCategory::Network),
260
261 "task_v2"
267 | "task"
268 | "Task"
269 | "task_subagent"
270 | "spec_subagent"
271 | "background_composer_followup"
272 | "start_grind_execution"
273 | "start_grind_planning" => Some(ToolCategory::Delegation),
274
275 _ => None,
291 }
292}
293
294pub fn session_to_view(session: &CursorSession) -> ConversationView {
299 Builder::new(session).build()
300}
301
302struct Builder<'a> {
303 session: &'a CursorSession,
304 turns: Vec<Turn>,
305 files_changed_order: Vec<String>,
306 files_changed_seen: std::collections::HashSet<String>,
307 total_usage: TokenUsage,
308 total_usage_set: bool,
309}
310
311impl<'a> Builder<'a> {
312 fn new(session: &'a CursorSession) -> Self {
313 Self {
314 session,
315 turns: Vec::new(),
316 files_changed_order: Vec::new(),
317 files_changed_seen: std::collections::HashSet::new(),
318 total_usage: TokenUsage::default(),
319 total_usage_set: false,
320 }
321 }
322
323 fn build(mut self) -> ConversationView {
324 let mut prev_turn_id: Option<String> = None;
325 for bubble in &self.session.bubbles {
326 let turn = match bubble.kind {
327 BUBBLE_TYPE_USER => self.user_turn(bubble, prev_turn_id.as_deref()),
328 BUBBLE_TYPE_ASSISTANT => self.assistant_turn(bubble, prev_turn_id.as_deref()),
329 _ => continue,
333 };
334 prev_turn_id = Some(turn.id.clone());
335 self.turns.push(turn);
336 }
337
338 let started_at = self
339 .session
340 .started_at()
341 .or_else(|| {
342 self.session
343 .bubbles
344 .first()
345 .and_then(|b| b.created_at_utc())
346 });
347 let last_activity = self
348 .session
349 .last_activity()
350 .or_else(|| {
351 self.session
352 .bubbles
353 .last()
354 .and_then(|b| b.created_at_utc())
355 });
356
357 ConversationView {
358 id: self.session.id().to_string(),
359 started_at,
360 last_activity,
361 turns: self.turns,
362 total_usage: if self.total_usage_set {
363 Some(self.total_usage)
364 } else {
365 None
366 },
367 provider_id: Some(PROVIDER_ID.to_string()),
368 files_changed: self.files_changed_order,
369 session_ids: vec![self.session.id().to_string()],
370 events: Vec::new(),
371 base: Some(SessionBase {
372 working_dir: self
373 .session
374 .workspace_path()
375 .map(|p| p.to_string_lossy().into_owned()),
376 vcs_revision: None,
377 vcs_branch: None,
378 vcs_remote: None,
379 }),
380 producer: Some(ProducerInfo {
381 name: PROVIDER_ID.into(),
382 version: self.session.data.agent_backend.clone(),
383 }),
384 }
385 }
386
387 fn user_turn(&self, bubble: &Bubble, parent: Option<&str>) -> Turn {
388 let environment = self.environment();
389 Turn {
390 id: bubble.bubble_id.clone(),
391 parent_id: parent.map(str::to_string),
392 group_id: None,
393 role: Role::User,
394 timestamp: bubble.created_at.clone().unwrap_or_default(),
395 text: bubble.text.clone(),
396 thinking: None,
397 tool_uses: Vec::new(),
398 model: None,
399 stop_reason: None,
400 token_usage: None,
401 attributed_token_usage: None,
402 environment,
403 delegations: Vec::new(),
404 file_mutations: Vec::new(),
405 }
406 }
407
408 fn assistant_turn(&mut self, bubble: &Bubble, parent: Option<&str>) -> Turn {
409 let environment = self.environment();
410
411 let thinking_chunks: Vec<String> = bubble
415 .all_thinking_blocks
416 .iter()
417 .filter(|t| !t.text.is_empty())
418 .map(|t| t.text.clone())
419 .collect();
420 let thinking = if thinking_chunks.is_empty() {
421 None
422 } else {
423 Some(thinking_chunks.join("\n\n"))
424 };
425
426 let mut tool_uses = Vec::new();
428 let mut file_mutations: Vec<FileMutation> = Vec::new();
429 if let Some(tf) = &bubble.tool_former_data {
430 let invocation = self.to_invocation(tf);
431 if invocation.category == Some(ToolCategory::FileWrite)
432 && let Some(fm) = self.file_mutation_for_edit(tf, &invocation.id)
433 {
434 if self.files_changed_seen.insert(fm.path.clone()) {
435 self.files_changed_order.push(fm.path.clone());
436 }
437 file_mutations.push(fm);
438 }
439 tool_uses.push(invocation);
440 }
441
442 let token_usage = bubble.token_count.as_ref().and_then(|t| {
454 let u = TokenUsage {
455 input_tokens: t.input_tokens.map(|n| n as u32).filter(|n| *n > 0),
456 output_tokens: t.output_tokens.map(|n| n as u32).filter(|n| *n > 0),
457 cache_read_tokens: None,
458 cache_write_tokens: None,
459 ..Default::default()
460 };
461 if u.input_tokens.is_none() && u.output_tokens.is_none() {
462 None
463 } else {
464 Some(u)
465 }
466 });
467 if let Some(u) = &token_usage {
468 accumulate(&mut self.total_usage, u);
469 self.total_usage_set = true;
470 }
471
472 let model = bubble
474 .model_info
475 .as_ref()
476 .and_then(|m| m.model_name.clone())
477 .filter(|m| !m.is_empty())
478 .or_else(|| self.session.data.default_model().map(str::to_string));
479
480 Turn {
481 id: bubble.bubble_id.clone(),
482 parent_id: parent.map(str::to_string),
483 group_id: None,
484 role: Role::Assistant,
485 timestamp: bubble.created_at.clone().unwrap_or_default(),
486 text: bubble.text.clone(),
487 thinking,
488 tool_uses,
489 model,
490 stop_reason: None,
491 token_usage,
492 attributed_token_usage: None,
493 environment,
494 delegations: Vec::new(),
495 file_mutations,
496 }
497 }
498
499 fn to_invocation(&self, tf: &ToolFormerData) -> ToolInvocation {
500 let mut input = tf.parse_params().unwrap_or(Value::Null);
501 if tf.tool == TOOL_EDIT_FILE_V2
502 && let Value::Object(obj) = &mut input
503 {
504 if let Some(path) = obj
505 .remove("relativeWorkspacePath")
506 .and_then(|v| v.as_str().map(str::to_string))
507 {
508 obj.insert("file_path".into(), Value::String(path));
509 }
510 if let Ok(Some(result)) = tf.parse_result() {
511 let before = blob_hash(&result, "beforeContentId")
512 .as_deref()
513 .and_then(|h| self.session.content_blob(h));
514 let after = blob_hash(&result, "afterContentId")
515 .as_deref()
516 .and_then(|h| self.session.content_blob(h));
517 match (before, after) {
518 (Some(b), Some(a)) if !b.is_empty() && b != a => {
519 obj.insert("old_string".into(), Value::String(b.to_string()));
520 obj.insert("new_string".into(), Value::String(a.to_string()));
521 }
522 (_, Some(a)) => {
523 obj.insert("content".into(), Value::String(a.to_string()));
524 }
525 _ => {}
526 }
527 }
528 }
529 let result = match (tf.is_completed(), tf.parse_result().ok().flatten()) {
530 (true, Some(v)) => Some(ToolResult {
531 content: result_to_text(tf, &v),
532 is_error: false,
533 }),
534 (false, Some(v)) if !tf.is_error() => Some(ToolResult {
537 content: result_to_text(tf, &v),
538 is_error: false,
539 }),
540 (false, _) if tf.is_error() => Some(ToolResult {
541 content: format!("tool {} errored", tf.name),
542 is_error: true,
543 }),
544 _ => None,
545 };
546 ToolInvocation {
547 id: tf.tool_call_id.clone(),
548 name: tf.name.clone(),
549 input,
550 result,
551 category: tool_category(tf.tool, &tf.name),
552 }
553 }
554
555 fn file_mutation_for_edit(
556 &self,
557 tf: &ToolFormerData,
558 tool_id: &str,
559 ) -> Option<FileMutation> {
560 let params = tf.parse_params().ok()?;
561 let path = params
562 .get("relativeWorkspacePath")
563 .or_else(|| params.get("path"))
564 .or_else(|| params.get("file_path"))
565 .and_then(|v| v.as_str())?;
566
567 let result = tf.parse_result().ok().flatten()?;
568 let before_hash = blob_hash(&result, "beforeContentId");
569 let after_hash = blob_hash(&result, "afterContentId");
570
571 let before = before_hash
572 .as_deref()
573 .and_then(|h| self.session.content_blob(h))
574 .map(str::to_string);
575 let after = after_hash
576 .as_deref()
577 .and_then(|h| self.session.content_blob(h))
578 .map(str::to_string);
579
580 let operation = match (before.as_deref(), after.as_deref()) {
581 (None, _) => "update",
582 (Some(b), Some(a)) if b.is_empty() && !a.is_empty() => "add",
583 (Some(_), Some(_)) => "update",
584 (Some(_), None) => "delete",
585 };
586
587 let raw_diff = match (&before, &after) {
588 (Some(b), Some(a)) if b != a => Some(unified_diff(path, b, a)),
589 _ => None,
590 };
591
592 Some(FileMutation {
593 path: path.to_string(),
594 tool_id: Some(tool_id.to_string()),
595 operation: Some(operation.to_string()),
596 raw_diff,
597 before,
598 after,
599 rename_to: None,
600 })
601 }
602
603 fn environment(&self) -> Option<EnvironmentSnapshot> {
604 let wd = self
605 .session
606 .workspace_path()?
607 .to_string_lossy()
608 .into_owned();
609 Some(EnvironmentSnapshot {
610 working_dir: Some(wd),
611 vcs_branch: None,
612 vcs_revision: None,
613 })
614 }
615}
616
617fn result_to_text(tf: &ToolFormerData, v: &Value) -> String {
624 match tf.tool {
625 TOOL_RUN_TERMINAL_COMMAND_V2 => v
626 .get("output")
627 .and_then(|s| s.as_str())
628 .map(str::to_string)
629 .unwrap_or_else(|| serde_json::to_string(v).unwrap_or_default()),
630 TOOL_EDIT_FILE_V2 => {
631 let path = tf
637 .parse_params()
638 .ok()
639 .and_then(|p| {
640 p.get("relativeWorkspacePath")
641 .and_then(|v| v.as_str())
642 .map(str::to_string)
643 });
644 match path {
645 Some(p) => format!("edited {p}"),
646 None => "edited file".into(),
647 }
648 }
649 _ => {
650 if let Value::Object(map) = v
658 && map.len() == 1
659 && let Some(Value::String(s)) = map.get("output")
660 {
661 return s.clone();
662 }
663 serde_json::to_string(v).unwrap_or_default()
664 }
665 }
666}
667
668fn blob_hash(v: &Value, field: &str) -> Option<String> {
669 let raw = v.get(field)?.as_str()?;
670 raw.strip_prefix(CONTENT_PREFIX).map(str::to_string)
671}
672
673fn accumulate(total: &mut TokenUsage, delta: &TokenUsage) {
674 if let Some(n) = delta.input_tokens {
675 *total.input_tokens.get_or_insert(0) += n;
676 }
677 if let Some(n) = delta.output_tokens {
678 *total.output_tokens.get_or_insert(0) += n;
679 }
680 if let Some(n) = delta.cache_read_tokens {
681 *total.cache_read_tokens.get_or_insert(0) += n;
682 }
683 if let Some(n) = delta.cache_write_tokens {
684 *total.cache_write_tokens.get_or_insert(0) += n;
685 }
686}
687
688impl ConversationProvider for CursorConvo {
691 fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
692 let metas = self
693 .list_sessions()
694 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
695 Ok(metas.into_iter().map(|m| m.id).collect())
696 }
697
698 fn load_conversation(
699 &self,
700 _project: &str,
701 conversation_id: &str,
702 ) -> toolpath_convo::Result<ConversationView> {
703 let s = self
704 .read_session(conversation_id)
705 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
706 Ok(session_to_view(&s))
707 }
708
709 fn load_metadata(
710 &self,
711 _project: &str,
712 conversation_id: &str,
713 ) -> toolpath_convo::Result<ConversationMeta> {
714 let m = self
715 .io
716 .read_session_metadata(conversation_id)
717 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
718 Ok(ConversationMeta {
719 id: m.id,
720 started_at: m.started_at,
721 last_activity: m.last_activity,
722 message_count: m.message_count,
723 file_path: m.workspace_path,
724 predecessor: None,
725 successor: None,
726 })
727 }
728
729 fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
730 let metas = self
731 .list_sessions()
732 .map_err(|e| ConvoTraitError::Provider(e.to_string()))?;
733 Ok(metas
734 .into_iter()
735 .map(|m| ConversationMeta {
736 id: m.id,
737 started_at: m.started_at,
738 last_activity: m.last_activity,
739 message_count: m.message_count,
740 file_path: m.workspace_path,
741 predecessor: None,
742 successor: None,
743 })
744 .collect())
745 }
746}
747
748#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::reader::tests::{BASIC_FIXTURE, fixture_db};
754 use chrono::{TimeZone, Utc};
755 use std::fs;
756 use tempfile::TempDir;
757
758 fn setup() -> (TempDir, CursorConvo) {
759 let temp = TempDir::new().unwrap();
760 let user_data = temp.path().join("UserData");
761 let global = user_data.join("User").join("globalStorage");
762 fs::create_dir_all(&global).unwrap();
763 let src = fixture_db(BASIC_FIXTURE);
764 fs::copy(src.path(), global.join("state.vscdb")).unwrap();
765 let resolver = PathResolver::new()
766 .with_home(temp.path())
767 .with_anysphere_dir(temp.path().join(".cursor"))
768 .with_user_data_dir(user_data);
769 (temp, CursorConvo::with_resolver(resolver))
770 }
771
772 #[test]
773 fn basic_view_shape() {
774 let (_t, mgr) = setup();
775 let session = mgr.read_session("c1").unwrap();
776 let view = session_to_view(&session);
777
778 assert_eq!(view.id, "c1");
779 assert_eq!(view.provider_id.as_deref(), Some("cursor"));
780 assert_eq!(view.turns.len(), 3);
781
782 assert_eq!(view.turns[0].role, Role::User);
783 assert_eq!(view.turns[0].text, "hello");
784
785 assert_eq!(view.turns[1].role, Role::Assistant);
786 assert_eq!(view.turns[1].text, "hi back");
787 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-7"));
788 assert_eq!(view.turns[1].token_usage.as_ref().unwrap().input_tokens, Some(10));
789
790 assert_eq!(view.turns[2].role, Role::Assistant);
791 assert_eq!(view.turns[2].tool_uses.len(), 1);
792 assert_eq!(view.turns[2].tool_uses[0].name, "edit_file_v2");
793 assert_eq!(
794 view.turns[2].tool_uses[0].category,
795 Some(ToolCategory::FileWrite)
796 );
797 }
798
799 #[test]
800 fn base_and_producer_populated() {
801 let (_t, mgr) = setup();
802 let view = session_to_view(&mgr.read_session("c1").unwrap());
803 let base = view.base.as_ref().unwrap();
804 assert_eq!(base.working_dir.as_deref(), Some("/p"));
805 let producer = view.producer.as_ref().unwrap();
806 assert_eq!(producer.name, "cursor");
807 assert_eq!(producer.version.as_deref(), Some("cursor-agent"));
808 }
809
810 #[test]
811 fn file_mutation_populated_with_diff() {
812 let (_t, mgr) = setup();
813 let view = session_to_view(&mgr.read_session("c1").unwrap());
814 let edit_turn = &view.turns[2];
815 assert_eq!(edit_turn.file_mutations.len(), 1);
816 let fm = &edit_turn.file_mutations[0];
817 assert_eq!(fm.path, "/p/x.rs");
818 assert_eq!(fm.operation.as_deref(), Some("add"));
819 assert_eq!(fm.before.as_deref(), Some(""));
820 assert_eq!(fm.after.as_deref(), Some("fn main() {}"));
821 assert!(fm.raw_diff.as_ref().unwrap().contains("+fn main() {}"));
822 assert_eq!(fm.tool_id.as_deref(), Some("tool_a"));
823 }
824
825 #[test]
826 fn files_changed_deduped_in_first_touch_order() {
827 let (_t, mgr) = setup();
828 let view = session_to_view(&mgr.read_session("c1").unwrap());
829 assert_eq!(view.files_changed, vec!["/p/x.rs"]);
830 }
831
832 #[test]
833 fn parent_id_chains_turns() {
834 let (_t, mgr) = setup();
835 let view = session_to_view(&mgr.read_session("c1").unwrap());
836 assert!(view.turns[0].parent_id.is_none());
837 assert_eq!(view.turns[1].parent_id.as_deref(), Some("u1"));
838 assert_eq!(view.turns[2].parent_id.as_deref(), Some("a1"));
839 }
840
841 #[test]
842 fn shell_tool_result_carries_output_and_exit_code() {
843 let setup_sql = r#"
844 INSERT INTO cursorDiskKV (key, value) VALUES
845 ('composerData:cs', '{"_v":16,"composerId":"cs","fullConversationHeadersOnly":[{"bubbleId":"b1","type":2}]}'),
846 ('bubbleId:cs:b1',
847 '{"_v":3,"type":2,"bubbleId":"b1","createdAt":"2026-06-01T00:00:00Z","text":"","capabilityType":15,"toolFormerData":{"tool":15,"toolCallId":"tc","status":"completed","name":"run_terminal_command_v2","params":"{\"command\":\"ls\"}","result":"{\"output\":\"a\\nb\\n\",\"exitCode\":0,\"rejected\":false,\"notInterrupted\":true}"}}');
848 "#;
849 let f = fixture_db(setup_sql);
850 let r = crate::reader::DbReader::open(f.path()).unwrap();
851 let s = r.load_session("cs").unwrap();
852 let view = session_to_view(&s);
853 let tool = &view.turns[0].tool_uses[0];
854 assert_eq!(tool.category, Some(ToolCategory::Shell));
855 let result = tool.result.as_ref().unwrap();
856 assert!(!result.is_error);
857 assert_eq!(result.content, "a\nb\n");
858 assert_eq!(tool.input["command"], "ls");
859 }
860
861 #[test]
862 fn errored_tool_marks_result_as_error() {
863 let setup_sql = r#"
864 INSERT INTO cursorDiskKV (key, value) VALUES
865 ('composerData:ce', '{"_v":16,"composerId":"ce","fullConversationHeadersOnly":[{"bubbleId":"b1","type":2}]}'),
866 ('bubbleId:ce:b1',
867 '{"_v":3,"type":2,"bubbleId":"b1","createdAt":"2026-06-01T00:00:00Z","capabilityType":15,"toolFormerData":{"tool":38,"toolCallId":"tx","status":"error","name":"edit_file_v2","params":"{\"relativeWorkspacePath\":\"/p/x.rs\"}","result":null}}');
868 "#;
869 let f = fixture_db(setup_sql);
870 let r = crate::reader::DbReader::open(f.path()).unwrap();
871 let s = r.load_session("ce").unwrap();
872 let view = session_to_view(&s);
873 let tool = &view.turns[0].tool_uses[0];
874 assert!(tool.result.as_ref().unwrap().is_error);
875 }
876
877 #[test]
878 fn unknown_tool_id_still_emits_invocation_uncategorized() {
879 let setup_sql = r#"
880 INSERT INTO cursorDiskKV (key, value) VALUES
881 ('composerData:cu', '{"_v":16,"composerId":"cu","fullConversationHeadersOnly":[{"bubbleId":"b1","type":2}]}'),
882 ('bubbleId:cu:b1',
883 '{"_v":3,"type":2,"bubbleId":"b1","createdAt":"2026-06-01T00:00:00Z","capabilityType":15,"toolFormerData":{"tool":12345,"toolCallId":"tz","status":"completed","name":"future_thing_v9","params":"{\"x\":1}","result":"{\"ok\":true}"}}');
884 "#;
885 let f = fixture_db(setup_sql);
886 let r = crate::reader::DbReader::open(f.path()).unwrap();
887 let s = r.load_session("cu").unwrap();
888 let view = session_to_view(&s);
889 let tool = &view.turns[0].tool_uses[0];
890 assert_eq!(tool.name, "future_thing_v9");
891 assert_eq!(tool.category, None);
892 assert_eq!(tool.input["x"], 1);
893 }
894
895 #[test]
896 fn started_at_uses_composer_when_present() {
897 let (_t, mgr) = setup();
898 let view = session_to_view(&mgr.read_session("c1").unwrap());
899 assert_eq!(
900 view.started_at.unwrap(),
901 Utc.timestamp_millis_opt(1000).single().unwrap()
902 );
903 }
904
905 #[test]
906 fn provider_trait_lists_and_loads() {
907 let (_t, mgr) = setup();
908 let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
909 assert_eq!(ids, vec!["c1".to_string()]);
910 let v = ConversationProvider::load_conversation(&mgr, "", "c1").unwrap();
911 assert_eq!(v.turns.len(), 3);
912 let m = ConversationProvider::load_metadata(&mgr, "", "c1").unwrap();
913 assert_eq!(m.message_count, 3);
914 }
915
916 #[test]
917 fn tool_category_observed_numeric_ids() {
918 assert_eq!(
920 tool_category(15, "run_terminal_command_v2"),
921 Some(ToolCategory::Shell)
922 );
923 assert_eq!(tool_category(38, "edit_file_v2"), Some(ToolCategory::FileWrite));
924 assert_eq!(tool_category(40, "read_file_v2"), Some(ToolCategory::FileRead));
925 assert_eq!(tool_category(41, "ripgrep_raw_search"), Some(ToolCategory::FileSearch));
926 assert_eq!(tool_category(42, "glob_file_search"), Some(ToolCategory::FileSearch));
927 assert_eq!(tool_category(48, "task_v2"), Some(ToolCategory::Delegation));
928 }
929
930 #[test]
931 fn tool_category_jsonl_friendly_names() {
932 assert_eq!(tool_category(9999, "Shell"), Some(ToolCategory::Shell));
934 assert_eq!(tool_category(9999, "Write"), Some(ToolCategory::FileWrite));
935 assert_eq!(tool_category(9999, "StrReplace"), Some(ToolCategory::FileWrite));
936 assert_eq!(tool_category(9999, "Read"), Some(ToolCategory::FileRead));
937 assert_eq!(tool_category(9999, "Glob"), Some(ToolCategory::FileSearch));
938 assert_eq!(tool_category(9999, "Grep"), Some(ToolCategory::FileSearch));
939 assert_eq!(tool_category(9999, "Task"), Some(ToolCategory::Delegation));
940 assert_eq!(tool_category(9999, "Edit"), Some(ToolCategory::FileWrite));
941 }
942
943 #[test]
944 fn tool_category_aiserver_v1_protobuf_names() {
945 let shell = [
948 "run_terminal_command_v2",
949 "run_terminal_commands",
950 "run_test",
951 "write_shell_stdin",
952 ];
953 for n in shell {
954 assert_eq!(tool_category(0, n), Some(ToolCategory::Shell), "{n}");
955 }
956
957 let file_write = [
958 "edit_file_v2",
959 "edit_file",
960 "delete_file",
961 "new_edit",
962 "new_file",
963 "save_file",
964 "reapply",
965 "undo_edit",
966 "apply_agent_diff",
967 "create_rm_files",
968 "add_test",
969 "delete_test",
970 "fix_lints",
971 "fix_lints_subagent",
972 ];
973 for n in file_write {
974 assert_eq!(tool_category(0, n), Some(ToolCategory::FileWrite), "{n}");
975 }
976
977 let file_read = [
978 "read_file_v2",
979 "read_file",
980 "read_chunk",
981 "list_dir",
982 "list_dir_v2",
983 "read_project",
984 "get_project_structure",
985 "get_symbols",
986 "get_tests",
987 "gotodef",
988 "summarize_code",
989 "read_lints",
990 "read_with_linter",
991 "read_semsearch_files",
992 ];
993 for n in file_read {
994 assert_eq!(tool_category(0, n), Some(ToolCategory::FileRead), "{n}");
995 }
996
997 let file_search = [
998 "glob_file_search",
999 "ripgrep_raw_search",
1000 "ripgrep_search",
1001 "search",
1002 "search_symbols",
1003 "semantic_search",
1004 "semantic_search_full",
1005 "deep_search",
1006 "deep_search_subagent",
1007 "tool_call_file_search",
1008 ];
1009 for n in file_search {
1010 assert_eq!(tool_category(0, n), Some(ToolCategory::FileSearch), "{n}");
1011 }
1012
1013 let network = [
1014 "web_search",
1015 "web_fetch",
1016 "fetch_pull_request",
1017 "call_mcp_tool",
1018 ];
1019 for n in network {
1020 assert_eq!(tool_category(0, n), Some(ToolCategory::Network), "{n}");
1021 }
1022
1023 let delegation = [
1024 "task_v2",
1025 "task",
1026 "task_subagent",
1027 "spec_subagent",
1028 "background_composer_followup",
1029 "start_grind_execution",
1030 "start_grind_planning",
1031 ];
1032 for n in delegation {
1033 assert_eq!(tool_category(0, n), Some(ToolCategory::Delegation), "{n}");
1034 }
1035 }
1036
1037 #[test]
1038 fn tool_category_uncategorized_drops_to_none() {
1039 let none_tools = [
1042 "create_plan",
1044 "todo_read",
1045 "todo_write",
1046 "read_todos",
1047 "update_todos",
1048 "get_mcp_tools",
1050 "list_mcp_resources",
1051 "read_mcp_resource",
1052 "mcp",
1053 "mcp_auth",
1054 "ask_question",
1056 "communicate_update",
1057 "send_final_summary",
1058 "switch_mode",
1059 "set_run",
1060 "await",
1061 "await_task",
1062 "end",
1063 "partial",
1064 "truncated",
1065 "reflect",
1066 "add_ui_step",
1067 "report_bug",
1069 "report_bugfix_results",
1070 "record_ci_investigation_findings",
1071 "ai_attribution",
1072 "set_active_branch",
1073 "edit_pr_labels",
1074 "update_pr_code_tour",
1075 "pr_management",
1076 "knowledge_base",
1077 "fetch_rules",
1078 "update_project",
1079 "replace_env",
1080 "setup_vm_environment",
1081 "generate_image",
1082 "computer_use",
1083 "record_screen",
1084 "create_diagram",
1085 "future_tool_does_not_exist",
1087 ];
1088 for n in none_tools {
1089 assert_eq!(tool_category(0, n), None, "{n} should be uncategorized");
1090 }
1091 }
1092
1093 #[test]
1094 fn tool_category_unknown_id_falls_through_to_name() {
1095 assert_eq!(tool_category(7777, "edit_file_v2"), Some(ToolCategory::FileWrite));
1097 assert_eq!(tool_category(7777, "future_tool"), None);
1098 }
1099}