Skip to main content

toolpath_cursor/
types.rs

1//! On-disk schema for Cursor's bubble store. See
2//! `docs/agents/formats/cursor.md` for the full field census.
3
4use chrono::{DateTime, TimeZone, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10// ── Composer index (`ItemTable.composer.composerHeaders`) ──────────────
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ComposerHeaders {
14    #[serde(rename = "allComposers", default)]
15    pub all_composers: Vec<ComposerHead>,
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct ComposerHead {
20    #[serde(rename = "type", default)]
21    pub kind: Option<String>,
22    #[serde(rename = "composerId")]
23    pub composer_id: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub name: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub subtitle: Option<String>,
28    #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
29    pub created_at: Option<i64>,
30    #[serde(rename = "lastUpdatedAt", default, skip_serializing_if = "Option::is_none")]
31    pub last_updated_at: Option<i64>,
32    #[serde(
33        rename = "conversationCheckpointLastUpdatedAt",
34        default,
35        skip_serializing_if = "Option::is_none"
36    )]
37    pub conversation_checkpoint_last_updated_at: Option<i64>,
38    #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")]
39    pub unified_mode: Option<String>,
40    #[serde(rename = "forceMode", default, skip_serializing_if = "Option::is_none")]
41    pub force_mode: Option<String>,
42    #[serde(rename = "isArchived", default)]
43    pub is_archived: bool,
44    #[serde(rename = "isDraft", default)]
45    pub is_draft: bool,
46    #[serde(rename = "hasUnreadMessages", default)]
47    pub has_unread_messages: bool,
48    #[serde(rename = "totalLinesAdded", default, skip_serializing_if = "Option::is_none")]
49    pub total_lines_added: Option<i64>,
50    #[serde(rename = "totalLinesRemoved", default, skip_serializing_if = "Option::is_none")]
51    pub total_lines_removed: Option<i64>,
52    #[serde(rename = "filesChangedCount", default, skip_serializing_if = "Option::is_none")]
53    pub files_changed_count: Option<i64>,
54    #[serde(rename = "contextUsagePercent", default, skip_serializing_if = "Option::is_none")]
55    pub context_usage_percent: Option<f64>,
56    #[serde(rename = "numSubComposers", default)]
57    pub num_sub_composers: u32,
58    #[serde(rename = "workspaceIdentifier", default, skip_serializing_if = "Option::is_none")]
59    pub workspace_identifier: Option<WorkspaceIdentifier>,
60    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
61    pub extra: HashMap<String, Value>,
62}
63
64impl ComposerHead {
65    pub fn created_at_utc(&self) -> Option<DateTime<Utc>> {
66        self.created_at
67            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
68    }
69
70    pub fn last_updated_at_utc(&self) -> Option<DateTime<Utc>> {
71        self.last_updated_at
72            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
73    }
74
75    pub fn workspace_path(&self) -> Option<PathBuf> {
76        self.workspace_identifier
77            .as_ref()?
78            .uri
79            .as_ref()?
80            .fs_path
81            .as_deref()
82            .map(PathBuf::from)
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct WorkspaceIdentifier {
88    pub id: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub uri: Option<WorkspaceUri>,
91}
92
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct WorkspaceUri {
95    #[serde(rename = "$mid", default, skip_serializing_if = "Option::is_none")]
96    pub mid: Option<i64>,
97    #[serde(rename = "fsPath", default, skip_serializing_if = "Option::is_none")]
98    pub fs_path: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub external: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub path: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub scheme: Option<String>,
105}
106
107// ── Composer data (`cursorDiskKV.composerData:<uuid>`) ─────────────────
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ComposerData {
111    #[serde(rename = "_v", default)]
112    pub v: u32,
113    #[serde(rename = "composerId")]
114    pub composer_id: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub name: Option<String>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub subtitle: Option<String>,
119    #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
120    pub created_at: Option<i64>,
121    #[serde(rename = "lastUpdatedAt", default, skip_serializing_if = "Option::is_none")]
122    pub last_updated_at: Option<i64>,
123    #[serde(rename = "isAgentic", default)]
124    pub is_agentic: bool,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub status: Option<String>,
127    #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")]
128    pub unified_mode: Option<String>,
129    #[serde(rename = "forceMode", default, skip_serializing_if = "Option::is_none")]
130    pub force_mode: Option<String>,
131    #[serde(rename = "agentBackend", default, skip_serializing_if = "Option::is_none")]
132    pub agent_backend: Option<String>,
133    #[serde(rename = "modelConfig", default, skip_serializing_if = "Option::is_none")]
134    pub model_config: Option<ModelConfig>,
135    /// May contain more entries than there are `bubbleId:` rows on disk;
136    /// don't use for iteration order.
137    #[serde(rename = "fullConversationHeadersOnly", default)]
138    pub full_conversation_headers_only: Vec<BubbleHeader>,
139    #[serde(rename = "subComposerIds", default, skip_serializing_if = "Vec::is_empty")]
140    pub sub_composer_ids: Vec<String>,
141    #[serde(rename = "subagentComposerIds", default, skip_serializing_if = "Vec::is_empty")]
142    pub subagent_composer_ids: Vec<String>,
143    #[serde(rename = "latestChatGenerationUUID", default, skip_serializing_if = "Option::is_none")]
144    pub latest_chat_generation_uuid: Option<String>,
145    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
146    pub extra: HashMap<String, Value>,
147}
148
149impl ComposerData {
150    pub fn created_at_utc(&self) -> Option<DateTime<Utc>> {
151        self.created_at
152            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
153    }
154
155    pub fn last_updated_at_utc(&self) -> Option<DateTime<Utc>> {
156        self.last_updated_at
157            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
158    }
159
160    pub fn default_model(&self) -> Option<&str> {
161        self.model_config
162            .as_ref()
163            .and_then(|mc| mc.model_name.as_deref())
164            .filter(|s| !s.is_empty())
165    }
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct ModelConfig {
170    #[serde(rename = "modelName", default, skip_serializing_if = "Option::is_none")]
171    pub model_name: Option<String>,
172    #[serde(rename = "maxMode", default)]
173    pub max_mode: bool,
174    #[serde(rename = "selectedModels", default, skip_serializing_if = "Vec::is_empty")]
175    pub selected_models: Vec<SelectedModel>,
176    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
177    pub extra: HashMap<String, Value>,
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct SelectedModel {
182    #[serde(rename = "modelId")]
183    pub model_id: String,
184    // Must serialize even when empty — Cursor's loader calls
185    // `parameters.map(...)` unconditionally; absent throws and hangs
186    // the chat panel.
187    #[serde(default)]
188    pub parameters: Vec<Value>,
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub struct BubbleHeader {
193    #[serde(rename = "bubbleId")]
194    pub bubble_id: String,
195    #[serde(rename = "type", default)]
196    pub kind: u8,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub grouping: Option<BubbleGrouping>,
199    #[serde(rename = "contentHeightHint", default, skip_serializing_if = "Option::is_none")]
200    pub content_height_hint: Option<u32>,
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct BubbleGrouping {
205    #[serde(rename = "isRenderable", default)]
206    pub is_renderable: bool,
207    #[serde(rename = "hasText", default, skip_serializing_if = "Option::is_none")]
208    pub has_text: Option<bool>,
209    #[serde(rename = "hasThinking", default, skip_serializing_if = "Option::is_none")]
210    pub has_thinking: Option<bool>,
211    #[serde(rename = "thinkingDurationMs", default, skip_serializing_if = "Option::is_none")]
212    pub thinking_duration_ms: Option<u64>,
213    #[serde(rename = "capabilityType", default, skip_serializing_if = "Option::is_none")]
214    pub capability_type: Option<u32>,
215    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
216    pub extra: HashMap<String, Value>,
217}
218
219// ── Bubble (`cursorDiskKV.bubbleId:<composer>:<bubble>`) ────────────────
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub struct Bubble {
223    #[serde(rename = "_v", default)]
224    pub v: u32,
225    #[serde(rename = "bubbleId")]
226    pub bubble_id: String,
227    #[serde(rename = "type", default)]
228    pub kind: u8,
229    #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
230    pub created_at: Option<String>,
231    #[serde(default)]
232    pub text: String,
233    /// Escaped JSON string holding a Lexical document. Opaque.
234    #[serde(rename = "richText", default, skip_serializing_if = "Option::is_none")]
235    pub rich_text: Option<String>,
236    /// `15` = tool, `30` = thinking, `null` = text.
237    #[serde(rename = "capabilityType", default, skip_serializing_if = "Option::is_none")]
238    pub capability_type: Option<u32>,
239    #[serde(rename = "conversationState", default, skip_serializing_if = "Option::is_none")]
240    pub conversation_state: Option<String>,
241    #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")]
242    pub unified_mode: Option<u32>,
243    #[serde(rename = "isAgentic", default)]
244    pub is_agentic: bool,
245    #[serde(rename = "requestId", default, skip_serializing_if = "Option::is_none")]
246    pub request_id: Option<String>,
247    #[serde(rename = "checkpointId", default, skip_serializing_if = "Option::is_none")]
248    pub checkpoint_id: Option<String>,
249    #[serde(rename = "tokenCount", default, skip_serializing_if = "Option::is_none")]
250    pub token_count: Option<TokenCount>,
251    #[serde(rename = "modelInfo", default, skip_serializing_if = "Option::is_none")]
252    pub model_info: Option<ModelInfo>,
253    #[serde(rename = "toolFormerData", default, skip_serializing_if = "Option::is_none")]
254    pub tool_former_data: Option<ToolFormerData>,
255    /// Must serialize as `[]` when empty — Cursor's renderer calls
256    /// `Object.entries(undefined)` on the thinking-blocks indexer.
257    #[serde(rename = "allThinkingBlocks", default)]
258    pub all_thinking_blocks: Vec<ThinkingBlock>,
259    /// Must serialize as `[]` when empty — same renderer trap as
260    /// `allThinkingBlocks`.
261    #[serde(rename = "toolResults", default)]
262    pub tool_results: Vec<Value>,
263    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
264    pub extra: HashMap<String, Value>,
265}
266
267impl Bubble {
268    pub fn created_at_utc(&self) -> Option<DateTime<Utc>> {
269        self.created_at
270            .as_ref()?
271            .parse::<DateTime<Utc>>()
272            .ok()
273    }
274
275    pub fn is_user(&self) -> bool {
276        self.kind == BUBBLE_TYPE_USER
277    }
278
279    pub fn is_assistant(&self) -> bool {
280        self.kind == BUBBLE_TYPE_ASSISTANT
281    }
282
283    pub fn is_thinking(&self) -> bool {
284        self.capability_type == Some(CAPABILITY_THINKING)
285    }
286
287    pub fn is_tool(&self) -> bool {
288        self.tool_former_data.is_some() || self.capability_type == Some(CAPABILITY_TOOL)
289    }
290}
291
292pub const BUBBLE_TYPE_USER: u8 = 1;
293pub const BUBBLE_TYPE_ASSISTANT: u8 = 2;
294
295pub const CAPABILITY_TOOL: u32 = 15;
296pub const CAPABILITY_THINKING: u32 = 30;
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct TokenCount {
300    #[serde(rename = "inputTokens", default, skip_serializing_if = "Option::is_none")]
301    pub input_tokens: Option<u64>,
302    #[serde(rename = "outputTokens", default, skip_serializing_if = "Option::is_none")]
303    pub output_tokens: Option<u64>,
304    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
305    pub extra: HashMap<String, Value>,
306}
307
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309pub struct ModelInfo {
310    #[serde(rename = "modelName", default, skip_serializing_if = "Option::is_none")]
311    pub model_name: Option<String>,
312    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
313    pub extra: HashMap<String, Value>,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317pub struct ThinkingBlock {
318    #[serde(default)]
319    pub text: String,
320    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
321    pub extra: HashMap<String, Value>,
322}
323
324// ── Tool call ─────────────────────────────────────────────────────────
325
326/// `params` and `result` are JSON strings (double-encoded on the wire);
327/// parse on use via [`ToolFormerData::parse_params`] / [`ToolFormerData::parse_result`].
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
329pub struct ToolFormerData {
330    pub tool: u32,
331    #[serde(rename = "toolIndex", default, skip_serializing_if = "Option::is_none")]
332    pub tool_index: Option<u32>,
333    #[serde(rename = "modelCallId", default, skip_serializing_if = "Option::is_none")]
334    pub model_call_id: Option<String>,
335    #[serde(rename = "toolCallId")]
336    pub tool_call_id: String,
337    /// `"completed"` | `"error"` | `"running"`.
338    #[serde(default)]
339    pub status: String,
340    #[serde(default)]
341    pub name: String,
342    #[serde(default)]
343    pub params: String,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub result: Option<String>,
346    #[serde(rename = "additionalData", default, skip_serializing_if = "Option::is_none")]
347    pub additional_data: Option<Value>,
348}
349
350impl ToolFormerData {
351    pub fn parse_params(&self) -> std::result::Result<Value, serde_json::Error> {
352        if self.params.is_empty() {
353            return Ok(Value::Null);
354        }
355        serde_json::from_str(&self.params)
356    }
357
358    pub fn parse_result(&self) -> std::result::Result<Option<Value>, serde_json::Error> {
359        match &self.result {
360            None => Ok(None),
361            Some(s) if s.is_empty() => Ok(None),
362            Some(s) => Ok(Some(serde_json::from_str(s)?)),
363        }
364    }
365
366    pub fn is_error(&self) -> bool {
367        self.status == "error"
368    }
369
370    pub fn is_completed(&self) -> bool {
371        self.status == "completed"
372    }
373}
374
375/// Cursor's numeric tool-dispatch enum, extracted from
376/// `Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`.
377/// The numeric id is what Cursor's UI dispatches on; a projector that
378/// wants its output to load back into Cursor.app must emit the
379/// canonical id, not just preserve the name.
380pub const TOOL_TABLE: &[(u32, &str)] = &[
381    (0, "unspecified"),
382    (1, "read_semsearch_files"),
383    (3, "ripgrep_search"),
384    (5, "read_file"),
385    (6, "list_dir"),
386    (7, "edit_file"),
387    (8, "file_search"),
388    (9, "semantic_search_full"),
389    (11, "delete_file"),
390    (12, "reapply"),
391    (15, "run_terminal_command_v2"),
392    (16, "fetch_rules"),
393    (18, "web_search"),
394    (19, "mcp"),
395    (23, "search_symbols"),
396    (24, "background_composer_followup"),
397    (25, "knowledge_base"),
398    (26, "fetch_pull_request"),
399    (27, "deep_search"),
400    (28, "create_diagram"),
401    (29, "fix_lints"),
402    (30, "read_lints"),
403    (31, "go_to_definition"),
404    (32, "task"),
405    (33, "await_task"),
406    (34, "todo_read"),
407    (35, "todo_write"),
408    (38, "edit_file_v2"),
409    (39, "list_dir_v2"),
410    (40, "read_file_v2"),
411    (41, "ripgrep_raw_search"),
412    (42, "glob_file_search"),
413    (43, "create_plan"),
414    (44, "list_mcp_resources"),
415    (45, "read_mcp_resource"),
416    (46, "read_project"),
417    (47, "update_project"),
418    (48, "task_v2"),
419    (49, "call_mcp_tool"),
420    (50, "apply_agent_diff"),
421    (51, "ask_question"),
422    (52, "switch_mode"),
423    (53, "generate_image"),
424    (54, "computer_use"),
425    (55, "write_shell_stdin"),
426    (56, "record_screen"),
427    (57, "web_fetch"),
428    (58, "report_bugfix_results"),
429    (59, "ai_attribution"),
430    (60, "mcp_auth"),
431    (61, "reflect"),
432    (62, "await"),
433    (63, "get_mcp_tools"),
434];
435
436pub const TOOL_UNSPECIFIED: u32 = 0;
437
438pub fn tool_id_for_name(name: &str) -> Option<u32> {
439    TOOL_TABLE
440        .iter()
441        .find_map(|(id, n)| if *n == name { Some(*id) } else { None })
442}
443
444pub fn tool_name_for_id(id: u32) -> Option<&'static str> {
445    TOOL_TABLE
446        .iter()
447        .find_map(|(i, name)| if *i == id { Some(*name) } else { None })
448}
449
450pub const TOOL_RUN_TERMINAL_COMMAND_V2: u32 = 15;
451pub const TOOL_EDIT_FILE_V2: u32 = 38;
452pub const TOOL_READ_FILE_V2: u32 = 40;
453pub const TOOL_GLOB_FILE_SEARCH: u32 = 42;
454pub const TOOL_RIPGREP_RAW_SEARCH: u32 = 41;
455pub const TOOL_TASK_V2: u32 = 48;
456pub const TOOL_WEB_SEARCH: u32 = 18;
457
458// ── Session — what the reader emits ────────────────────────────────────
459
460#[derive(Debug, Clone, Default, Serialize, Deserialize)]
461pub struct CursorSession {
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub head: Option<ComposerHead>,
464    pub data: ComposerData,
465    #[serde(default)]
466    pub bubbles: Vec<Bubble>,
467    /// Keyed by hash (the part after `composer.content.`).
468    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
469    pub content_blobs: HashMap<String, String>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub transcript_path: Option<PathBuf>,
472}
473
474impl CursorSession {
475    pub fn id(&self) -> &str {
476        &self.data.composer_id
477    }
478
479    pub fn title(&self) -> Option<String> {
480        self.data
481            .name
482            .clone()
483            .or_else(|| self.head.as_ref().and_then(|h| h.name.clone()))
484    }
485
486    pub fn started_at(&self) -> Option<DateTime<Utc>> {
487        self.data
488            .created_at_utc()
489            .or_else(|| self.head.as_ref().and_then(|h| h.created_at_utc()))
490    }
491
492    pub fn last_activity(&self) -> Option<DateTime<Utc>> {
493        self.data
494            .last_updated_at_utc()
495            .or_else(|| self.head.as_ref().and_then(|h| h.last_updated_at_utc()))
496    }
497
498    pub fn workspace_path(&self) -> Option<PathBuf> {
499        self.head.as_ref().and_then(|h| h.workspace_path())
500    }
501
502    pub fn first_user_text(&self) -> Option<String> {
503        self.bubbles
504            .iter()
505            .find(|b| b.is_user() && !b.text.is_empty())
506            .map(|b| b.text.clone())
507    }
508
509    pub fn content_blob(&self, hash: &str) -> Option<&str> {
510        self.content_blobs.get(hash).map(|s| s.as_str())
511    }
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct CursorSessionMetadata {
516    pub id: String,
517    pub name: Option<String>,
518    pub subtitle: Option<String>,
519    pub started_at: Option<DateTime<Utc>>,
520    pub last_activity: Option<DateTime<Utc>>,
521    pub unified_mode: Option<String>,
522    pub workspace_path: Option<PathBuf>,
523    pub message_count: usize,
524    pub first_user_message: Option<String>,
525}
526
527// ── Tests ─────────────────────────────────────────────────────────────
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn tool_table_round_trips_name_and_id() {
535        for (id, name) in TOOL_TABLE {
536            assert_eq!(tool_id_for_name(name), Some(*id));
537            assert_eq!(tool_name_for_id(*id), Some(*name));
538        }
539    }
540
541    #[test]
542    fn tool_table_has_all_observed_ids() {
543        for &id in &[15u32, 38, 40, 41, 42, 48] {
544            assert!(
545                tool_name_for_id(id).is_some(),
546                "expected tool id {id} in TOOL_TABLE",
547            );
548        }
549    }
550
551    #[test]
552    fn tool_table_unknown_lookups_return_none() {
553        assert_eq!(tool_id_for_name("future_tool_v9"), None);
554        assert_eq!(tool_name_for_id(9999), None);
555    }
556
557    #[test]
558    fn parse_minimal_composer_data() {
559        let raw = r#"{
560            "_v": 16,
561            "composerId": "abcd-1",
562            "name": "Test session",
563            "createdAt": 1780325474978,
564            "lastUpdatedAt": 1780325508923,
565            "isAgentic": true,
566            "unifiedMode": "agent",
567            "agentBackend": "cursor-agent",
568            "modelConfig": {
569                "modelName": "default",
570                "maxMode": false,
571                "selectedModels": [{"modelId": "default", "parameters": []}]
572            },
573            "fullConversationHeadersOnly": [
574                {"bubbleId": "b1", "type": 1, "grouping": {"isRenderable": true, "hasText": true}}
575            ],
576            "futureField": "kept"
577        }"#;
578        let cd: ComposerData = serde_json::from_str(raw).unwrap();
579        assert_eq!(cd.v, 16);
580        assert_eq!(cd.composer_id, "abcd-1");
581        assert_eq!(cd.name.as_deref(), Some("Test session"));
582        assert!(cd.is_agentic);
583        assert_eq!(cd.unified_mode.as_deref(), Some("agent"));
584        assert_eq!(cd.default_model(), Some("default"));
585        assert_eq!(cd.full_conversation_headers_only.len(), 1);
586        assert_eq!(cd.extra.get("futureField"), Some(&Value::String("kept".into())));
587    }
588
589    #[test]
590    fn parse_user_bubble() {
591        let raw = r#"{
592            "_v": 3,
593            "type": 1,
594            "bubbleId": "bub-user-1",
595            "createdAt": "2026-06-01T14:51:48.926Z",
596            "text": "hello",
597            "conversationState": "~"
598        }"#;
599        let b: Bubble = serde_json::from_str(raw).unwrap();
600        assert!(b.is_user());
601        assert!(!b.is_assistant());
602        assert_eq!(b.text, "hello");
603        assert_eq!(b.created_at.as_deref(), Some("2026-06-01T14:51:48.926Z"));
604        assert!(b.created_at_utc().is_some());
605    }
606
607    #[test]
608    fn parse_assistant_tool_bubble() {
609        let raw = r#"{
610            "_v": 3,
611            "type": 2,
612            "bubbleId": "bub-tool-1",
613            "createdAt": "2026-06-01T14:52:08.647Z",
614            "text": "",
615            "capabilityType": 15,
616            "tokenCount": {"inputTokens": 0, "outputTokens": 0},
617            "toolFormerData": {
618                "tool": 38,
619                "toolIndex": 0,
620                "modelCallId": "",
621                "toolCallId": "tool_x",
622                "status": "completed",
623                "name": "edit_file_v2",
624                "params": "{\"relativeWorkspacePath\":\"/p/x.rs\",\"noCodeblock\":true}",
625                "result": "{\"beforeContentId\":\"composer.content.e3b0c4\",\"afterContentId\":\"composer.content.abcd\"}"
626            }
627        }"#;
628        let b: Bubble = serde_json::from_str(raw).unwrap();
629        assert!(b.is_assistant());
630        assert!(b.is_tool());
631        let tf = b.tool_former_data.as_ref().unwrap();
632        assert_eq!(tf.tool, TOOL_EDIT_FILE_V2);
633        assert!(tf.is_completed());
634        let params = tf.parse_params().unwrap();
635        assert_eq!(params["relativeWorkspacePath"], "/p/x.rs");
636        let result = tf.parse_result().unwrap().unwrap();
637        assert_eq!(result["afterContentId"], "composer.content.abcd");
638    }
639
640    #[test]
641    fn parse_assistant_thinking_bubble() {
642        let raw = r#"{
643            "_v": 3,
644            "type": 2,
645            "bubbleId": "bub-think-1",
646            "createdAt": "2026-06-01T14:52:01.382Z",
647            "text": "",
648            "capabilityType": 30
649        }"#;
650        let b: Bubble = serde_json::from_str(raw).unwrap();
651        assert!(b.is_thinking());
652        assert!(!b.is_tool());
653    }
654
655    #[test]
656    fn tool_former_error_has_no_result() {
657        let raw = r#"{
658            "tool": 38,
659            "toolCallId": "tool_err",
660            "status": "error",
661            "name": "edit_file_v2",
662            "params": "{}",
663            "result": null
664        }"#;
665        let tf: ToolFormerData = serde_json::from_str(raw).unwrap();
666        assert!(tf.is_error());
667        assert!(tf.parse_result().unwrap().is_none());
668    }
669
670    #[test]
671    fn composer_headers_roundtrip() {
672        let raw = r#"{
673            "allComposers": [
674                {
675                    "type": "head",
676                    "composerId": "abcd-1",
677                    "name": "X",
678                    "createdAt": 1000,
679                    "lastUpdatedAt": 2000,
680                    "unifiedMode": "agent",
681                    "isArchived": false,
682                    "isDraft": false,
683                    "hasUnreadMessages": false,
684                    "workspaceIdentifier": {
685                        "id": "ws1",
686                        "uri": {"$mid": 1, "fsPath": "/p", "path": "/p", "scheme": "file"}
687                    }
688                }
689            ]
690        }"#;
691        let h: ComposerHeaders = serde_json::from_str(raw).unwrap();
692        assert_eq!(h.all_composers.len(), 1);
693        let head = &h.all_composers[0];
694        assert_eq!(head.composer_id, "abcd-1");
695        assert_eq!(head.workspace_path().unwrap(), PathBuf::from("/p"));
696        assert!(head.created_at_utc().is_some());
697    }
698
699    #[test]
700    fn composer_headers_tolerate_empty_uri_for_remote_workspace() {
701        let raw = r#"{
702            "allComposers": [
703                {
704                    "type": "head",
705                    "composerId": "abcd-2",
706                    "workspaceIdentifier": {"id": "1780325463952", "uri": {}}
707                }
708            ]
709        }"#;
710        let h: ComposerHeaders = serde_json::from_str(raw).unwrap();
711        assert_eq!(h.all_composers.len(), 1);
712        let head = &h.all_composers[0];
713        assert!(head.workspace_path().is_none());
714        assert_eq!(
715            head.workspace_identifier.as_ref().unwrap().id,
716            "1780325463952"
717        );
718    }
719
720    #[test]
721    fn composer_headers_tolerate_missing_workspace_identifier() {
722        let raw = r#"{
723            "allComposers": [
724                {"type": "head", "composerId": "abcd-3"}
725            ]
726        }"#;
727        let h: ComposerHeaders = serde_json::from_str(raw).unwrap();
728        assert!(h.all_composers[0].workspace_identifier.is_none());
729        assert!(h.all_composers[0].workspace_path().is_none());
730    }
731
732    #[test]
733    fn unknown_capability_type_round_trips() {
734        let raw = r#"{
735            "_v": 3,
736            "type": 2,
737            "bubbleId": "b",
738            "text": "x",
739            "capabilityType": 999
740        }"#;
741        let b: Bubble = serde_json::from_str(raw).unwrap();
742        assert_eq!(b.capability_type, Some(999));
743        assert!(!b.is_thinking());
744        assert!(!b.is_tool());
745    }
746
747    #[test]
748    fn unknown_tool_id_parses() {
749        let raw = r#"{
750            "tool": 12345,
751            "toolCallId": "tool_z",
752            "status": "completed",
753            "name": "future_tool_v9",
754            "params": "{}",
755            "result": "{}"
756        }"#;
757        let tf: ToolFormerData = serde_json::from_str(raw).unwrap();
758        assert_eq!(tf.tool, 12345);
759        assert_eq!(tf.name, "future_tool_v9");
760    }
761}