Skip to main content

chasm/
models.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Data models for VS Code chat sessions
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// VS Code workspace information
10#[derive(Debug, Clone)]
11pub struct Workspace {
12    /// Workspace hash (folder name in workspaceStorage)
13    pub hash: String,
14    /// Associated project path
15    pub project_path: Option<String>,
16    /// Full path to workspace directory
17    pub workspace_path: std::path::PathBuf,
18    /// Path to chatSessions directory
19    pub chat_sessions_path: std::path::PathBuf,
20    /// Number of chat session files
21    pub chat_session_count: usize,
22    /// Whether chatSessions directory exists
23    pub has_chat_sessions: bool,
24    /// Last modified timestamp
25    #[allow(dead_code)]
26    pub last_modified: Option<DateTime<Utc>>,
27}
28
29/// VS Code workspace.json structure
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceJson {
32    pub folder: Option<String>,
33}
34
35/// VS Code Chat Session (version 3 format)
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ChatSession {
39    /// Session format version
40    #[serde(default = "default_version")]
41    pub version: u32,
42
43    /// Unique session identifier (may not be present in file, use filename)
44    #[serde(default)]
45    pub session_id: Option<String>,
46
47    /// Creation timestamp (milliseconds)
48    #[serde(default)]
49    pub creation_date: i64,
50
51    /// Last message timestamp (milliseconds)
52    #[serde(default)]
53    pub last_message_date: i64,
54
55    /// Whether this session was imported
56    #[serde(default)]
57    pub is_imported: bool,
58
59    /// Initial location (panel, terminal, notebook, editor)
60    #[serde(default = "default_location")]
61    pub initial_location: String,
62
63    /// Custom title set by user
64    #[serde(default)]
65    pub custom_title: Option<String>,
66
67    /// Requester username
68    #[serde(default)]
69    pub requester_username: Option<String>,
70
71    /// Requester avatar URI
72    #[serde(default)]
73    pub requester_avatar_icon_uri: Option<serde_json::Value>,
74
75    /// Responder username
76    #[serde(default)]
77    pub responder_username: Option<String>,
78
79    /// Responder avatar URI
80    #[serde(default)]
81    pub responder_avatar_icon_uri: Option<serde_json::Value>,
82
83    /// Chat requests/messages
84    #[serde(default)]
85    pub requests: Vec<ChatRequest>,
86}
87
88impl ChatSession {
89    /// Collect all text content from the session (user messages and responses)
90    pub fn collect_all_text(&self) -> String {
91        self.requests
92            .iter()
93            .flat_map(|req| {
94                let mut texts = Vec::new();
95                if let Some(msg) = &req.message {
96                    if let Some(text) = &msg.text {
97                        texts.push(text.clone());
98                    }
99                }
100                if let Some(resp) = &req.response {
101                    if let Some(text) = extract_response_text(resp) {
102                        texts.push(text);
103                    }
104                }
105                texts
106            })
107            .collect::<Vec<_>>()
108            .join(" ")
109    }
110
111    /// Get user message texts
112    pub fn user_messages(&self) -> Vec<&str> {
113        self.requests
114            .iter()
115            .filter_map(|req| req.message.as_ref().and_then(|m| m.text.as_deref()))
116            .collect()
117    }
118
119    /// Get assistant response texts
120    pub fn assistant_responses(&self) -> Vec<String> {
121        self.requests
122            .iter()
123            .filter_map(|req| req.response.as_ref().and_then(|r| extract_response_text(r)))
124            .collect()
125    }
126}
127
128fn default_version() -> u32 {
129    3
130}
131
132fn default_location() -> String {
133    "panel".to_string()
134}
135
136/// Default response state: Complete (1)
137fn default_response_state() -> u8 {
138    1
139}
140
141/// A single chat request (message + response)
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct ChatRequest {
145    /// Request timestamp (milliseconds)
146    #[serde(default)]
147    pub timestamp: Option<i64>,
148
149    /// The user's message
150    #[serde(default)]
151    pub message: Option<ChatMessage>,
152
153    /// The AI's response (complex structure - use Value for flexibility)
154    #[serde(default)]
155    pub response: Option<serde_json::Value>,
156
157    /// Variable data (context, files, etc.)
158    #[serde(default)]
159    pub variable_data: Option<serde_json::Value>,
160
161    /// Request ID
162    #[serde(default)]
163    pub request_id: Option<String>,
164
165    /// Response ID
166    #[serde(default)]
167    pub response_id: Option<String>,
168
169    /// Model ID
170    #[serde(default)]
171    pub model_id: Option<String>,
172
173    /// Agent information
174    #[serde(default)]
175    pub agent: Option<serde_json::Value>,
176
177    /// Result metadata
178    #[serde(default)]
179    pub result: Option<serde_json::Value>,
180
181    /// Follow-up suggestions
182    #[serde(default)]
183    pub followups: Option<Vec<serde_json::Value>>,
184
185    /// Whether canceled
186    #[serde(default)]
187    pub is_canceled: Option<bool>,
188
189    /// Content references
190    #[serde(default)]
191    pub content_references: Option<Vec<serde_json::Value>>,
192
193    /// Code citations
194    #[serde(default)]
195    pub code_citations: Option<Vec<serde_json::Value>>,
196
197    /// Response markdown info
198    #[serde(default)]
199    pub response_markdown_info: Option<Vec<serde_json::Value>>,
200
201    /// Source session for merged requests
202    #[serde(rename = "_sourceSession", skip_serializing_if = "Option::is_none")]
203    pub source_session: Option<String>,
204
205    /// Model state tracking (VS Code 1.109+ / Copilot Chat 0.37+)
206    /// Object with `value` (0=Pending, 1=Complete, 2=Cancelled) and optional `completedAt`
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub model_state: Option<serde_json::Value>,
209
210    /// Time spent waiting for response (milliseconds)
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub time_spent_waiting: Option<i64>,
213}
214
215/// User message in a chat request
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct ChatMessage {
219    /// Message text
220    #[serde(alias = "content")]
221    pub text: Option<String>,
222
223    /// Message parts (for complex messages)
224    #[serde(default)]
225    pub parts: Option<Vec<serde_json::Value>>,
226}
227
228impl ChatMessage {
229    /// Get the text content of this message
230    pub fn get_text(&self) -> String {
231        self.text.clone().unwrap_or_default()
232    }
233}
234
235/// Extract text from various response formats.
236///
237/// Handles three formats:
238/// 1. **New array format (VS Code Copilot Chat 0.37+)**: response is a JSON array of
239///    response parts, each with an optional `kind` field. Markdown content parts have
240///    `kind: ""` (or absent) and a `value` field containing the text.
241/// 2. **Legacy object format**: response is `{"value": [{"value": "text"}, ...]}`.
242/// 3. **Simple formats**: response has `"text"` or `"content"` string fields.
243pub fn extract_response_text(response: &serde_json::Value) -> Option<String> {
244    // New format: response is an array of response parts
245    if let Some(parts) = response.as_array() {
246        let texts: Vec<&str> = parts
247            .iter()
248            .filter_map(|part| {
249                let kind = part.get("kind").and_then(|k| k.as_str()).unwrap_or("");
250                match kind {
251                    // Markdown content: kind is "" or absent
252                    "" => part.get("value").and_then(|v| v.as_str()),
253                    // Thinking blocks
254                    "thinking" => part.get("value").and_then(|v| v.as_str()),
255                    // Skip tool invocations, MCP server starts, etc.
256                    _ => None,
257                }
258            })
259            .collect();
260        if !texts.is_empty() {
261            return Some(texts.join("\n"));
262        }
263    }
264
265    // Legacy object format: {"value": [{"value": "text"}, ...]}
266    if let Some(value) = response.get("value").and_then(|v| v.as_array()) {
267        let parts: Vec<String> = value
268            .iter()
269            .filter_map(|v| v.get("value").and_then(|v| v.as_str()))
270            .map(String::from)
271            .collect();
272        if !parts.is_empty() {
273            return Some(parts.join("\n"));
274        }
275    }
276
277    // Try direct text field
278    if let Some(text) = response.get("text").and_then(|v| v.as_str()) {
279        return Some(text.to_string());
280    }
281
282    // Try result field (legacy)
283    if let Some(result) = response.get("result").and_then(|v| v.as_str()) {
284        return Some(result.to_string());
285    }
286
287    // Try content field (OpenAI format)
288    if let Some(content) = response.get("content").and_then(|v| v.as_str()) {
289        return Some(content.to_string());
290    }
291
292    None
293}
294
295/// AI response in a chat request
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298#[allow(dead_code)]
299pub struct ChatResponse {
300    /// Response text
301    #[serde(alias = "content")]
302    pub text: Option<String>,
303
304    /// Response parts
305    #[serde(default)]
306    pub parts: Option<Vec<serde_json::Value>>,
307
308    /// Result metadata
309    #[serde(default)]
310    pub result: Option<serde_json::Value>,
311}
312
313/// VS Code chat session index (stored in state.vscdb)
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ChatSessionIndex {
316    /// Index version
317    #[serde(default = "default_index_version")]
318    pub version: u32,
319
320    /// Session entries keyed by session ID.
321    /// Handles both old format (array of UUID strings) and new format (map of UUID → entry).
322    #[serde(default, deserialize_with = "deserialize_index_entries")]
323    pub entries: HashMap<String, ChatSessionIndexEntry>,
324}
325
326/// Custom deserializer that handles both index formats:
327/// - Old: `{"entries": ["uuid1", "uuid2", ...]}`
328/// - New: `{"entries": {"uuid1": {...}, "uuid2": {...}, ...}}`
329fn deserialize_index_entries<'de, D>(
330    deserializer: D,
331) -> std::result::Result<HashMap<String, ChatSessionIndexEntry>, D::Error>
332where
333    D: serde::Deserializer<'de>,
334{
335    use serde::de;
336
337    struct EntriesVisitor;
338
339    impl<'de> de::Visitor<'de> for EntriesVisitor {
340        type Value = HashMap<String, ChatSessionIndexEntry>;
341
342        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
343            f.write_str("a map of session entries or an array of session ID strings")
344        }
345
346        // New format: {"uuid": {entry}, ...}
347        fn visit_map<M>(self, mut access: M) -> std::result::Result<Self::Value, M::Error>
348        where
349            M: de::MapAccess<'de>,
350        {
351            let mut map = HashMap::new();
352            while let Some((key, value)) = access.next_entry::<String, ChatSessionIndexEntry>()? {
353                map.insert(key, value);
354            }
355            Ok(map)
356        }
357
358        // Old format: ["uuid1", "uuid2", ...]
359        fn visit_seq<S>(self, mut seq: S) -> std::result::Result<Self::Value, S::Error>
360        where
361            S: de::SeqAccess<'de>,
362        {
363            let mut map = HashMap::new();
364            while let Some(id) = seq.next_element::<String>()? {
365                let entry = ChatSessionIndexEntry {
366                    session_id: id.clone(),
367                    title: String::new(),
368                    last_message_date: 0,
369                    timing: None,
370                    last_response_state: 0,
371                    initial_location: "panel".to_string(),
372                    is_empty: false,
373                    is_imported: None,
374                    has_pending_edits: None,
375                    is_external: None,
376                };
377                map.insert(id, entry);
378            }
379            Ok(map)
380        }
381    }
382
383    deserializer.deserialize_any(EntriesVisitor)
384}
385
386fn default_index_version() -> u32 {
387    1
388}
389
390impl Default for ChatSessionIndex {
391    fn default() -> Self {
392        Self {
393            version: 1,
394            entries: HashMap::new(),
395        }
396    }
397}
398
399/// Session timing information (VS Code 1.109+)
400/// Supports both old format (startTime/endTime) and new format (created/lastRequestStarted/lastRequestEnded)
401#[derive(Debug, Clone, Default, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct ChatSessionTiming {
404    /// When the session was created (ms since epoch)
405    /// Old format used "startTime", new format uses "created"
406    #[serde(default, alias = "startTime")]
407    pub created: i64,
408
409    /// When the most recent request started (ms since epoch)
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub last_request_started: Option<i64>,
412
413    /// When the most recent request completed (ms since epoch)
414    /// Old format used "endTime", new format uses "lastRequestEnded"
415    #[serde(default, skip_serializing_if = "Option::is_none", alias = "endTime")]
416    pub last_request_ended: Option<i64>,
417}
418
419/// Entry in the chat session index
420#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct ChatSessionIndexEntry {
423    /// Session ID
424    pub session_id: String,
425
426    /// Session title
427    pub title: String,
428
429    /// Last message timestamp (milliseconds)
430    pub last_message_date: i64,
431
432    /// Session timing (VS Code 1.109+)
433    #[serde(default)]
434    pub timing: Option<ChatSessionTiming>,
435
436    /// Last response state: 0=Pending, 1=Complete, 2=Cancelled, 3=Failed, 4=NeedsInput
437    #[serde(default = "default_response_state")]
438    pub last_response_state: u8,
439
440    /// Initial location (panel, terminal, etc.)
441    #[serde(default = "default_location")]
442    pub initial_location: String,
443
444    /// Whether the session is empty
445    #[serde(default)]
446    pub is_empty: bool,
447
448    /// Whether the session was imported
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub is_imported: Option<bool>,
451
452    /// Whether the session has pending edits
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub has_pending_edits: Option<bool>,
455
456    /// Whether the session is from an external source
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub is_external: Option<bool>,
459}
460
461/// Entry in the `agentSessions.model.cache` DB key.
462/// This cache drives the Chat panel sidebar in VS Code — sessions *must* have
463/// a model cache entry to be visible in the UI.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465#[serde(rename_all = "camelCase")]
466pub struct ModelCacheEntry {
467    /// Always "local" for local sessions
468    #[serde(default)]
469    pub provider_type: String,
470
471    /// Always "Local" for local sessions
472    #[serde(default)]
473    pub provider_label: String,
474
475    /// Resource URI: `vscode-chat-session://local/{base64(sessionId)}`
476    pub resource: String,
477
478    /// Icon identifier (typically "vm")
479    #[serde(default)]
480    pub icon: String,
481
482    /// Session title (display label)
483    #[serde(default)]
484    pub label: String,
485
486    /// Status: 1 = valid
487    #[serde(default)]
488    pub status: u8,
489
490    /// Session timing information
491    #[serde(default)]
492    pub timing: ChatSessionTiming,
493
494    /// Initial location (panel, terminal, etc.)
495    #[serde(default)]
496    pub initial_location: String,
497
498    /// Whether the session has pending edits
499    #[serde(default)]
500    pub has_pending_edits: bool,
501
502    /// Whether the session is empty (no requests)
503    #[serde(default)]
504    pub is_empty: bool,
505
506    /// Whether the session is from an external source
507    #[serde(default)]
508    pub is_external: bool,
509
510    /// Last response state: 0=Pending, 1=Complete, 2=Cancelled, 3=Failed, 4=NeedsInput
511    #[serde(default)]
512    pub last_response_state: u8,
513}
514
515/// Entry in the `agentSessions.state.cache` DB key.
516/// Tracks read timestamps per session for UI state (e.g., unread indicators).
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct StateCacheEntry {
520    /// Resource URI: `vscode-chat-session://local/{base64(sessionId)}`
521    pub resource: String,
522
523    /// Timestamp of last read (ms since epoch)
524    #[serde(default)]
525    pub read: Option<i64>,
526}
527
528/// Session with its file path for internal processing
529#[derive(Debug, Clone)]
530pub struct SessionWithPath {
531    pub path: std::path::PathBuf,
532    pub session: ChatSession,
533}
534
535impl SessionWithPath {
536    /// Get the session ID from the session data or from the filename
537    #[allow(dead_code)]
538    pub fn get_session_id(&self) -> String {
539        self.session.session_id.clone().unwrap_or_else(|| {
540            self.path
541                .file_stem()
542                .map(|s| s.to_string_lossy().to_string())
543                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
544        })
545    }
546}
547
548impl ChatSession {
549    /// Get the session ID (from field or will need to be set from filename)
550    #[allow(dead_code)]
551    pub fn get_session_id(&self) -> String {
552        self.session_id
553            .clone()
554            .unwrap_or_else(|| "unknown".to_string())
555    }
556
557    /// Get the title for this session (from custom_title or first message)
558    pub fn title(&self) -> String {
559        // First try custom_title
560        if let Some(title) = &self.custom_title {
561            if !title.is_empty() {
562                return title.clone();
563            }
564        }
565
566        // Try to extract from first message
567        if let Some(first_req) = self.requests.first() {
568            if let Some(msg) = &first_req.message {
569                if let Some(text) = &msg.text {
570                    // Truncate to first 50 chars
571                    let title: String = text.chars().take(50).collect();
572                    if !title.is_empty() {
573                        if title.len() < text.len() {
574                            return format!("{}...", title);
575                        }
576                        return title;
577                    }
578                }
579            }
580        }
581
582        "Untitled".to_string()
583    }
584
585    /// Check if this session is empty
586    pub fn is_empty(&self) -> bool {
587        self.requests.is_empty()
588    }
589
590    /// Get the request count
591    pub fn request_count(&self) -> usize {
592        self.requests.len()
593    }
594
595    /// Get the timestamp range of requests
596    pub fn timestamp_range(&self) -> Option<(i64, i64)> {
597        if self.requests.is_empty() {
598            return None;
599        }
600
601        let timestamps: Vec<i64> = self.requests.iter().filter_map(|r| r.timestamp).collect();
602
603        if timestamps.is_empty() {
604            return None;
605        }
606
607        let min = *timestamps.iter().min().unwrap();
608        let max = *timestamps.iter().max().unwrap();
609        Some((min, max))
610    }
611}