1use 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 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 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
409fn 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 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
725fn 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
761fn 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
814fn 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
840fn 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}