Skip to main content

toolpath_cursor/
project.rs

1//! Maps a [`ConversationView`] back to a [`CursorSession`].
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use serde_json::{Map, Value, json};
8use sha2::{Digest, Sha256};
9use toolpath_convo::{
10    ConversationProjector, ConversationView, ConvoError, FileMutation, Result, Role,
11    ToolInvocation, Turn,
12};
13
14use crate::reader::CONTENT_PREFIX;
15use crate::types::{
16    BUBBLE_TYPE_ASSISTANT, BUBBLE_TYPE_USER, Bubble, BubbleGrouping, BubbleHeader, CAPABILITY_TOOL,
17    ComposerData, ComposerHead, CursorSession, ModelConfig, ModelInfo, SelectedModel,
18    TOOL_EDIT_FILE_V2, TOOL_GLOB_FILE_SEARCH, TOOL_READ_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2,
19    TOOL_TASK_V2, ThinkingBlock, TokenCount, ToolFormerData, WorkspaceIdentifier, WorkspaceUri,
20};
21
22const DEFAULT_AGENT_BACKEND: &str = "cursor-agent";
23const DEFAULT_MODEL_NAME: &str = "default";
24const DEFAULT_UNIFIED_MODE: &str = "agent";
25const DEFAULT_FORCE_MODE: &str = "edit";
26const COMPOSER_SCHEMA_V: u32 = 16;
27const BUBBLE_SCHEMA_V: u32 = 3;
28const UNIFIED_MODE_AGENT: u32 = 2;
29const CAPABILITY_THINKING: u32 = crate::types::CAPABILITY_THINKING;
30
31#[derive(Debug, Clone, Default)]
32pub struct CursorProjector {
33    pub composer_id: Option<String>,
34    pub workspace_id: Option<String>,
35    pub workspace_path: Option<PathBuf>,
36    pub agent_backend: Option<String>,
37    pub default_model: Option<String>,
38    pub title: Option<String>,
39}
40
41impl CursorProjector {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    pub fn with_composer_id(mut self, id: impl Into<String>) -> Self {
47        self.composer_id = Some(id.into());
48        self
49    }
50
51    pub fn with_workspace_id(mut self, id: impl Into<String>) -> Self {
52        self.workspace_id = Some(id.into());
53        self
54    }
55
56    pub fn with_workspace_path(mut self, p: impl Into<PathBuf>) -> Self {
57        self.workspace_path = Some(p.into());
58        self
59    }
60
61    pub fn with_agent_backend(mut self, b: impl Into<String>) -> Self {
62        self.agent_backend = Some(b.into());
63        self
64    }
65
66    pub fn with_default_model(mut self, m: impl Into<String>) -> Self {
67        self.default_model = Some(m.into());
68        self
69    }
70
71    pub fn with_title(mut self, t: impl Into<String>) -> Self {
72        self.title = Some(t.into());
73        self
74    }
75}
76
77impl ConversationProjector for CursorProjector {
78    type Output = CursorSession;
79
80    fn project(&self, view: &ConversationView) -> Result<CursorSession> {
81        project_view(self, view).map_err(ConvoError::Provider)
82    }
83}
84
85fn is_projectable(turn: &Turn) -> bool {
86    matches!(turn.role, Role::User | Role::Assistant)
87}
88
89fn project_view(
90    cfg: &CursorProjector,
91    view: &ConversationView,
92) -> std::result::Result<CursorSession, String> {
93    let composer_id = cfg
94        .composer_id
95        .clone()
96        .filter(|s| !s.is_empty())
97        .unwrap_or_else(|| ensure_uuid_shape(&view.id));
98
99    let workspace_path = cfg
100        .workspace_path
101        .clone()
102        .or_else(|| {
103            view.turns
104                .iter()
105                .find_map(|t| t.environment.as_ref()?.working_dir.clone())
106                .map(PathBuf::from)
107        })
108        .or_else(|| {
109            view.base
110                .as_ref()
111                .and_then(|b| b.working_dir.clone())
112                .map(PathBuf::from)
113        })
114        .unwrap_or_else(|| PathBuf::from("/"));
115    let workspace_path_str = workspace_path.to_string_lossy().into_owned();
116
117    let workspace_id = cfg
118        .workspace_id
119        .clone()
120        .filter(|s| !s.is_empty())
121        .unwrap_or_else(|| workspace_hash(&workspace_path_str));
122
123    let title = cfg
124        .title
125        .clone()
126        .or_else(|| view.title(80));
127
128    let agent_backend = cfg
129        .agent_backend
130        .clone()
131        .unwrap_or_else(|| DEFAULT_AGENT_BACKEND.to_string());
132    let default_model = cfg
133        .default_model
134        .clone()
135        .unwrap_or_else(|| DEFAULT_MODEL_NAME.to_string());
136
137    let created_at = view
138        .started_at
139        .map(|t| t.timestamp_millis())
140        .or_else(|| {
141            view.turns
142                .first()
143                .and_then(|t| parse_timestamp_ms(&t.timestamp))
144        });
145    let last_updated_at = view
146        .last_activity
147        .map(|t| t.timestamp_millis())
148        .or_else(|| {
149            view.turns
150                .last()
151                .and_then(|t| parse_timestamp_ms(&t.timestamp))
152        })
153        .or(created_at);
154
155    let mut content_blobs: HashMap<String, String> = HashMap::new();
156    let mut bubbles: Vec<Bubble> = Vec::with_capacity(view.turns.len());
157    let mut headers: Vec<BubbleHeader> = Vec::with_capacity(view.turns.len());
158
159    let mut total_input: u64 = 0;
160    let mut total_output: u64 = 0;
161    let mut total_files: std::collections::HashSet<String> = std::collections::HashSet::new();
162
163    for turn in &view.turns {
164        if !is_projectable(turn) {
165            continue;
166        }
167        let bubble = build_bubble(turn, &mut content_blobs);
168
169        if let Some(tc) = &bubble.token_count {
170            total_input = total_input.saturating_add(tc.input_tokens.unwrap_or(0));
171            total_output = total_output.saturating_add(tc.output_tokens.unwrap_or(0));
172        }
173        for fm in &turn.file_mutations {
174            total_files.insert(fm.path.clone());
175        }
176
177        headers.push(header_for(&bubble, turn));
178        bubbles.push(bubble);
179    }
180
181    let workspace_identifier = if workspace_path_str.starts_with('/') {
182        Some(WorkspaceIdentifier {
183            id: workspace_id.clone(),
184            uri: Some(WorkspaceUri {
185                mid: Some(1),
186                fs_path: Some(workspace_path_str.clone()),
187                external: Some(format!("file://{}", workspace_path_str)),
188                path: Some(workspace_path_str.clone()),
189                scheme: Some("file".into()),
190            }),
191        })
192    } else {
193        Some(WorkspaceIdentifier {
194            id: workspace_id.clone(),
195            uri: None,
196        })
197    };
198
199    let head = ComposerHead {
200        kind: Some("head".into()),
201        composer_id: composer_id.clone(),
202        name: title.clone(),
203        subtitle: None,
204        created_at,
205        last_updated_at,
206        conversation_checkpoint_last_updated_at: last_updated_at,
207        unified_mode: Some(DEFAULT_UNIFIED_MODE.into()),
208        force_mode: Some(DEFAULT_FORCE_MODE.into()),
209        is_archived: false,
210        is_draft: false,
211        has_unread_messages: false,
212        total_lines_added: None,
213        total_lines_removed: None,
214        files_changed_count: if total_files.is_empty() {
215            None
216        } else {
217            Some(total_files.len() as i64)
218        },
219        context_usage_percent: None,
220        num_sub_composers: 0,
221        workspace_identifier: workspace_identifier.clone(),
222        extra: HashMap::new(),
223    };
224
225    let data = ComposerData {
226        v: COMPOSER_SCHEMA_V,
227        composer_id: composer_id.clone(),
228        name: title,
229        subtitle: None,
230        created_at,
231        last_updated_at,
232        is_agentic: true,
233        status: Some("completed".into()),
234        unified_mode: Some(DEFAULT_UNIFIED_MODE.into()),
235        force_mode: Some(DEFAULT_FORCE_MODE.into()),
236        agent_backend: Some(agent_backend),
237        model_config: Some(ModelConfig {
238            model_name: Some(default_model.clone()),
239            max_mode: false,
240            selected_models: vec![SelectedModel {
241                model_id: default_model,
242                parameters: vec![],
243            }],
244            extra: HashMap::new(),
245        }),
246        full_conversation_headers_only: headers,
247        sub_composer_ids: vec![],
248        subagent_composer_ids: vec![],
249        latest_chat_generation_uuid: None,
250        extra: composer_required_extras(),
251    };
252
253    Ok(CursorSession {
254        head: Some(head),
255        data,
256        bubbles,
257        content_blobs,
258        transcript_path: None,
259    })
260}
261
262fn build_bubble(turn: &Turn, content_blobs: &mut HashMap<String, String>) -> Bubble {
263    let kind = match turn.role {
264        Role::User => BUBBLE_TYPE_USER,
265        Role::Assistant => BUBBLE_TYPE_ASSISTANT,
266        Role::System | Role::Other(_) => return Bubble::default(),
267    };
268
269    let primary_tool = turn.tool_uses.first();
270
271    let (tool_former_data, capability_type) = match primary_tool {
272        Some(tu) => {
273            let tf = tool_former_data_for(tu, &turn.file_mutations, content_blobs);
274            (Some(tf), Some(CAPABILITY_TOOL))
275        }
276        None if turn.thinking.is_some() => (None, Some(CAPABILITY_THINKING)),
277        _ => (None, None),
278    };
279
280    let all_thinking_blocks = match &turn.thinking {
281        Some(t) if !t.is_empty() => vec![ThinkingBlock {
282            text: t.clone(),
283            extra: HashMap::new(),
284        }],
285        _ => Vec::new(),
286    };
287
288    let token_count = turn.token_usage.as_ref().map(|u| TokenCount {
289        input_tokens: u.input_tokens.map(|n| n as u64),
290        output_tokens: u.output_tokens.map(|n| n as u64),
291        extra: HashMap::new(),
292    });
293
294    let is_tool_bubble = capability_type == Some(CAPABILITY_TOOL);
295    let model_info = if is_tool_bubble {
296        None
297    } else {
298        turn.model.as_deref().filter(|m| !m.is_empty()).map(|m| ModelInfo {
299            model_name: Some(m.to_string()),
300            extra: HashMap::new(),
301        })
302    };
303
304    // Empty-text bubbles must omit richText — emitting an empty
305    // Lexical envelope throws in Cursor's renderer.
306    let rich_text = if turn.text.is_empty() {
307        None
308    } else {
309        Some(rich_text_envelope(&turn.text))
310    };
311
312    let created_at = if turn.timestamp.is_empty() {
313        None
314    } else {
315        Some(turn.timestamp.clone())
316    };
317
318    Bubble {
319        v: BUBBLE_SCHEMA_V,
320        bubble_id: turn.id.clone(),
321        kind,
322        created_at,
323        text: turn.text.clone(),
324        rich_text,
325        capability_type,
326        conversation_state: Some("~".into()),
327        unified_mode: Some(UNIFIED_MODE_AGENT),
328        is_agentic: turn.role == Role::Assistant && capability_type != Some(CAPABILITY_TOOL),
329        request_id: Some(String::new()),
330        checkpoint_id: None,
331        token_count,
332        model_info,
333        tool_former_data,
334        all_thinking_blocks,
335        tool_results: Vec::new(),
336        extra: bubble_required_extras(is_tool_bubble),
337    }
338}
339
340fn header_for(bubble: &Bubble, turn: &Turn) -> BubbleHeader {
341    let has_text = !bubble.text.is_empty();
342    let has_thinking = !bubble.all_thinking_blocks.is_empty();
343    let mut grouping = BubbleGrouping {
344        is_renderable: true,
345        has_text: if has_text { Some(true) } else { None },
346        has_thinking: if has_thinking { Some(true) } else { None },
347        thinking_duration_ms: None,
348        capability_type: bubble.capability_type,
349        extra: HashMap::new(),
350    };
351    if has_text && bubble.text.lines().count() <= 1 && bubble.text.chars().count() <= 120 {
352        grouping
353            .extra
354            .insert("isShortPlainText".into(), Value::Bool(true));
355    }
356    let _ = turn;
357    BubbleHeader {
358        bubble_id: bubble.bubble_id.clone(),
359        kind: bubble.kind,
360        grouping: Some(grouping),
361        content_height_hint: None,
362    }
363}
364
365fn tool_former_data_for(
366    tu: &ToolInvocation,
367    file_mutations: &[FileMutation],
368    content_blobs: &mut HashMap<String, String>,
369) -> ToolFormerData {
370    let (tool_id, internal_name) = tool_id_and_name(tu);
371    // Historical projection: never emit "running" — that would hang
372    // Cursor's UI waiting for a result that already happened.
373    let status = match &tu.result {
374        Some(r) if r.is_error => "error".to_string(),
375        _ => "completed".to_string(),
376    };
377    let normalized_input = normalize_input_for_cursor(tool_id, &tu.input);
378    let params = serde_json::to_string(&normalized_input).unwrap_or_else(|_| "{}".to_string());
379    let result = build_result_payload(tu, tool_id, file_mutations, content_blobs);
380    let additional_data = match tool_id {
381        TOOL_EDIT_FILE_V2 => Some(Value::Object(serde_json::Map::new())),
382        _ => None,
383    };
384    ToolFormerData {
385        tool: tool_id,
386        tool_index: Some(0),
387        model_call_id: Some(String::new()),
388        tool_call_id: tu.id.clone(),
389        status,
390        name: internal_name,
391        params,
392        result,
393        additional_data,
394    }
395}
396
397fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
398    let rest = line.strip_prefix("@@")?.trim_start();
399    let close = rest.find("@@")?;
400    let body = rest[..close].trim();
401    let mut parts = body.split_whitespace();
402    let old = parts.next()?.strip_prefix('-')?;
403    let new = parts.next()?.strip_prefix('+')?;
404    let old_start = old.split(',').next()?.parse::<u32>().ok()?;
405    let new_start = new.split(',').next()?.parse::<u32>().ok()?;
406    Some((old_start, new_start))
407}
408
409// Cursor's protobuf parsers use strict decoding: unknown keys throw
410// inside the chat renderer and the bubble fails to display. Rename
411// foreign keys to Cursor's vocabulary and drop unrecognized ones.
412fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value {
413    let Value::Object(obj) = input else {
414        return input.clone();
415    };
416
417    let renames: &[(&str, &str)] = match tool_id {
418        crate::types::TOOL_RUN_TERMINAL_COMMAND_V2 => &[
419            ("description", "commandDescription"),
420        ],
421        crate::types::TOOL_EDIT_FILE_V2 => &[
422            ("file_path", "relativeWorkspacePath"),
423            ("filePath", "relativeWorkspacePath"),
424            ("path", "relativeWorkspacePath"),
425        ],
426        crate::types::TOOL_READ_FILE_V2 => &[
427            ("file_path", "targetFile"),
428            ("filePath", "targetFile"),
429            ("path", "targetFile"),
430        ],
431        crate::types::TOOL_GLOB_FILE_SEARCH => &[
432            ("pattern", "globPattern"),
433            ("glob_pattern", "globPattern"),
434            ("path", "targetDirectory"),
435            ("target_directory", "targetDirectory"),
436        ],
437        crate::types::TOOL_RIPGREP_RAW_SEARCH => &[
438            ("query", "pattern"),
439            ("regex", "pattern"),
440            ("target_directory", "path"),
441            ("case_insensitive", "caseInsensitive"),
442        ],
443        crate::types::TOOL_TASK_V2 => &[
444            ("subagent_type", "subagentType"),
445        ],
446        _ => &[],
447    };
448
449    let mut out = serde_json::Map::new();
450    let mut renamed: std::collections::HashSet<&str> = std::collections::HashSet::new();
451    for (foreign, cursor) in renames {
452        if let Some(v) = obj.get(*foreign) {
453            out.entry((*cursor).to_string()).or_insert_with(|| v.clone());
454            renamed.insert(*foreign);
455        }
456    }
457    let allowed: Option<&[&str]> = match tool_id {
458        crate::types::TOOL_RUN_TERMINAL_COMMAND_V2 => Some(&[
459            "command",
460            "commandDescription",
461            "cwd",
462            "options",
463            "parsingResult",
464            "requestedSandboxPolicy",
465        ]),
466        crate::types::TOOL_EDIT_FILE_V2 => Some(&[
467            "relativeWorkspacePath",
468            "noCodeblock",
469            "cloudAgentEdit",
470        ]),
471        crate::types::TOOL_READ_FILE_V2 => Some(&[
472            "targetFile",
473            "effectiveUri",
474            "charsLimit",
475            "shouldReadEntireFile",
476            "startLineOneIndexed",
477            "endLineOneIndexedInclusive",
478        ]),
479        crate::types::TOOL_GLOB_FILE_SEARCH => Some(&[
480            "globPattern",
481            "targetDirectory",
482        ]),
483        crate::types::TOOL_RIPGREP_RAW_SEARCH => Some(&[
484            "pattern",
485            "path",
486            "caseInsensitive",
487            "includes",
488            "excludes",
489        ]),
490        crate::types::TOOL_TASK_V2 => Some(&[
491            "description",
492            "prompt",
493            "subagentType",
494            "model",
495            "name",
496        ]),
497        _ => None,
498    };
499    for (k, v) in obj {
500        if renamed.contains(k.as_str()) {
501            continue;
502        }
503        let keep = match allowed {
504            None => true,
505            Some(list) => list.contains(&k.as_str()),
506        };
507        if keep && !out.contains_key(k) {
508            out.insert(k.clone(), v.clone());
509        }
510    }
511    if tool_id == crate::types::TOOL_EDIT_FILE_V2 {
512        out.entry("noCodeblock".to_string()).or_insert(Value::Bool(true));
513        out.entry("cloudAgentEdit".to_string()).or_insert(Value::Bool(false));
514    }
515    Value::Object(out)
516}
517
518fn tool_id_and_name(tu: &ToolInvocation) -> (u32, String) {
519    use toolpath_convo::ToolCategory;
520    if let Some(id) = crate::types::tool_id_for_name(&tu.name) {
521        return (id, tu.name.clone());
522    }
523    match tu.category {
524        Some(ToolCategory::Shell) => (
525            TOOL_RUN_TERMINAL_COMMAND_V2,
526            "run_terminal_command_v2".into(),
527        ),
528        Some(ToolCategory::FileWrite) => (TOOL_EDIT_FILE_V2, "edit_file_v2".into()),
529        Some(ToolCategory::FileRead) => (TOOL_READ_FILE_V2, "read_file_v2".into()),
530        Some(ToolCategory::FileSearch) => (TOOL_GLOB_FILE_SEARCH, "glob_file_search".into()),
531        Some(ToolCategory::Network) => (
532            crate::types::TOOL_WEB_SEARCH,
533            "web_search".into(),
534        ),
535        Some(ToolCategory::Delegation) => (TOOL_TASK_V2, "task_v2".into()),
536        None => (crate::types::TOOL_UNSPECIFIED, tu.name.clone()),
537    }
538}
539
540fn build_result_payload(
541    tu: &ToolInvocation,
542    tool_id: u32,
543    file_mutations: &[FileMutation],
544    content_blobs: &mut HashMap<String, String>,
545) -> Option<String> {
546    if matches!(&tu.result, Some(r) if r.is_error) {
547        return None;
548    }
549
550    match tool_id {
551        TOOL_EDIT_FILE_V2 => {
552            // Cursor's edit-bubble renderer requires both
553            // beforeContentId and afterContentId to fetch and diff
554            // the blobs.
555            let fm = file_mutations
556                .iter()
557                .find(|fm| fm.tool_id.as_deref() == Some(tu.id.as_str()))
558                .or_else(|| file_mutations.iter().find(|fm| fm.path.starts_with('/')));
559            let (before_hash, after_hash) = match fm {
560                Some(fm) => {
561                    let (before_src, after_src) = source_blob_pair(fm);
562                    (
563                        register_blob(content_blobs, before_src.as_deref()),
564                        register_blob(content_blobs, after_src.as_deref()),
565                    )
566                }
567                None => (None, None),
568            };
569            let mut obj = Map::new();
570            if let Some(h) = before_hash {
571                obj.insert(
572                    "beforeContentId".into(),
573                    Value::String(format!("{CONTENT_PREFIX}{h}")),
574                );
575            }
576            if let Some(h) = after_hash {
577                obj.insert(
578                    "afterContentId".into(),
579                    Value::String(format!("{CONTENT_PREFIX}{h}")),
580                );
581            }
582            Some(serde_json::to_string(&Value::Object(obj)).unwrap_or_else(|_| "{}".into()))
583        }
584        TOOL_RUN_TERMINAL_COMMAND_V2 => {
585            let output = tu
586                .result
587                .as_ref()
588                .map(|r| r.content.clone())
589                .unwrap_or_default();
590            let payload = json!({
591                "output": output,
592                "exitCode": 0,
593                "rejected": false,
594                "notInterrupted": true,
595            });
596            Some(serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into()))
597        }
598        _ => tu.result.as_ref().map(|r| {
599            let payload = json!({ "output": r.content });
600            serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into())
601        }),
602    }
603}
604
605fn source_blob_pair(fm: &FileMutation) -> (Option<String>, Option<String>) {
606    let before = fm.before.clone().filter(|s| !s.is_empty());
607    let after = fm.after.clone().filter(|s| !s.is_empty());
608    if before.is_some() || after.is_some() {
609        return (
610            before.or_else(|| Some(String::new())),
611            after.or_else(|| Some(String::new())),
612        );
613    }
614    if let Some(diff) = fm.raw_diff.as_deref() {
615        let (b, a) = reconstruct_hunks_from_diff(diff);
616        if !b.is_empty() || !a.is_empty() {
617            return (Some(b), Some(a));
618        }
619    }
620    (None, None)
621}
622
623fn reconstruct_hunks_from_diff(diff: &str) -> (String, String) {
624    let mut before = String::new();
625    let mut after = String::new();
626    let mut in_hunk = false;
627    for raw in diff.lines() {
628        if raw.starts_with("@@") {
629            in_hunk = parse_hunk_header(raw).is_some();
630            continue;
631        }
632        if !in_hunk {
633            continue;
634        }
635        match raw.chars().next() {
636            Some('+') => {
637                after.push_str(&raw[1..]);
638                after.push('\n');
639            }
640            Some('-') => {
641                before.push_str(&raw[1..]);
642                before.push('\n');
643            }
644            Some(' ') => {
645                before.push_str(&raw[1..]);
646                before.push('\n');
647                after.push_str(&raw[1..]);
648                after.push('\n');
649            }
650            Some('\\') => continue,
651            None => {
652                before.push('\n');
653                after.push('\n');
654            }
655            _ => continue,
656        }
657    }
658    (before, after)
659}
660
661fn register_blob(blobs: &mut HashMap<String, String>, body: Option<&str>) -> Option<String> {
662    let body = body?;
663    let hash = sha256_hex(body.as_bytes());
664    blobs.entry(hash.clone()).or_insert_with(|| body.to_string());
665    Some(hash)
666}
667
668fn sha256_hex(bytes: &[u8]) -> String {
669    use std::fmt::Write;
670    let mut h = Sha256::new();
671    h.update(bytes);
672    h.finalize().iter().fold(String::with_capacity(64), |mut s, b| {
673        let _ = write!(s, "{b:02x}");
674        s
675    })
676}
677
678fn workspace_hash(path: &str) -> String {
679    let full = sha256_hex(path.as_bytes());
680    full.chars().take(32).collect()
681}
682
683fn parse_timestamp_ms(ts: &str) -> Option<i64> {
684    DateTime::parse_from_rfc3339(ts)
685        .ok()
686        .map(|dt| dt.timestamp_millis())
687        .or_else(|| ts.parse::<DateTime<Utc>>().ok().map(|dt| dt.timestamp_millis()))
688        .or_else(|| ts.parse::<i64>().ok())
689}
690
691fn ensure_uuid_shape(id: &str) -> String {
692    if looks_like_uuid(id) {
693        return id.to_string();
694    }
695    let h = sha256_hex(id.as_bytes());
696    format!(
697        "{}-{}-4{}-a{}-{}",
698        &h[0..8],
699        &h[8..12],
700        &h[13..16],
701        &h[17..20],
702        &h[20..32],
703    )
704}
705
706fn looks_like_uuid(s: &str) -> bool {
707    let bytes = s.as_bytes();
708    if bytes.len() != 36 {
709        return false;
710    }
711    let dashes = [8usize, 13, 18, 23];
712    for (i, &b) in bytes.iter().enumerate() {
713        let want_dash = dashes.contains(&i);
714        if want_dash {
715            if b != b'-' {
716                return false;
717            }
718        } else if !b.is_ascii_hexdigit() {
719            return false;
720        }
721    }
722    true
723}
724
725// Empty text must serialize as `children: []` — Lexical rejects
726// empty `{text: ""}` nodes and Cursor's renderer hangs on "Loading chat".
727fn rich_text_envelope(text: &str) -> String {
728    let children = if text.is_empty() {
729        Value::Array(Vec::new())
730    } else {
731        json!([{
732            "detail": 0,
733            "format": 0,
734            "mode": "normal",
735            "style": "",
736            "text": text,
737            "type": "text",
738            "version": 1,
739        }])
740    };
741    let doc = json!({
742        "root": {
743            "children": [{
744                "children": children,
745                "direction": "ltr",
746                "format": "",
747                "indent": 0,
748                "type": "paragraph",
749                "version": 1,
750            }],
751            "direction": "ltr",
752            "format": "",
753            "indent": 0,
754            "type": "root",
755            "version": 1,
756        }
757    });
758    serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into())
759}
760
761// All keys must be present — Cursor's chat loader dereferences them
762// unconditionally and missing keys throw `undefined.fileSelections`,
763// hanging the chat panel.
764fn empty_context_value() -> Value {
765    json!({
766        "composers": [],
767        "selectedCommits": [],
768        "selectedPullRequests": [],
769        "selectedImages": [],
770        "selectedDocuments": [],
771        "selectedVideos": [],
772        "folderSelections": [],
773        "fileSelections": [],
774        "terminalFiles": [],
775        "selections": [],
776        "terminalSelections": [],
777        "selectedDocs": [],
778        "externalLinks": [],
779        "cursorRules": [],
780        "cursorCommands": [],
781        "gitPRDiffSelections": [],
782        "subagentSelections": [],
783        "browserSelections": [],
784        "extraContext": [],
785        "mentions": {
786            "composers": {},
787            "selectedCommits": {},
788            "selectedPullRequests": {},
789            "gitDiff": [],
790            "gitDiffFromBranchToMain": [],
791            "selectedImages": {},
792            "selectedDocuments": {},
793            "selectedVideos": {},
794            "folderSelections": {},
795            "fileSelections": {},
796            "terminalFiles": {},
797            "selections": {},
798            "terminalSelections": {},
799            "selectedDocs": {},
800            "externalLinks": {},
801            "diffHistory": [],
802            "cursorRules": {},
803            "cursorCommands": {},
804            "uiElementSelections": [],
805            "consoleLogs": [],
806            "ideEditorsState": [],
807            "gitPRDiffSelections": {},
808            "subagentSelections": {},
809            "browserSelections": {}
810        }
811    })
812}
813
814// Each key is required by Cursor's chat loader — omitting any of
815// them throws inside the renderer and hangs the chat panel.
816fn composer_required_extras() -> HashMap<String, Value> {
817    let empty_obj = Value::Object(serde_json::Map::new());
818    let mut out: HashMap<String, Value> = HashMap::new();
819    out.insert(
820        "capabilities".into(),
821        json!([
822            {"type": 15, "data": {"bubbleDataMap": "{}"}},
823            {"type": 19, "data": {}},
824            {"type": 33, "data": {}},
825            {"type": 32, "data": {}},
826            {"type": 23, "data": {}},
827            {"type": 16, "data": {}},
828            {"type": 24, "data": {}},
829            {"type": 21, "data": {}}
830        ]),
831    );
832    out.insert("context".into(), empty_context_value());
833    out.insert("conversationMap".into(), empty_obj.clone());
834    out.insert("codeBlockData".into(), empty_obj.clone());
835    out.insert("originalFileStates".into(), empty_obj.clone());
836    out.insert("usageData".into(), empty_obj);
837    out
838}
839
840// Each key is required — missing keys throw `Object.entries(undefined)`
841// in Cursor's renderer and the bubble doesn't display.
842fn bubble_required_extras(is_tool_bubble: bool) -> HashMap<String, Value> {
843    let empty_arr = Value::Array(Vec::new());
844    let mut out: HashMap<String, Value> = HashMap::new();
845    for k in [
846        "aiWebSearchResults",
847        "approximateLintErrors",
848        "assistantSuggestedDiffs",
849        "attachedCodeChunks",
850        "attachedFileCodeChunksMetadataOnly",
851        "attachedFolders",
852        "attachedFoldersListDirResults",
853        "attachedFoldersNew",
854        "capabilities",
855        "capabilityContexts",
856        "codeBlocks",
857        "codebaseContextChunks",
858        "commits",
859        "consoleLogs",
860        "contextPieces",
861        "cursorCommands",
862        "cursorRules",
863        "deletedFiles",
864        "diffHistories",
865        "diffsForCompressingFiles",
866        "diffsSinceLastApply",
867        "docsReferences",
868        "documentationSelections",
869        "editTrailContexts",
870        "externalLinks",
871        "fileDiffTrajectories",
872        "gitDiffs",
873        "humanChanges",
874        "images",
875        "interpreterResults",
876        "knowledgeItems",
877        "lints",
878        "mcpDescriptors",
879        "multiFileLinterErrors",
880        "notepads",
881        "pastChats",
882        "projectLayouts",
883        "pullRequests",
884        "recentLocationsHistory",
885        "recentlyViewedFiles",
886        "relevantFiles",
887        "suggestedCodeBlocks",
888        "summarizedComposers",
889        "supportedTools",
890        "uiElementPicked",
891        "userResponsesToSuggestedCodeBlocks",
892        "webReferences",
893        "workspaceUris",
894    ] {
895        out.insert(k.into(), empty_arr.clone());
896    }
897    for k in [
898        "attachedHumanChanges",
899        "cursorCommandsExplicitlySet",
900        "existedPreviousTerminalCommand",
901        "existedSubsequentTerminalCommand",
902        "isRefunded",
903        "pastChatsExplicitlySet",
904    ] {
905        out.insert(k.into(), Value::Bool(false));
906    }
907    if !is_tool_bubble {
908        out.insert("context".into(), empty_context_value());
909    }
910    out.insert("todos".into(), empty_arr);
911    out
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use serde_json::json;
918    use toolpath_convo::{
919        EnvironmentSnapshot, ProducerInfo, SessionBase, TokenUsage, ToolCategory, ToolInvocation,
920        ToolResult,
921    };
922
923    fn user_turn(id: &str, text: &str) -> Turn {
924        Turn {
925            id: id.into(),
926            parent_id: None,
927            group_id: None,
928            role: Role::User,
929            timestamp: "2026-06-01T00:00:00.000Z".into(),
930            text: text.into(),
931            thinking: None,
932            tool_uses: vec![],
933            model: None,
934            stop_reason: None,
935            token_usage: None,
936            attributed_token_usage: None,
937            environment: Some(EnvironmentSnapshot {
938                working_dir: Some("/proj".into()),
939                vcs_branch: None,
940                vcs_revision: None,
941            }),
942            delegations: vec![],
943            file_mutations: vec![],
944        }
945    }
946
947    fn assistant_turn(id: &str, text: &str) -> Turn {
948        Turn {
949            id: id.into(),
950            parent_id: None,
951            group_id: None,
952            role: Role::Assistant,
953            timestamp: "2026-06-01T00:00:01.000Z".into(),
954            text: text.into(),
955            thinking: None,
956            tool_uses: vec![],
957            model: Some("claude-opus-4-7".into()),
958            stop_reason: None,
959            token_usage: Some(TokenUsage {
960                input_tokens: Some(10),
961                output_tokens: Some(5),
962                cache_read_tokens: None,
963                cache_write_tokens: None,
964                ..Default::default()
965            }),
966            attributed_token_usage: None,
967            environment: Some(EnvironmentSnapshot {
968                working_dir: Some("/proj".into()),
969                vcs_branch: None,
970                vcs_revision: None,
971            }),
972            delegations: vec![],
973            file_mutations: vec![],
974        }
975    }
976
977    fn view_with(turns: Vec<Turn>) -> ConversationView {
978        ConversationView {
979            id: "comp-1".into(),
980            started_at: None,
981            last_activity: None,
982            turns,
983            total_usage: None,
984            provider_id: Some("cursor".into()),
985            files_changed: vec![],
986            session_ids: vec![],
987            events: vec![],
988            base: Some(SessionBase {
989                working_dir: Some("/proj".into()),
990                vcs_revision: None,
991                vcs_branch: None,
992                vcs_remote: None,
993            }),
994            producer: Some(ProducerInfo {
995                name: "cursor".into(),
996                version: Some("cursor-agent".into()),
997            }),
998        }
999    }
1000
1001    #[test]
1002    fn empty_view_yields_session_with_no_bubbles() {
1003        let s = CursorProjector::new().project(&view_with(vec![])).unwrap();
1004        assert!(s.bubbles.is_empty());
1005        assert!(s.data.full_conversation_headers_only.is_empty());
1006        assert!(s.head.is_some());
1007        assert_eq!(s.data.v, COMPOSER_SCHEMA_V);
1008    }
1009
1010    #[test]
1011    fn user_turn_becomes_user_bubble() {
1012        let s = CursorProjector::new()
1013            .project(&view_with(vec![user_turn("u1", "hello")]))
1014            .unwrap();
1015        assert_eq!(s.bubbles.len(), 1);
1016        let b = &s.bubbles[0];
1017        assert_eq!(b.kind, BUBBLE_TYPE_USER);
1018        assert_eq!(b.v, BUBBLE_SCHEMA_V);
1019        assert_eq!(b.text, "hello");
1020        assert_eq!(b.bubble_id, "u1");
1021        assert!(b.rich_text.is_some());
1022        assert!(b.capability_type.is_none());
1023    }
1024
1025    #[test]
1026    fn assistant_turn_becomes_assistant_bubble_with_model_info() {
1027        let s = CursorProjector::new()
1028            .project(&view_with(vec![assistant_turn("a1", "ok")]))
1029            .unwrap();
1030        let b = &s.bubbles[0];
1031        assert_eq!(b.kind, BUBBLE_TYPE_ASSISTANT);
1032        assert_eq!(b.text, "ok");
1033        assert_eq!(
1034            b.model_info.as_ref().unwrap().model_name.as_deref(),
1035            Some("claude-opus-4-7")
1036        );
1037        assert_eq!(b.token_count.as_ref().unwrap().input_tokens, Some(10));
1038    }
1039
1040    #[test]
1041    fn thinking_only_turn_marks_capability_30() {
1042        let mut t = assistant_turn("a1", "");
1043        t.thinking = Some("thinking about it".into());
1044        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1045        let b = &s.bubbles[0];
1046        assert_eq!(b.capability_type, Some(CAPABILITY_THINKING));
1047        assert_eq!(b.all_thinking_blocks.len(), 1);
1048        assert_eq!(b.all_thinking_blocks[0].text, "thinking about it");
1049    }
1050
1051    #[test]
1052    fn shell_tool_use_round_trips_via_native_id() {
1053        let mut t = assistant_turn("a1", "");
1054        t.tool_uses = vec![ToolInvocation {
1055            id: "tc1".into(),
1056            name: "Bash".into(),
1057            input: json!({"command": "ls"}),
1058            result: Some(ToolResult {
1059                content: "a\nb\n".into(),
1060                is_error: false,
1061            }),
1062            category: Some(ToolCategory::Shell),
1063        }];
1064        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1065        let b = &s.bubbles[0];
1066        assert_eq!(b.capability_type, Some(CAPABILITY_TOOL));
1067        let tf = b.tool_former_data.as_ref().unwrap();
1068        assert_eq!(tf.tool, TOOL_RUN_TERMINAL_COMMAND_V2);
1069        assert_eq!(tf.name, "run_terminal_command_v2");
1070        assert_eq!(tf.tool_call_id, "tc1");
1071        assert_eq!(tf.status, "completed");
1072        let params = tf.parse_params().unwrap();
1073        assert_eq!(params["command"], "ls");
1074        let result = tf.parse_result().unwrap().unwrap();
1075        assert_eq!(result["output"], "a\nb\n");
1076    }
1077
1078    #[test]
1079    fn file_write_tool_registers_blobs_and_emits_result() {
1080        let mut t = assistant_turn("a1", "");
1081        t.tool_uses = vec![ToolInvocation {
1082            id: "tc2".into(),
1083            name: "edit_file_v2".into(),
1084            input: json!({"relativeWorkspacePath": "/proj/x.rs"}),
1085            result: Some(ToolResult {
1086                content: "edited /proj/x.rs".into(),
1087                is_error: false,
1088            }),
1089            category: Some(ToolCategory::FileWrite),
1090        }];
1091        t.file_mutations = vec![FileMutation {
1092            path: "/proj/x.rs".into(),
1093            tool_id: Some("tc2".into()),
1094            operation: Some("add".into()),
1095            raw_diff: None,
1096            before: Some(String::new()),
1097            after: Some("fn main() {}".into()),
1098            rename_to: None,
1099        }];
1100        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1101
1102        assert!(s.content_blobs.contains_key(
1103            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1104        ));
1105        let after_hash = sha256_hex("fn main() {}".as_bytes());
1106        assert_eq!(
1107            s.content_blobs.get(&after_hash).map(String::as_str),
1108            Some("fn main() {}")
1109        );
1110
1111        let tf = s.bubbles[0].tool_former_data.as_ref().unwrap();
1112        let result = tf.parse_result().unwrap().unwrap();
1113        assert_eq!(
1114            result["beforeContentId"],
1115            "composer.content.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1116        );
1117        assert_eq!(
1118            result["afterContentId"],
1119            format!("composer.content.{after_hash}")
1120        );
1121    }
1122
1123    #[test]
1124    fn errored_tool_emits_null_result_and_status_error() {
1125        let mut t = assistant_turn("a1", "");
1126        t.tool_uses = vec![ToolInvocation {
1127            id: "tc3".into(),
1128            name: "edit_file_v2".into(),
1129            input: json!({"relativeWorkspacePath": "/proj/x.rs"}),
1130            result: Some(ToolResult {
1131                content: "boom".into(),
1132                is_error: true,
1133            }),
1134            category: Some(ToolCategory::FileWrite),
1135        }];
1136        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1137        let tf = s.bubbles[0].tool_former_data.as_ref().unwrap();
1138        assert_eq!(tf.status, "error");
1139        assert!(tf.result.is_none());
1140    }
1141
1142    #[test]
1143    fn no_result_yet_emits_completed_status_not_running() {
1144        let mut t = assistant_turn("a1", "");
1145        t.tool_uses = vec![ToolInvocation {
1146            id: "tc4".into(),
1147            name: "read_file_v2".into(),
1148            input: json!({"target_file": "/proj/x.rs"}),
1149            result: None,
1150            category: Some(ToolCategory::FileRead),
1151        }];
1152        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1153        let tf = s.bubbles[0].tool_former_data.as_ref().unwrap();
1154        assert_eq!(tf.status, "completed");
1155        assert_ne!(tf.status, "running");
1156    }
1157
1158    #[test]
1159    fn composer_metadata_populated() {
1160        let s = CursorProjector::new()
1161            .with_title("My session")
1162            .with_workspace_id("workspaceid")
1163            .project(&view_with(vec![user_turn("u1", "hi")]))
1164            .unwrap();
1165        assert_eq!(s.data.name.as_deref(), Some("My session"));
1166        assert_eq!(s.head.as_ref().unwrap().name.as_deref(), Some("My session"));
1167        assert!(s.data.is_agentic);
1168        assert_eq!(s.data.unified_mode.as_deref(), Some("agent"));
1169        assert_eq!(s.data.agent_backend.as_deref(), Some("cursor-agent"));
1170        let ws = s
1171            .head
1172            .as_ref()
1173            .unwrap()
1174            .workspace_identifier
1175            .as_ref()
1176            .unwrap();
1177        assert_eq!(ws.id, "workspaceid");
1178        assert_eq!(ws.uri.as_ref().unwrap().fs_path.as_deref(), Some("/proj"));
1179    }
1180
1181    #[test]
1182    fn header_manifest_matches_bubbles() {
1183        let s = CursorProjector::new()
1184            .project(&view_with(vec![
1185                user_turn("u1", "hi"),
1186                assistant_turn("a1", "ok"),
1187            ]))
1188            .unwrap();
1189        let headers = &s.data.full_conversation_headers_only;
1190        assert_eq!(headers.len(), 2);
1191        assert_eq!(headers[0].bubble_id, "u1");
1192        assert_eq!(headers[0].kind, BUBBLE_TYPE_USER);
1193        assert_eq!(headers[1].bubble_id, "a1");
1194        assert_eq!(headers[1].kind, BUBBLE_TYPE_ASSISTANT);
1195    }
1196
1197    #[test]
1198    fn ensure_uuid_shape_passes_through_real_uuids() {
1199        let id = "724686cd-875e-47da-a90b-dbc3e523efb8";
1200        assert_eq!(ensure_uuid_shape(id), id);
1201    }
1202
1203    #[test]
1204    fn ensure_uuid_shape_synthesizes_v4_shape_from_other_ids() {
1205        let made = ensure_uuid_shape("not-a-uuid");
1206        assert!(looks_like_uuid(&made), "expected v4 shape, got {made}");
1207    }
1208
1209    #[test]
1210    fn remote_workspace_emits_uri_none() {
1211        let s = CursorProjector::new()
1212            .with_workspace_path(PathBuf::from("untitled-workspace"))
1213            .project(&view_with(vec![user_turn("u1", "hi")]))
1214            .unwrap();
1215        let ws = s
1216            .head
1217            .as_ref()
1218            .unwrap()
1219            .workspace_identifier
1220            .as_ref()
1221            .unwrap();
1222        assert!(ws.uri.is_none());
1223    }
1224
1225    #[test]
1226    fn every_canonical_tool_name_resolves_to_its_canonical_id() {
1227        use crate::types::{TOOL_TABLE, TOOL_UNSPECIFIED};
1228        for &(id, name) in TOOL_TABLE {
1229            let tu = ToolInvocation {
1230                id: "test".into(),
1231                name: name.to_string(),
1232                input: json!({}),
1233                result: None,
1234                category: None,
1235            };
1236            let (resolved_id, resolved_name) = super::tool_id_and_name(&tu);
1237            assert_eq!(
1238                resolved_id, id,
1239                "tool name {name:?} should resolve to id {id}, got {resolved_id}"
1240            );
1241            assert_eq!(resolved_name, name);
1242            if name != "unspecified" {
1243                assert_ne!(resolved_id, TOOL_UNSPECIFIED);
1244            }
1245        }
1246    }
1247
1248    #[test]
1249    fn foreign_name_with_category_picks_canonical_tool_per_category() {
1250        use toolpath_convo::ToolCategory;
1251        let cases = [
1252            (ToolCategory::Shell, 15u32, "run_terminal_command_v2"),
1253            (ToolCategory::FileWrite, 38, "edit_file_v2"),
1254            (ToolCategory::FileRead, 40, "read_file_v2"),
1255            (ToolCategory::FileSearch, 42, "glob_file_search"),
1256            (ToolCategory::Network, 18, "web_search"),
1257            (ToolCategory::Delegation, 48, "task_v2"),
1258        ];
1259        for (cat, want_id, want_name) in cases {
1260            let tu = ToolInvocation {
1261                id: "test".into(),
1262                name: "Foreign_Name_Not_In_Cursor".into(),
1263                input: json!({}),
1264                result: None,
1265                category: Some(cat),
1266            };
1267            let (id, name) = super::tool_id_and_name(&tu);
1268            assert_eq!(id, want_id, "{cat:?}");
1269            assert_eq!(name, want_name);
1270        }
1271    }
1272
1273    #[test]
1274    fn foreign_name_without_category_falls_back_to_unspecified() {
1275        let tu = ToolInvocation {
1276            id: "test".into(),
1277            name: "weird_thing_v9".into(),
1278            input: json!({}),
1279            result: None,
1280            category: None,
1281        };
1282        let (id, name) = super::tool_id_and_name(&tu);
1283        assert_eq!(id, crate::types::TOOL_UNSPECIFIED);
1284        assert_eq!(name, "weird_thing_v9");
1285    }
1286
1287    #[test]
1288    fn projects_via_trait_object() {
1289        use toolpath_convo::project::AnyProjector;
1290        let any = AnyProjector::new(CursorProjector::new());
1291        let session: CursorSession = any
1292            .project_as(&view_with(vec![user_turn("u1", "hello")]))
1293            .unwrap();
1294        assert_eq!(session.bubbles.len(), 1);
1295    }
1296
1297    #[test]
1298    fn parse_hunk_header_basic() {
1299        assert_eq!(super::parse_hunk_header("@@ -1,3 +1,4 @@"), Some((1, 1)));
1300        assert_eq!(super::parse_hunk_header("@@ -10,5 +20,7 @@ fn foo()"), Some((10, 20)));
1301        assert_eq!(super::parse_hunk_header("@@ -1 +1 @@"), Some((1, 1)));
1302        assert_eq!(super::parse_hunk_header("@@ -0,0 +1,3 @@"), Some((0, 1)));
1303        assert_eq!(super::parse_hunk_header("not a hunk"), None);
1304    }
1305
1306    #[test]
1307    fn reconstruct_hunks_from_diff_simple_edit() {
1308        let diff = "--- a/x.rs\n+++ b/x.rs\n@@ -1,3 +1,3 @@\n fn main() {\n-    println!(\"hi\");\n+    println!(\"hello\");\n }\n";
1309        let (before, after) = super::reconstruct_hunks_from_diff(diff);
1310        assert_eq!(before, "fn main() {\n    println!(\"hi\");\n}\n");
1311        assert_eq!(after, "fn main() {\n    println!(\"hello\");\n}\n");
1312    }
1313
1314    #[test]
1315    fn reconstruct_hunks_from_diff_new_file() {
1316        let diff = "--- /dev/null\n+++ b/x.rs\n@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}\n";
1317        let (before, after) = super::reconstruct_hunks_from_diff(diff);
1318        assert_eq!(before, "");
1319        assert_eq!(after, "fn a() {}\nfn b() {}\n");
1320    }
1321
1322    #[test]
1323    fn reconstruct_hunks_from_diff_handles_blank_context_lines() {
1324        let diff = "@@ -1,3 +1,3 @@\n line1\n\n+line3-new\n";
1325        let (before, after) = super::reconstruct_hunks_from_diff(diff);
1326        assert_eq!(before, "line1\n\n");
1327        assert_eq!(after, "line1\n\nline3-new\n");
1328    }
1329
1330    #[test]
1331    fn edit_tool_with_raw_diff_emits_content_blobs() {
1332        let mut t = assistant_turn("a1", "");
1333        t.tool_uses = vec![ToolInvocation {
1334            id: "tc1".into(),
1335            name: "edit_file_v2".into(),
1336            input: json!({"relativeWorkspacePath": "/proj/x.rs"}),
1337            result: Some(ToolResult {
1338                content: "ok".into(),
1339                is_error: false,
1340            }),
1341            category: Some(ToolCategory::FileWrite),
1342        }];
1343        t.file_mutations = vec![FileMutation {
1344            path: "/proj/x.rs".into(),
1345            tool_id: Some("tc1".into()),
1346            operation: Some("edit".into()),
1347            raw_diff: Some(
1348                "@@ -1,1 +1,1 @@\n-old\n+new\n".into(),
1349            ),
1350            before: None,
1351            after: None,
1352            rename_to: None,
1353        }];
1354        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1355        let tf = s.bubbles[0].tool_former_data.as_ref().unwrap();
1356        let result = tf.parse_result().unwrap().unwrap();
1357        let before_id = result["beforeContentId"].as_str().unwrap();
1358        let after_id = result["afterContentId"].as_str().unwrap();
1359        assert!(before_id.starts_with("composer.content."));
1360        assert!(after_id.starts_with("composer.content."));
1361        assert_ne!(before_id, after_id);
1362        let before_hash = before_id.trim_start_matches("composer.content.");
1363        let after_hash = after_id.trim_start_matches("composer.content.");
1364        assert_eq!(s.content_blobs.get(before_hash).map(String::as_str), Some("old\n"));
1365        assert_eq!(s.content_blobs.get(after_hash).map(String::as_str), Some("new\n"));
1366    }
1367
1368    #[test]
1369    fn edit_tool_with_full_content_prefers_blob_over_reconstruction() {
1370        let mut t = assistant_turn("a1", "");
1371        t.tool_uses = vec![ToolInvocation {
1372            id: "tc1".into(),
1373            name: "edit_file_v2".into(),
1374            input: json!({"relativeWorkspacePath": "/proj/x.rs"}),
1375            result: Some(ToolResult {
1376                content: "ok".into(),
1377                is_error: false,
1378            }),
1379            category: Some(ToolCategory::FileWrite),
1380        }];
1381        t.file_mutations = vec![FileMutation {
1382            path: "/proj/x.rs".into(),
1383            tool_id: Some("tc1".into()),
1384            operation: Some("edit".into()),
1385            raw_diff: Some("@@ -1,1 +1,1 @@\n-fake\n+also fake\n".into()),
1386            before: Some("real before".into()),
1387            after: Some("real after".into()),
1388            rename_to: None,
1389        }];
1390        let s = CursorProjector::new().project(&view_with(vec![t])).unwrap();
1391        let tf = s.bubbles[0].tool_former_data.as_ref().unwrap();
1392        let result = tf.parse_result().unwrap().unwrap();
1393        let before_hash = result["beforeContentId"].as_str().unwrap()
1394            .trim_start_matches("composer.content.");
1395        let after_hash = result["afterContentId"].as_str().unwrap()
1396            .trim_start_matches("composer.content.");
1397        assert_eq!(s.content_blobs.get(before_hash).map(String::as_str), Some("real before"));
1398        assert_eq!(s.content_blobs.get(after_hash).map(String::as_str), Some("real after"));
1399    }
1400
1401}