1use chrono::{DateTime, TimeZone, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[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#[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 #[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 #[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#[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 #[serde(rename = "richText", default, skip_serializing_if = "Option::is_none")]
235 pub rich_text: Option<String>,
236 #[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 #[serde(rename = "allThinkingBlocks", default)]
258 pub all_thinking_blocks: Vec<ThinkingBlock>,
259 #[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#[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 #[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
375pub 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#[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 #[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#[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}