Skip to main content

toolpath_cursor/
provider.rs

1//! Implementation of `toolpath-convo` traits for Cursor sessions.
2//!
3//! Cursor's bubble store is already linear (one bubble per UI message,
4//! ordered by `fullConversationHeadersOnly`), so the mapping per turn
5//! is direct:
6//!
7//! - User bubble (`type: 1`) → [`Turn`] with `role: User`, `text`
8//!   from `bubble.text`.
9//! - Assistant text/thinking/tool bubble (`type: 2`) → [`Turn`] with
10//!   `role: Assistant`. The bubble's `text`, `allThinkingBlocks`,
11//!   and `toolFormerData` populate `text`, `thinking`, and a single
12//!   `tool_uses[0]` respectively (Cursor stores at most one tool
13//!   call per bubble in observed data; multi-tool turns fan out to
14//!   multiple bubbles with the same `requestId`).
15//!
16//! File mutations are resolved at view-build time: when a tool bubble
17//! carries `edit_file_v2` with `beforeContentId`/`afterContentId`, the
18//! provider looks the content blobs up from
19//! [`CursorSession::content_blobs`] and emits a [`FileMutation`] with
20//! `before`, `after`, and a unified diff computed via
21//! [`toolpath_convo::unified_diff`].
22//!
23//! Provider-specific UI metadata (Cursor's per-bubble `checkpointId`,
24//! `requestId`, `richText`, capability flags, the
25//! `anysphere.cursor-commits` checkpoint store, …) is **not** smuggled
26//! through the IR. The IR has no `Turn.extra`; round-tripping a Cursor
27//! session through `ConversationView` drops those fields by design.
28//! The bubble store on disk is the source of truth for anything that
29//! needs them.
30
31use 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
47/// The dispatch family used in `path.meta.source` and
48/// `ConversationView.provider_id`.
49pub const PROVIDER_ID: &str = "cursor";
50
51/// Provider for Cursor sessions.
52#[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        // list_session_metadata follows the header order, which Cursor
87        // maintains roughly newest-first. Fall back to last_activity
88        // when present so this stays right even if the header order
89        // ever drifts.
90        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    /// Read every session that has bubbles on disk. Expensive on
100    /// large histories.
101    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
114// ── Tool classification ────────────────────────────────────────────────
115
116/// Map a Cursor tool to toolpath's category ontology. `tool` is the
117/// numeric id from `toolFormerData.tool`; `name` is the internal tool
118/// name. Both are needed because Anysphere has historically renamed
119/// tools while keeping ids stable and vice versa.
120/// Classify a Cursor tool into [`ToolCategory`].
121///
122/// The mapping covers every tool name observed in:
123/// - the 71 `aiserver.v1.<Name>Params` protobuf messages embedded in
124///   Cursor.app's workbench bundle
125///   (`Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`),
126/// - the 47 `*ToolCall` discriminators in the same bundle's switch
127///   statements (UI-side aliases and control-flow markers), and
128/// - the agent-side JSONL transcript vocabulary (`Shell`, `Read`,
129///   `Write`, `StrReplace`, `Glob`, `Grep`, `Task`).
130///
131/// Numeric ids only fire for ones we've **observed in the wild**
132/// (`TOOL_*` consts in [`crate::types`]). Anysphere assigns these
133/// at the cloud-side; the workbench bundle doesn't expose the full
134/// enum literally and we'd rather miss a numeric id than guess one
135/// wrong. Tools whose id we haven't seen still classify by `name`.
136///
137/// Tools that have no clean fit in toolpath's category ontology
138/// (planning/`todo_*`, MCP machinery, UI control flow like `await` /
139/// `truncated` / `end`, reporting tools, VCS read-only queries,
140/// screenshot/diagram/image generation) return `None`. Consumers
141/// still see the `name` + `input` on the `ToolInvocation`.
142pub fn tool_category(tool: u32, name: &str) -> Option<ToolCategory> {
143    // Resolve the tool by id when possible — the canonical `tool`
144    // value beats whatever name the bubble carries (names can drift
145    // between releases; ids are the cloud-server enum and survive
146    // renames). But when the id is `0` (UNSPECIFIED) or otherwise
147    // canonical-but-uncategorized, the literal `name` still might
148    // classify (foreign provider names, JSONL-friendly aliases),
149    // so we try it as a fallback rather than short-circuiting to
150    // `None`.
151    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        // ── Shell ────────────────────────────────────────────────
162        // `RunTerminalCommandV2` (observed id 15), `RunTerminalCommands`,
163        // `RunTest`, plus the JSONL-side friendly `Shell`/`shell` and
164        // the legacy `run_terminal_cmd`. `WriteShellStdin` writes to
165        // an already-running shell session so it's shell-category too.
166        "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        // ── FileWrite ────────────────────────────────────────────
175        // Includes legacy v1 (`edit_file`, `new_file`, `new_edit`),
176        // current v2 (`edit_file_v2`), test-mgmt (`add_test`/`delete_test`),
177        // lint-fix (`fix_lints`/`fix_lints_subagent`), bulk file ops
178        // (`create_rm_files`, `save_file`, `undo_edit`, `apply_agent_diff`,
179        // `reapply`), and the agent-side friendly aliases
180        // `Write`/`StrReplace`/`edit`/`delete`/`Edit`.
181        "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        // ── FileRead ─────────────────────────────────────────────
202        // Includes legacy v1 (`read_file`, `list_dir`), current v2
203        // (`read_file_v2`, `list_dir_v2`), partial reads (`read_chunk`),
204        // project-level (`read_project`, `get_project_structure`),
205        // code-intel (`get_symbols`, `gotodef`, `summarize_code`),
206        // test discovery (`get_tests`), lint reading
207        // (`read_lints`, `read_with_linter`), semsearch hydration
208        // (`read_semsearch_files`), the friendly aliases
209        // `Read`/`read`/`ls`, and the read-only PR query
210        // `blame_by_file_path`.
211        "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        // ── FileSearch ───────────────────────────────────────────
231        // Glob/grep family, semantic search variants, and the
232        // discriminator-level alias `sem_search`.
233        "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        // ── Network ──────────────────────────────────────────────
251        // Network covers everything that talks to a remote service
252        // outside the local fs / shell: web fetch + search, GitHub
253        // PR retrieval, and MCP tool dispatch (`call_mcp_tool`
254        // proxies a model-driven call to a remote MCP server).
255        "web_search"
256        | "web_fetch"
257        | "fetch_pull_request"
258        | "fetch"
259        | "call_mcp_tool" => Some(ToolCategory::Network),
260
261        // ── Delegation ───────────────────────────────────────────
262        // `Task`/`task_v2` is the dispatch primitive. `TaskSubagent`,
263        // `SpecSubagent`, `BackgroundComposerFollowup`,
264        // `StartGrindExecution`/`StartGrindPlanning` (background-
265        // agent "grind" mode) all spin up sub-work.
266        "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        // ── Uncategorized — no IR slot ──────────────────────────
276        // Planning / todos: create_plan, todo_read, todo_write,
277        //   read_todos, update_todos.
278        // MCP control plane: get_mcp_tools, list_mcp_resources,
279        //   read_mcp_resource, mcp, mcp_auth.
280        // UI / control flow: ask_question, communicate_update,
281        //   send_final_summary, switch_mode, set_run, await,
282        //   await_task, end, partial, truncated, reflect, add_ui_step.
283        // Reporting: report_bug, report_bugfix_results,
284        //   record_ci_investigation_findings, ai_attribution.
285        // VCS write: set_active_branch, edit_pr_labels,
286        //   update_pr_code_tour, pr_management.
287        // Misc: knowledge_base, fetch_rules, update_project,
288        //   replace_env, setup_vm_environment, generate_image,
289        //   computer_use, record_screen, create_diagram.
290        _ => None,
291    }
292}
293
294// ── Session → ConversationView ─────────────────────────────────────────
295
296/// Convert a Cursor [`CursorSession`] into the provider-agnostic
297/// [`ConversationView`].
298pub 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                // Unknown bubble kind — skip silently. A new Anysphere
330                // bubble kind would land here; we'd rather emit a
331                // shorter conversation than break the parse.
332                _ => 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        // Thinking: join `allThinkingBlocks[*].text`. Empty when Cursor
412        // recorded a thinking-duration on the header but no body
413        // (common in observed sessions).
414        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        // Tool call (at most one per bubble in observed data).
427        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        // Token usage. POSSIBLE GAP, UNVERIFIED: we read only the bubble's
443        // `tokenCount`. When that's `{0,0}` (as in the one real session we
444        // have) we report `None`. Community exporters read fallback fields
445        // (a snake_case `usage` object, `contextWindowStatusAtCreation`,
446        // `promptDryRunInfo`), which hints `tokenCount` isn't always
447        // sufficient — but we have too little real Cursor data to know how
448        // often it's empty or to verify those field shapes. Wiring fallbacks
449        // in needs a live session with non-zero counts. We report `None`
450        // rather than fabricate — never derive usage from the
451        // `promptTokenBreakdown`/`contextUsagePercent` estimates, which are
452        // context-size gauges, not billed spend.
453        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        // Model: per-bubble modelInfo wins, then composer default.
473        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            // Server may emit a result on `running` too; treat any
535            // present non-null result as the tool's output.
536            (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
617/// Render a tool result `Value` to the text the model would have
618/// seen. For shell commands that's the stdout/stderr stream; for
619/// edits it's the descriptive summary; everything else falls back
620/// to the JSON serialization, with a special case for the
621/// `{"output": "..."}` envelope our own projector wraps non-native
622/// results in (keeps `IR → cursor → IR` symmetric for unknown tools).
623fn 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            // The literal result is `{beforeContentId, afterContentId}` —
632            // the model sees a synthesized "OK, edited X" line, not these
633            // ids. We surface a deterministic summary so consumers
634            // (audit, diff inspection) get something readable while the
635            // structured file mutation carries the real payload.
636            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 the result is the single-field envelope
651            // `{"output": "..."}` (the shape our projector emits for
652            // tools without a native Cursor wire shape), unwrap it so
653            // the round-trip is byte-symmetric. Otherwise hand back
654            // the raw JSON — that's what the model would have seen
655            // for native Cursor wire shapes like
656            // `{"directories":[…]}` from `glob_file_search`.
657            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
688// ── ConversationProvider impl ──────────────────────────────────────────
689
690impl 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// ── Tests ────────────────────────────────────────────────────────────
749
750#[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        // Observed in the wild — these come from `toolFormerData.tool`.
919        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        // Agent-side names from the JSONL transcript layer.
933        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        // The 71 `aiserver.v1.*Params` types from Cursor's workbench
946        // bundle, classified into IR ontology.
947        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        // Tools that don't fit IR ontology stay `None` — callers
1040        // still see `name` + `input` on the ToolInvocation.
1041        let none_tools = [
1042            // Planning
1043            "create_plan",
1044            "todo_read",
1045            "todo_write",
1046            "read_todos",
1047            "update_todos",
1048            // MCP control plane
1049            "get_mcp_tools",
1050            "list_mcp_resources",
1051            "read_mcp_resource",
1052            "mcp",
1053            "mcp_auth",
1054            // UI / control
1055            "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            // Reporting / VCS write / misc
1068            "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            // Truly unknown
1086            "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        // Future numeric ids we haven't seen still classify via name.
1096        assert_eq!(tool_category(7777, "edit_file_v2"), Some(ToolCategory::FileWrite));
1097        assert_eq!(tool_category(7777, "future_tool"), None);
1098    }
1099}