Skip to main content

chasm/
copilot_version.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Copilot Chat extension version detection and compatibility analysis
4//!
5//! Detects installed VS Code Copilot Chat extension versions, extracts version
6//! information from session data, and checks compatibility between extension
7//! versions and VS Code versions.
8//!
9//! This module supports the recovery pipeline by identifying version mismatches
10//! that can cause session recovery failures.
11
12use anyhow::{Context, Result};
13use semver::Version;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18// ============================================================================
19// Version Info Structures
20// ============================================================================
21
22/// Information about an installed Copilot Chat extension
23#[derive(Debug, Clone)]
24pub struct CopilotChatInstall {
25    /// Extension version (e.g., "0.37.8")
26    pub version: Version,
27    /// Minimum VS Code version required (from package.json engines.vscode)
28    pub required_vscode_version: String,
29    /// Required Node.js version (from package.json engines.node)
30    pub required_node_version: Option<String>,
31    /// Full path to the extension directory
32    pub extension_path: PathBuf,
33    /// Whether this is the currently active (newest) installation
34    pub is_active: bool,
35}
36
37/// Comprehensive Copilot Chat version report
38#[derive(Debug, Clone)]
39pub struct CopilotVersionReport {
40    /// All installed Copilot Chat extension versions
41    pub installed: Vec<CopilotChatInstall>,
42    /// The active (newest) installation, if any
43    pub active_version: Option<Version>,
44    /// Extension versions found in session data (version string -> count)
45    pub session_versions: HashMap<String, usize>,
46    /// Detected version mismatches / compatibility issues
47    pub issues: Vec<VersionIssue>,
48}
49
50/// A version compatibility issue
51#[derive(Debug, Clone)]
52pub struct VersionIssue {
53    /// Severity: "error", "warning", "info"
54    pub severity: &'static str,
55    /// Human-readable description
56    pub message: String,
57}
58
59// ============================================================================
60// Minimal package.json deserialization
61// ============================================================================
62
63#[derive(Deserialize, Debug)]
64struct PackageJson {
65    version: Option<String>,
66    engines: Option<Engines>,
67}
68
69#[derive(Deserialize, Debug)]
70struct Engines {
71    vscode: Option<String>,
72    node: Option<String>,
73}
74
75// ============================================================================
76// Known Version Compatibility Table
77// ============================================================================
78
79/// Known Copilot Chat version ranges and their session format characteristics.
80/// Derived from the CHANGELOG at https://github.com/microsoft/vscode-copilot-chat
81///
82/// Key format transitions:
83///   - 0.25.x  (Feb 2025)  VS Code 1.98  — Agent mode experimental, legacy JSON
84///   - 0.26.x  (Mar 2025)  VS Code 1.99  — Unified chat view, MCP support
85///   - 0.27.x  (Apr 2025)  VS Code 1.100 — Prompt files, instructions files
86///   - 0.28.x  (May 2025)  VS Code 1.101 — Custom chat modes, tool sets
87///   - 0.29.x  (Jun 2025)  VS Code 1.102 — Copilot Chat open source release
88///   - 0.30.x  (Jul 2025)  VS Code 1.103 — Chat checkpoints, tool picker revamp
89///   - 0.31.x  (Aug 2025)  VS Code 1.104 — Auto model selection, todo list tool
90///   - 0.32.x  (Sep 2025)  VS Code 1.105 — MCP marketplace, session management
91///   - 0.33.x  (Oct 2025)  VS Code 1.106 — Agent Sessions view, plan agent
92///   - 0.35.x  (Nov 2025)  VS Code 1.107 — Agent sessions in Chat view, language models editor
93///   - 0.36.x  (Dec 2025)  VS Code 1.108 — Agent Skills experimental
94///   - 0.37.x  (Jan 2026)  VS Code 1.109 — JSONL format (event-sourced), session type picker
95///
96/// The JSONL format transition happened with VS Code 1.109.0+ (January 2026).
97/// Sessions created with 0.37.x use JSONL; earlier versions use legacy JSON.
98pub struct VersionCompatEntry {
99    pub extension_min: &'static str,
100    pub extension_max: &'static str,
101    pub vscode_min: &'static str,
102    pub session_format: &'static str,
103    pub notes: &'static str,
104}
105
106/// Get the known compatibility table
107pub fn known_compatibility() -> Vec<VersionCompatEntry> {
108    vec![
109        VersionCompatEntry {
110            extension_min: "0.25.0",
111            extension_max: "0.36.99",
112            vscode_min: "1.98.0",
113            session_format: "json",
114            notes: "Legacy JSON format (single object)",
115        },
116        VersionCompatEntry {
117            extension_min: "0.37.0",
118            extension_max: "0.37.99",
119            vscode_min: "1.109.0",
120            session_format: "jsonl",
121            notes: "JSONL event-sourced format (kind 0/1/2)",
122        },
123    ]
124}
125
126// ============================================================================
127// Extension Detection
128// ============================================================================
129
130/// Get the VS Code extensions directory for the current platform
131fn get_vscode_extensions_dir() -> Option<PathBuf> {
132    let home = dirs::home_dir()?;
133    let path = home.join(".vscode").join("extensions");
134    if path.exists() {
135        Some(path)
136    } else {
137        None
138    }
139}
140
141/// Detect all installed Copilot Chat extension versions.
142///
143/// Scans `~/.vscode/extensions/` for directories matching `github.copilot-chat-*`
144/// and reads each one's `package.json` for version and engine requirements.
145pub fn detect_installed_versions() -> Result<Vec<CopilotChatInstall>> {
146    let extensions_dir = match get_vscode_extensions_dir() {
147        Some(d) => d,
148        None => return Ok(Vec::new()),
149    };
150
151    let mut installs: Vec<CopilotChatInstall> = Vec::new();
152
153    for entry in std::fs::read_dir(&extensions_dir).with_context(|| {
154        format!(
155            "Failed to read extensions dir: {}",
156            extensions_dir.display()
157        )
158    })? {
159        let entry = entry?;
160        let dir_name = entry.file_name().to_string_lossy().to_string();
161
162        // Match github.copilot-chat-X.Y.Z pattern
163        if !dir_name.starts_with("github.copilot-chat-") {
164            continue;
165        }
166
167        let version_str = &dir_name["github.copilot-chat-".len()..];
168        let version = match Version::parse(version_str) {
169            Ok(v) => v,
170            Err(_) => continue, // Skip directories with unparseable version
171        };
172
173        let ext_path = entry.path();
174        let pkg_path = ext_path.join("package.json");
175
176        let (required_vscode, required_node) = if pkg_path.exists() {
177            match std::fs::read_to_string(&pkg_path) {
178                Ok(content) => match serde_json::from_str::<PackageJson>(&content) {
179                    Ok(pkg) => {
180                        let vscode = pkg
181                            .engines
182                            .as_ref()
183                            .and_then(|e| e.vscode.as_ref())
184                            .cloned()
185                            .unwrap_or_else(|| "unknown".to_string());
186                        let node = pkg.engines.as_ref().and_then(|e| e.node.clone());
187                        (vscode, node)
188                    }
189                    Err(_) => ("unknown".to_string(), None),
190                },
191                Err(_) => ("unknown".to_string(), None),
192            }
193        } else {
194            ("unknown".to_string(), None)
195        };
196
197        installs.push(CopilotChatInstall {
198            version,
199            required_vscode_version: required_vscode,
200            required_node_version: required_node,
201            extension_path: ext_path,
202            is_active: false, // Set below
203        });
204    }
205
206    // Sort by version descending — newest first
207    installs.sort_by(|a, b| b.version.cmp(&a.version));
208
209    // Mark the newest as active
210    if let Some(first) = installs.first_mut() {
211        first.is_active = true;
212    }
213
214    Ok(installs)
215}
216
217// ============================================================================
218// Session Version Extraction
219// ============================================================================
220
221/// Extract the `extensionVersion` from JSONL session content.
222///
223/// Scans through JSONL lines for request entries containing
224/// `agent.extensionVersion` fields and collects all unique versions found.
225pub fn extract_session_versions(content: &str) -> Vec<String> {
226    let mut versions = Vec::new();
227    let mut seen = std::collections::HashSet::new();
228
229    for line in content.lines() {
230        if line.is_empty() {
231            continue;
232        }
233
234        // Fast path: skip lines that don't contain extensionVersion
235        if !line.contains("extensionVersion") {
236            continue;
237        }
238
239        if let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) {
240            // Walk common paths: v.requests[].agent.extensionVersion (kind:0)
241            // or v.extensionVersion / request.agent.extensionVersion (kind:1/2)
242            extract_extension_versions_from_value(&obj, &mut versions, &mut seen);
243        }
244    }
245
246    versions
247}
248
249/// Recursively search a JSON value for extensionVersion fields
250fn extract_extension_versions_from_value(
251    value: &serde_json::Value,
252    versions: &mut Vec<String>,
253    seen: &mut std::collections::HashSet<String>,
254) {
255    match value {
256        serde_json::Value::Object(map) => {
257            if let Some(v) = map.get("extensionVersion").and_then(|v| v.as_str()) {
258                if seen.insert(v.to_string()) {
259                    versions.push(v.to_string());
260                }
261            }
262            for (_, v) in map {
263                extract_extension_versions_from_value(v, versions, seen);
264            }
265        }
266        serde_json::Value::Array(arr) => {
267            for item in arr {
268                extract_extension_versions_from_value(item, versions, seen);
269            }
270        }
271        _ => {}
272    }
273}
274
275// ============================================================================
276// Version Compatibility Analysis
277// ============================================================================
278
279/// Build a full version report by detecting installed extensions and optionally
280/// scanning session files for embedded version info.
281///
282/// If `session_dir` is provided, scans JSONL files in that directory for
283/// `extensionVersion` fields.
284pub fn build_version_report(session_dir: Option<&Path>) -> Result<CopilotVersionReport> {
285    let installed = detect_installed_versions()?;
286
287    let active_version = installed
288        .iter()
289        .find(|i| i.is_active)
290        .map(|i| i.version.clone());
291
292    let mut session_versions: HashMap<String, usize> = HashMap::new();
293
294    if let Some(dir) = session_dir {
295        if dir.exists() {
296            for entry in std::fs::read_dir(dir)? {
297                let entry = entry?;
298                let path = entry.path();
299                if path.extension().is_some_and(|e| e == "jsonl") {
300                    if let Ok(content) = std::fs::read_to_string(&path) {
301                        for ver in extract_session_versions(&content) {
302                            *session_versions.entry(ver).or_default() += 1;
303                        }
304                    }
305                }
306            }
307        }
308    }
309
310    // Analyze for issues
311    let mut issues = Vec::new();
312
313    // Check: No Copilot Chat installed
314    if installed.is_empty() {
315        issues.push(VersionIssue {
316            severity: "error",
317            message: "No Copilot Chat extension found in ~/.vscode/extensions/".to_string(),
318        });
319    }
320
321    // Check: Multiple versions installed (stale installs)
322    if installed.len() > 1 {
323        let old_versions: Vec<String> = installed
324            .iter()
325            .skip(1)
326            .map(|i| i.version.to_string())
327            .collect();
328        issues.push(VersionIssue {
329            severity: "info",
330            message: format!(
331                "Multiple Copilot Chat versions installed: older {} may be stale",
332                old_versions.join(", ")
333            ),
334        });
335    }
336
337    // Check: Session versions that don't match installed version
338    if let Some(ref active) = active_version {
339        let active_str = active.to_string();
340        for (session_ver, count) in &session_versions {
341            if session_ver != &active_str {
342                // Determine severity based on major.minor difference
343                let severity = if let Ok(sv) = Version::parse(session_ver) {
344                    if sv.major != active.major || sv.minor != active.minor {
345                        // Different minor version — format may differ
346                        "warning"
347                    } else {
348                        "info"
349                    }
350                } else {
351                    "info"
352                };
353
354                issues.push(VersionIssue {
355                    severity,
356                    message: format!(
357                        "{} session(s) created with extension v{} (installed: v{})",
358                        count, session_ver, active_str
359                    ),
360                });
361            }
362        }
363    }
364
365    // Check: Sessions from pre-JSONL era (< 0.37.0) still present with JSONL extension installed
366    if let Some(ref active) = active_version {
367        if active >= &Version::new(0, 37, 0) {
368            for (session_ver, count) in &session_versions {
369                if let Ok(sv) = Version::parse(session_ver) {
370                    if sv < Version::new(0, 37, 0) {
371                        issues.push(VersionIssue {
372                            severity: "warning",
373                            message: format!(
374                                "{} session(s) from pre-JSONL era (v{}) — these use legacy JSON format. \
375                                Current extension v{} uses JSONL. Consider upgrading with: chasm recover upgrade",
376                                count, session_ver, active
377                            ),
378                        });
379                    }
380                }
381            }
382        }
383    }
384
385    Ok(CopilotVersionReport {
386        installed,
387        active_version,
388        session_versions,
389        issues,
390    })
391}
392
393/// Format the version report for human-readable console output
394pub fn format_version_report(report: &CopilotVersionReport) -> String {
395    use std::fmt::Write;
396
397    let mut out = String::new();
398
399    writeln!(out, "[*] Copilot Chat Extension Analysis").unwrap();
400    writeln!(out).unwrap();
401
402    if report.installed.is_empty() {
403        writeln!(
404            out,
405            "    [!] No Copilot Chat extension found in ~/.vscode/extensions/"
406        )
407        .unwrap();
408    } else {
409        writeln!(out, "    Installed versions:").unwrap();
410        for install in &report.installed {
411            let active_marker = if install.is_active {
412                " (active)"
413            } else {
414                " (stale)"
415            };
416            writeln!(
417                out,
418                "      v{}{} — requires VS Code {}",
419                install.version, active_marker, install.required_vscode_version,
420            )
421            .unwrap();
422            if let Some(ref node) = install.required_node_version {
423                writeln!(out, "        Node.js: {}", node).unwrap();
424            }
425            writeln!(out, "        Path: {}", install.extension_path.display()).unwrap();
426        }
427    }
428
429    if !report.session_versions.is_empty() {
430        writeln!(out).unwrap();
431        writeln!(out, "    Session extension versions:").unwrap();
432        let mut sorted: Vec<_> = report.session_versions.iter().collect();
433        sorted.sort_by(|a, b| b.1.cmp(a.1));
434        for (ver, count) in sorted {
435            writeln!(out, "      v{}: {} session(s)", ver, count).unwrap();
436        }
437    }
438
439    if !report.issues.is_empty() {
440        writeln!(out).unwrap();
441        writeln!(out, "    Compatibility notes:").unwrap();
442        for issue in &report.issues {
443            let prefix = match issue.severity {
444                "error" => "[!]",
445                "warning" => "[?]",
446                _ => "[i]",
447            };
448            writeln!(out, "      {} {}", prefix, issue.message).unwrap();
449        }
450    }
451
452    // Show expected session format for the active version
453    if let Some(ref active) = report.active_version {
454        writeln!(out).unwrap();
455        let expected_format = if active >= &Version::new(0, 37, 0) {
456            "JSONL (event-sourced, kind 0/1/2)"
457        } else {
458            "Legacy JSON (single object)"
459        };
460        writeln!(out, "    Expected session format: {}", expected_format).unwrap();
461
462        // Show known compatibility info
463        let compat = known_compatibility();
464        for entry in &compat {
465            if let (Ok(min), Ok(max)) = (
466                Version::parse(entry.extension_min),
467                Version::parse(entry.extension_max),
468            ) {
469                if active >= &min && active <= &max {
470                    writeln!(
471                        out,
472                        "    Min VS Code for this extension: {}",
473                        entry.vscode_min
474                    )
475                    .unwrap();
476                    writeln!(out, "    Notes: {}", entry.notes).unwrap();
477                    break;
478                }
479            }
480        }
481    }
482
483    out
484}
485
486/// Format the version report as JSON
487pub fn format_version_report_json(report: &CopilotVersionReport) -> Result<String> {
488    let installed_json: Vec<serde_json::Value> = report
489        .installed
490        .iter()
491        .map(|i| {
492            serde_json::json!({
493                "version": i.version.to_string(),
494                "required_vscode_version": i.required_vscode_version,
495                "required_node_version": i.required_node_version,
496                "extension_path": i.extension_path.display().to_string(),
497                "is_active": i.is_active,
498            })
499        })
500        .collect();
501
502    let issues_json: Vec<serde_json::Value> = report
503        .issues
504        .iter()
505        .map(|i| {
506            serde_json::json!({
507                "severity": i.severity,
508                "message": i.message,
509            })
510        })
511        .collect();
512
513    let result = serde_json::json!({
514        "installed": installed_json,
515        "active_version": report.active_version.as_ref().map(|v| v.to_string()),
516        "session_versions": report.session_versions,
517        "issues": issues_json,
518        "compatibility_table": known_compatibility().iter().map(|e| {
519            serde_json::json!({
520                "extension_range": format!("{} - {}", e.extension_min, e.extension_max),
521                "vscode_min": e.vscode_min,
522                "session_format": e.session_format,
523                "notes": e.notes,
524            })
525        }).collect::<Vec<_>>(),
526    });
527
528    Ok(serde_json::to_string_pretty(&result)?)
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_extract_session_versions_from_jsonl() {
537        let content =
538            r#"{"kind":0,"v":{"version":3,"requests":[{"agent":{"extensionVersion":"0.32.4"}}]}}"#;
539        let versions = extract_session_versions(content);
540        assert_eq!(versions, vec!["0.32.4"]);
541    }
542
543    #[test]
544    fn test_extract_multiple_versions() {
545        let content = r#"{"kind":0,"v":{"requests":[{"agent":{"extensionVersion":"0.32.3"}},{"agent":{"extensionVersion":"0.32.4"}}]}}
546{"kind":1,"v":{"agent":{"extensionVersion":"0.37.8"}}}"#;
547        let versions = extract_session_versions(content);
548        assert_eq!(versions.len(), 3);
549        assert!(versions.contains(&"0.32.3".to_string()));
550        assert!(versions.contains(&"0.32.4".to_string()));
551        assert!(versions.contains(&"0.37.8".to_string()));
552    }
553
554    #[test]
555    fn test_extract_no_versions() {
556        let content = r#"{"kind":0,"v":{"version":3,"requests":[{"message":{"text":"hello"}}]}}"#;
557        let versions = extract_session_versions(content);
558        assert!(versions.is_empty());
559    }
560
561    #[test]
562    fn test_known_compatibility_table() {
563        let compat = known_compatibility();
564        assert!(!compat.is_empty());
565        // The last entry should be JSONL format
566        let last = compat.last().unwrap();
567        assert_eq!(last.session_format, "jsonl");
568    }
569}