diff --git a/package-lock.json b/package-lock.json index c6b8efb45..39cb95495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/claude-code": "^1.0.120", "@anthropic-ai/sdk": "^0.68.0", - "@github/copilot": "^0.0.343", + "@github/copilot": "^0.0.354", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -721,6 +721,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3057,13 +3058,10 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.343", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.343.tgz", - "integrity": "sha512-8Gc8hrEnpH6XtB0d0HAfef5aKSYyfAAXwD4BDHpSD4+LURZ+2j6ZAyxtZOdmnckXpL6rA79zLzrH/4Ln1xUGFw==", - "dependencies": { - "node-pty": "npm:@devm33/node-pty@^1.0.8", - "sharp": "^0.34.3" - }, + "version": "0.0.354", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.354.tgz", + "integrity": "sha512-vk/80NI1jlgSyCdNWBdVPMC0ZyI8PIGAswQga1OLu+BIGQAeI9oks1tp23OeXika2cFMJSVv3GJfTMRx/gqhHA==", + "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "index.js" }, @@ -3221,15 +3219,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -3338,38 +3327,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", @@ -3386,38 +3343,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-linux-arm": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", @@ -3462,50 +3387,6 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", @@ -3528,107 +3409,6 @@ "@img/sharp-libvips-linux-x64": "1.0.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.5.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-win32-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", @@ -9162,6 +8942,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -14098,23 +13879,6 @@ } } }, - "node_modules/node-pty": { - "name": "@devm33/node-pty", - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@devm33/node-pty/-/node-pty-1.0.9.tgz", - "integrity": "sha512-5yzbTTywkaFk1iRwte2aWEpyDfcpDjCofVD1BiOUQI+fsCvp/+RdJnB4jgnULrdlWOEWuBf+bg4/NZKVApPhoQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, - "node_modules/node-pty/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, "node_modules/node-sarif-builder": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", @@ -16383,257 +16147,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" - } - }, - "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" - } - }, - "node_modules/sharp/node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 531c31752..192621552 100644 --- a/package.json +++ b/package.json @@ -4569,7 +4569,7 @@ "dependencies": { "@anthropic-ai/claude-code": "^1.0.120", "@anthropic-ai/sdk": "^0.68.0", - "@github/copilot": "^0.0.343", + "@github/copilot": "^0.0.354", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/src/extension/agents/copilotcli/node/copilotCli.ts b/src/extension/agents/copilotcli/node/copilotCli.ts index 7fce92127..3b1af7158 100644 --- a/src/extension/agents/copilotcli/node/copilotCli.ts +++ b/src/extension/agents/copilotcli/node/copilotCli.ts @@ -3,66 +3,50 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ModelProvider } from '@github/copilot/sdk'; +import type { SessionOptions } from '@github/copilot/sdk'; import type { ChatSessionProviderOptionItem } from 'vscode'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; import { IEnvService } from '../../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../../platform/log/common/logService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { createServiceIdentifier } from '../../../../util/common/services'; import { Lazy } from '../../../../util/vs/base/common/lazy'; +import { Disposable, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { getCopilotLogger } from './logger'; import { ensureNodePtyShim } from './nodePtyShim'; const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; -const DEFAULT_CLI_MODEL: ModelProvider = { - type: 'anthropic', - model: 'claude-sonnet-4.5' -}; - -/** - * Convert a model ID to a ModelProvider object for the Copilot CLI SDK - */ -export function getModelProvider(modelId: string): ModelProvider { - // Keep logic minimal; advanced mapping handled by resolveModelProvider in modelMapping.ts. - if (modelId.startsWith('claude-')) { - return { - type: 'anthropic', - model: modelId - }; - } else if (modelId.startsWith('gpt-')) { - return { - type: 'openai', - model: modelId - }; - } - return DEFAULT_CLI_MODEL; -} +const DEFAULT_CLI_MODEL = 'claude-sonnet-4'; export interface ICopilotCLIModels { _serviceBrand: undefined; - toModelProvider(modelId: string): ModelProvider; + toModelProvider(modelId: string): string; getDefaultModel(): Promise; setDefaultModel(model: ChatSessionProviderOptionItem): Promise; getAvailableModels(): Promise; } +export const ICopilotCLISDK = createServiceIdentifier('ICopilotCLISDK'); + export const ICopilotCLIModels = createServiceIdentifier('ICopilotCLIModels'); export class CopilotCLIModels implements ICopilotCLIModels { declare _serviceBrand: undefined; private readonly _availableModels: Lazy>; constructor( + @ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK, @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, ) { this._availableModels = new Lazy>(() => this._getAvailableModels()); } public toModelProvider(modelId: string) { - // TODO: replace with SDK-backed lookup once dynamic model list available. - return getModelProvider(modelId); + return modelId; } public async getDefaultModel() { // We control this const models = await this.getAvailableModels(); - const defaultModel = models.find(m => m.id.toLowerCase().includes(DEFAULT_CLI_MODEL.model.toLowerCase())) ?? models[0]; + const defaultModel = models.find(m => m.id.toLowerCase() === DEFAULT_CLI_MODEL.toLowerCase()) ?? models[0]; const preferredModelId = this.extensionContext.globalState.get(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id); return models.find(m => m.id === preferredModelId) ?? defaultModel; @@ -78,22 +62,12 @@ export class CopilotCLIModels implements ICopilotCLIModels { } private async _getAvailableModels(): Promise { - return [{ - id: 'claude-sonnet-4.5', - name: 'Claude Sonnet 4.5' - }, - { - id: 'claude-sonnet-4', - name: 'Claude Sonnet 4' - }, - { - id: 'claude-haiku-4.5', - name: 'Claude Haiku 4.5' - }, - { - id: 'gpt-5', - name: 'GPT-5' - }]; + const { getAvailableModels } = await this.copilotCLISDK.getPackage(); + const models = await getAvailableModels(); + return models.map(model => ({ + id: model.model, + name: model.label + } satisfies ChatSessionProviderOptionItem)); } } @@ -106,8 +80,6 @@ export interface ICopilotCLISDK { getPackage(): Promise; } -export const ICopilotCLISDK = createServiceIdentifier('ICopilotCLISDK'); - export class CopilotCLISDK implements ICopilotCLISDK { declare _serviceBrand: undefined; @@ -120,7 +92,7 @@ export class CopilotCLISDK implements ICopilotCLISDK { public async getPackage(): Promise { try { // Ensure the node-pty shim exists before importing the SDK (required for CLI sessions) - await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot); + await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService); return await import('@github/copilot/sdk'); } catch (error) { this.logService.error(`[CopilotCLISDK] Failed to load @github/copilot/sdk: ${error}`); @@ -128,3 +100,86 @@ export class CopilotCLISDK implements ICopilotCLISDK { } } } + +export interface ICopilotCLISessionOptionsService { + readonly _serviceBrand: undefined; + createOptions( + options: SessionOptions, + permissionHandler: CopilotCLIPermissionsHandler + ): Promise; +} +export const ICopilotCLISessionOptionsService = createServiceIdentifier('ICopilotCLISessionOptionsService'); + +export class CopilotCLISessionOptionsService implements ICopilotCLISessionOptionsService { + declare _serviceBrand: undefined; + constructor( + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @ILogService private readonly logService: ILogService, + ) { } + + public async createOptions(options: SessionOptions, permissionHandler: CopilotCLIPermissionsHandler) { + const copilotToken = await this._authenticationService.getAnyGitHubSession(); + const workingDirectory = options.workingDirectory ?? await this.getWorkspaceFolderPath(); + const allOptions: SessionOptions = { + env: { + ...process.env, + COPILOTCLI_DISABLE_NONESSENTIAL_TRAFFIC: '1' + }, + logger: getCopilotLogger(this.logService), + requestPermission: async (permissionRequest) => { + return await permissionHandler.getPermissions(permissionRequest); + }, + authInfo: { + type: 'token', + token: copilotToken?.accessToken ?? '', + host: 'https://github.com' + }, + ...options, + }; + + if (workingDirectory) { + allOptions.workingDirectory = workingDirectory; + } + return allOptions; + } + private async getWorkspaceFolderPath() { + if (this.workspaceService.getWorkspaceFolders().length === 0) { + return undefined; + } + if (this.workspaceService.getWorkspaceFolders().length === 1) { + return this.workspaceService.getWorkspaceFolders()[0].fsPath; + } + const folder = await this.workspaceService.showWorkspaceFolderPicker(); + return folder?.uri?.fsPath; + } +} + + +/** + * Perhaps temporary interface to handle permission requests from the Copilot CLI SDK + * Perhaps because the SDK needs a better way to handle this in long term per session. + */ +export interface ICopilotCLIPermissions { + onDidRequestPermissions(handler: SessionOptions['requestPermission']): IDisposable; +} + +export class CopilotCLIPermissionsHandler extends Disposable implements ICopilotCLIPermissions { + private _handler: SessionOptions['requestPermission'] | undefined; + + public onDidRequestPermissions(handler: SessionOptions['requestPermission']): IDisposable { + this._handler = handler; + return this._register(toDisposable(() => { + this._handler = undefined; + })); + } + + public async getPermissions(permission: Parameters>[0]): Promise>> { + if (!this._handler) { + return { + kind: "denied-interactively-by-user" + }; + } + return await this._handler(permission); + } +} \ No newline at end of file diff --git a/src/extension/agents/copilotcli/node/copilotcliSession.ts b/src/extension/agents/copilotcli/node/copilotcliSession.ts index f55c99b5e..fd7fc07e5 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSession.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -3,23 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AgentOptions, Attachment, ModelProvider, PostToolUseHookInput, PreToolUseHookInput, Session, SessionEvent } from '@github/copilot/sdk'; +import type { Attachment, Session } from '@github/copilot/sdk'; import type * as vscode from 'vscode'; -import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; import { ILogService } from '../../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { ResourceMap } from '../../../../util/vs/base/common/map'; import { ChatRequestTurn2, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatSessionStatus, EventEmitter, LanguageModelTextPart, Uri } from '../../../../vscodeTypes'; import { IToolsService } from '../../../tools/common/toolsService'; import { ExternalEditTracker } from '../../common/externalEditTracker'; -import { getAffectedUrisForEditTool } from '../common/copilotcliTools'; -import { ICopilotCLISDK } from './copilotCli'; -import { buildChatHistoryFromEvents, isCopilotCliEditToolCall, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; -import { getCopilotLogger } from './logger'; +import { CopilotCLIPermissionsHandler, ICopilotCLISessionOptionsService } from './copilotCli'; +import { buildChatHistoryFromEvents, getAffectedUrisForEditTool, isCopilotCliEditToolCall, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; import { getConfirmationToolParams, PermissionRequest } from './permissionHelpers'; -export interface ICopilotCLISession { +export interface ICopilotCLISession extends IDisposable { readonly sessionId: string; readonly status: vscode.ChatSessionStatus | undefined; readonly onDidChangeStatus: vscode.Event; @@ -27,10 +25,9 @@ export interface ICopilotCLISession { handleRequest( prompt: string, attachments: Attachment[], - toolInvocationToken: vscode.ChatParticipantToolToken, + modelId: string | undefined, stream: vscode.ChatResponseStream, - modelId: ModelProvider | undefined, - workingDirectory: string | undefined, + toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken ): Promise; @@ -39,10 +36,9 @@ export interface ICopilotCLISession { getSelectedModelId(): Promise; getChatHistory(): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]>; } + export class CopilotCLISession extends DisposableStore implements ICopilotCLISession { - private _abortController = new AbortController(); private _pendingToolInvocations = new Map(); - private _editTracker = new ExternalEditTracker(); public readonly sessionId: string; private _status?: vscode.ChatSessionStatus; public get status(): vscode.ChatSessionStatus | undefined { @@ -54,110 +50,142 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes constructor( private readonly _sdkSession: Session, + private readonly _permissionHandler: CopilotCLIPermissionsHandler, @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IToolsService private readonly toolsService: IToolsService, - @ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK + @ICopilotCLISessionOptionsService private readonly cliSessionOptions: ICopilotCLISessionOptionsService, ) { super(); this.sessionId = _sdkSession.sessionId; } - public override dispose(): void { - this._abortController.abort(); - super.dispose(); - } - - async *query(prompt: string, attachments: Attachment[], options: AgentOptions): AsyncGenerator { - // Dynamically import the SDK - const { Agent } = await this.copilotCLISDK.getPackage(); - const agent = new Agent(options); - yield* agent.query(prompt, attachments); - } - public async handleRequest( prompt: string, attachments: Attachment[], - toolInvocationToken: vscode.ChatParticipantToolToken, + modelId: string | undefined, stream: vscode.ChatResponseStream, - modelId: ModelProvider | undefined, - workingDirectory: string | undefined, + toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken ): Promise { if (this.isDisposed) { throw new Error('Session disposed'); } - this._status = ChatSessionStatus.InProgress; this._statusChange.fire(this._status); this.logService.trace(`[CopilotCLISession] Invoking session ${this.sessionId}`); - const copilotToken = await this._authenticationService.getCopilotToken(); - // TODO@rebornix handle workspace properly - const effectiveWorkingDirectory = workingDirectory ?? this.workspaceService.getWorkspaceFolders().at(0)?.fsPath; + const disposables = this.add(new DisposableStore()); + const abortController = new AbortController(); + disposables.add(token.onCancellationRequested(() => { + abortController.abort(); + })); + disposables.add(toDisposable(() => abortController.abort())); + + const toolNames = new Map(); + const editToolIds = new Set(); + const editTracker = new ExternalEditTracker(); + const editFilesAndToolCallIds = new ResourceMap(); + disposables.add(this._permissionHandler.onDidRequestPermissions(async (permissionRequest) => { + return await this.requestPermission(permissionRequest, stream, editTracker, + (file: Uri) => { + const ids = editFilesAndToolCallIds.get(file); + return ids?.shift(); + }, + toolInvocationToken + ); + })); + + try { + const [currentModel, + sessionOptions + ] = await Promise.all([ + modelId ? this._sdkSession.getSelectedModel() : undefined, + this.cliSessionOptions.createOptions({}, this._permissionHandler) + ]); + if (sessionOptions.authInfo) { + this._sdkSession.setAuthInfo(sessionOptions.authInfo); + } + if (modelId && modelId !== currentModel) { + await this._sdkSession.setSelectedModel(modelId); + } - const options: AgentOptions = { - modelProvider: modelId ?? { - type: 'anthropic', - model: 'claude-sonnet-4.5', - }, - abortController: this._abortController, - workingDirectory: effectiveWorkingDirectory, - copilotToken: copilotToken.token, - env: { - ...process.env, - COPILOTCLI_DISABLE_NONESSENTIAL_TRAFFIC: '1' - }, - requestPermission: async (permissionRequest) => { - return await this.requestPermission(permissionRequest, toolInvocationToken); - }, - logger: getCopilotLogger(this.logService), - session: this._sdkSession, - hooks: { - preToolUse: [ - async (input: PreToolUseHookInput) => { - const editKey = getEditOperationKey(input.toolName, input.toolArgs); - await this._onWillEditTool(input, editKey, stream); + disposables.add(toDisposable(this._sdkSession.on('*', (event) => this.logService.trace(`[CopilotCLISession]CopilotCLI Event: ${JSON.stringify(event, null, 2)}`)))); + disposables.add(toDisposable(this._sdkSession.on('assistant.message', (event) => { + if (typeof event.data.content === 'string' && event.data.content.length) { + stream.markdown(event.data.content); + } + }))); + disposables.add(toDisposable(this._sdkSession.on('tool.execution_start', (event) => { + toolNames.set(event.data.toolCallId, event.data.toolName); + if (isCopilotCliEditToolCall(event.data.toolName, event.data.arguments)) { + editToolIds.add(event.data.toolCallId); + // Track edits for edit tools. + const editUris = getAffectedUrisForEditTool(event.data.toolName, event.data.arguments || {}); + if (editUris.length) { + editUris.forEach(uri => { + const ids = editFilesAndToolCallIds.get(uri) || []; + ids.push(event.data.toolCallId); + editFilesAndToolCallIds.set(uri, ids); + this.logService.trace(`[CopilotCLISession] Tracking for toolCallId ${event.data.toolCallId} of file ${uri.fsPath}`); + }); } - ], - postToolUse: [ - async (input: PostToolUseHookInput) => { - const editKey = getEditOperationKey(input.toolName, input.toolArgs); - void this._onDidEditTool(editKey); + } else { + const responsePart = processToolExecutionStart(event, this._pendingToolInvocations); + if (responsePart instanceof ChatResponseThinkingProgressPart) { + stream.push(responsePart); } - ] - } - }; + } + this.logService.trace(`[CopilotCLISession] Start Tool ${event.data.toolName || ''}`); + }))); + disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => { + // Mark the end of the edit if this was an edit tool. + editTracker.completeEdit(event.data.toolCallId); + if (editToolIds.has(event.data.toolCallId)) { + this.logService.trace(`[CopilotCLISession] Completed edit tracking for toolCallId ${event.data.toolCallId}`); + return; + } - try { - for await (const event of this.query(prompt, attachments, options)) { - if (token.isCancellationRequested) { - break; + const responsePart = processToolExecutionComplete(event, this._pendingToolInvocations); + if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) { + stream.push(responsePart); } - this._processEvent(event, stream, toolInvocationToken); - } + const toolName = toolNames.get(event.data.toolCallId) || ''; + const success = `success: ${event.data.success}`; + const error = event.data.error ? `error: ${event.data.error.code},${event.data.error.message}` : ''; + const result = event.data.result ? `result: ${event.data.result?.content}` : ''; + const parts = [success, error, result].filter(part => part.length > 0).join(', '); + this.logService.trace(`[CopilotCLISession]Complete Tool ${toolName}, ${parts}`); + }))); + disposables.add(toDisposable(this._sdkSession.on('session.error', (event) => { + this.logService.error(`[CopilotCLISession]CopilotCLI error: (${event.data.errorType}), ${event.data.message}`); + stream.markdown(`\n\n❌ Error: (${event.data.errorType}) ${event.data.message}`); + }))); + + await this._sdkSession.send({ prompt, attachments, abortController }); + this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`); + this._status = ChatSessionStatus.Completed; this._statusChange.fire(this._status); } catch (error) { this._status = ChatSessionStatus.Failed; this._statusChange.fire(this._status); - this.logService.error(`CopilotCLI session error: ${error}`); + this.logService.error(`[CopilotCLISession] Invoking session (error) ${this.sessionId}`, error); stream.markdown(`\n\n❌ Error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + disposables.dispose(); } } addUserMessage(content: string) { - this._sdkSession.addEvent({ type: 'user.message', data: { content } }); + this._sdkSession.emit('user.message', { content }); } addUserAssistantMessage(content: string) { - this._sdkSession.addEvent({ - type: 'assistant.message', data: { - messageId: `msg_${Date.now()}`, - content - } + this._sdkSession.emit('assistant.message', { + messageId: `msg_${Date.now()}`, + content }); } @@ -170,67 +198,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return buildChatHistoryFromEvents(events); } - private _toolNames = new Map(); - private _processEvent( - event: SessionEvent, - stream: vscode.ChatResponseStream, - toolInvocationToken: vscode.ChatParticipantToolToken - ): void { - this.logService.trace(`CopilotCLI Event: ${JSON.stringify(event, null, 2)}`); - - switch (event.type) { - case 'assistant.turn_start': - case 'assistant.turn_end': { - this._toolNames.clear(); - break; - } - - case 'assistant.message': { - if (event.data.content.length) { - stream.markdown(event.data.content); - } - break; - } - - case 'tool.execution_start': { - this._toolNames.set(event.data.toolCallId, event.data.toolName); - const responsePart = processToolExecutionStart(event, this._pendingToolInvocations); - if (isCopilotCliEditToolCall(event.data.toolName, event.data.arguments)) { - this._pendingToolInvocations.delete(event.data.toolCallId); - } - if (responsePart instanceof ChatResponseThinkingProgressPart) { - stream.push(responsePart); - stream.push(new ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true })); - } - this.logService.trace(`Start Tool ${event.data.toolName || ''}`); - break; - } - - case 'tool.execution_complete': { - const responsePart = processToolExecutionComplete(event, this._pendingToolInvocations); - if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) { - stream.push(responsePart); - } - - const toolName = this._toolNames.get(event.data.toolCallId) || ''; - const success = `success: ${event.data.success}`; - const error = event.data.error ? `error: ${event.data.error.code},${event.data.error.message}` : ''; - const result = event.data.result ? `result: ${event.data.result?.content}` : ''; - const parts = [success, error, result].filter(part => part.length > 0).join(', '); - this.logService.trace(`Complete Tool ${toolName}, ${parts}`); - break; - } - - case 'session.error': { - this.logService.error(`CopilotCLI error: (${event.data.errorType}), ${event.data.message}`); - stream.markdown(`\n\n❌ Error: ${event.data.message}`); - break; - } - } - } - private async requestPermission( permissionRequest: PermissionRequest, + stream: vscode.ChatResponseStream, + editTracker: ExternalEditTracker, + getEditKeyForFile: (file: Uri) => string | undefined, toolInvocationToken: vscode.ChatParticipantToolToken ): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> { if (permissionRequest.kind === 'read') { @@ -251,6 +223,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const firstResultPart = result.content.at(0); if (firstResultPart instanceof LanguageModelTextPart && firstResultPart.value === 'yes') { + // If we're editing a file, start tracking the edit & wait for core to acknowledge it. + const editFile = permissionRequest.kind === 'write' ? Uri.file(permissionRequest.fileName) : undefined; + const editKey = editFile ? getEditKeyForFile(editFile) : undefined; + if (editFile && editKey) { + this.logService.trace(`[CopilotCLISession] Starting to track edit for toolCallId ${editKey} & file ${editFile.fsPath}`); + await editTracker.trackEdit(editKey, [editFile], stream); + } return { kind: 'approved' }; } } catch (error) { @@ -259,19 +238,4 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return { kind: 'denied-interactively-by-user' }; } - - private async _onWillEditTool(input: PreToolUseHookInput, editKey: string, stream: vscode.ChatResponseStream): Promise { - const uris = getAffectedUrisForEditTool(input.toolName, input.toolArgs); - return this._editTracker.trackEdit(editKey, uris, stream); - } - - private async _onDidEditTool(editKey: string): Promise { - return this._editTracker.completeEdit(editKey); - } -} - - -function getEditOperationKey(toolName: string, toolArgs: unknown): string { - // todo@connor4312: get copilot CLI to surface the tool call ID instead? - return `${toolName}:${JSON.stringify(toolArgs)}`; } diff --git a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts index 3548fc1f9..d21e001f9 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { internal, ModelProvider, Session, SessionManager } from '@github/copilot/sdk'; +import type { ModelMetadata, Session, internal } from '@github/copilot/sdk'; import { ChatCompletionMessageParam } from 'openai/resources/chat/completions'; import type { CancellationToken, ChatRequest } from 'vscode'; import { INativeEnvService } from '../../../../platform/env/common/envService'; @@ -12,14 +12,14 @@ import { RelativePattern } from '../../../../platform/filesystem/common/fileType import { ILogService } from '../../../../platform/log/common/logService'; import { createServiceIdentifier } from '../../../../util/common/services'; import { coalesce } from '../../../../util/vs/base/common/arrays'; -import { raceCancellationError } from '../../../../util/vs/base/common/async'; +import { disposableTimeout, raceCancellationError } from '../../../../util/vs/base/common/async'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Lazy } from '../../../../util/vs/base/common/lazy'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; import { joinPath } from '../../../../util/vs/base/common/resources'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatSessionStatus } from '../../../../vscodeTypes'; -import { ICopilotCLISDK } from './copilotCli'; +import { CopilotCLIPermissionsHandler, ICopilotCLISDK, ICopilotCLISessionOptionsService } from './copilotCli'; import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession'; import { stripReminders } from './copilotcliToolInvocationFormatter'; import { getCopilotLogger } from './logger'; @@ -46,12 +46,13 @@ export interface ICopilotCLISessionService { deleteSession(sessionId: string): Promise; // Session wrapper tracking - getSession(sessionId: string, model: ModelProvider | undefined, readonly: boolean, token: CancellationToken): Promise; - createSession(prompt: string, model: ModelProvider | undefined, token: CancellationToken): Promise; + getSession(sessionId: string, model: string | undefined, workingDirectory: string | undefined, readonly: boolean, token: CancellationToken): Promise; + createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, token: CancellationToken): Promise; } export const ICopilotCLISessionService = createServiceIdentifier('ICopilotCLISessionService'); +const SESSION_SHUTDOWN_TIMEOUT_MS = 30 * 1000; export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService { declare _serviceBrand: undefined; @@ -64,10 +65,13 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS private readonly _onDidChangeSessions = new Emitter(); public readonly onDidChangeSessions = this._onDidChangeSessions.event; + private readonly sessionTerminators = new DisposableMap(); + constructor( @ILogService private readonly logService: ILogService, @ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ICopilotCLISessionOptionsService private readonly optionsService: ICopilotCLISessionOptionsService, @INativeEnvService private readonly nativeEnv: INativeEnvService, @IFileSystemService private readonly fileSystem: IFileSystemService, ) { @@ -125,7 +129,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this.logService.warn(`Copilot CLI session not found, ${metadata.sessionId}`); return; } - const chatMessages = await raceCancellationError(session.getChatMessages(), token); + const chatMessages = await raceCancellationError(session.getChatContextMessages(), token); const noUserMessages = !chatMessages.find(message => message.role === 'user'); const label = await this._generateSessionLabel(session.sessionId, chatMessages, undefined); @@ -173,22 +177,27 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } private async getReadonlySdkSession(sessionId: string, token: CancellationToken): Promise<{ session: Session; dispose: () => Promise } | undefined> { - // let session = this._sessionWrappers.get(sessionId)?.session; const sessionManager = await raceCancellationError(this.getSessionManager(), token); - const session = await sessionManager.getSession(sessionId); + const session = await sessionManager.getSession({ sessionId }, false); if (!session) { return undefined; } return { session, dispose: () => Promise.resolve() }; } - public async createSession(prompt: string, model: ModelProvider | undefined, token: CancellationToken): Promise { + public async createSession(prompt: string, model: string | undefined, workingDirectory: string | undefined, token: CancellationToken): Promise { const sessionDisposables = this._register(new DisposableStore()); + const permissionHandler = sessionDisposables.add(new CopilotCLIPermissionsHandler()); try { - const sessionManager = await raceCancellationError(this.getSessionManager(), token); + const options = await raceCancellationError(this.optionsService.createOptions( + { + model: model as unknown as ModelMetadata['model'], + workingDirectory - const sdkSession = await sessionManager.createSession(); - const chatMessages = await sdkSession.getChatMessages(); + }, permissionHandler), token); + const sessionManager = await raceCancellationError(this.getSessionManager(), token); + const sdkSession = await sessionManager.createSession(options); + const chatMessages = await sdkSession.getChatContextMessages(); const noUserMessages = !chatMessages.find(message => message.role === 'user'); const label = this._generateSessionLabel(sdkSession.sessionId, chatMessages as any, prompt); const newSession: ICopilotCLISessionItem = { @@ -202,7 +211,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS sessionDisposables.add(toDisposable(() => this._newActiveSessions.delete(sdkSession.sessionId))); - const session = await this.createCopilotSession(sdkSession, sessionManager, sessionDisposables); + const session = await this.createCopilotSession(sdkSession, sessionManager, permissionHandler, sessionDisposables); sessionDisposables.add(session.onDidChangeStatus(() => { // This will get swapped out as soon as the session has completed. @@ -217,7 +226,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - public async getSession(sessionId: string, model: ModelProvider | undefined, readonly: boolean, token: CancellationToken): Promise { + public async getSession(sessionId: string, model: string | undefined, workingDirectory: string | undefined, readonly: boolean, token: CancellationToken): Promise { const session = this._sessionWrappers.get(sessionId); if (session) { @@ -228,35 +237,58 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const sessionDisposables = this._register(new DisposableStore()); try { const sessionManager = await raceCancellationError(this.getSessionManager(), token); + const permissionHandler = sessionDisposables.add(new CopilotCLIPermissionsHandler()); + const options = await raceCancellationError(this.optionsService.createOptions({ + model: model as unknown as ModelMetadata['model'], + workingDirectory + }, permissionHandler), token); - const sdkSession = await sessionManager.getSession(sessionId); + const sdkSession = await sessionManager.getSession({ ...options, sessionId }, !readonly); if (!sdkSession) { this.logService.error(`[CopilotCLIAgentManager] CopilotCLI failed to get session ${sessionId}.`); sessionDisposables.dispose(); return undefined; } - return this.createCopilotSession(sdkSession, sessionManager, sessionDisposables); + return this.createCopilotSession(sdkSession, sessionManager, permissionHandler, sessionDisposables); } catch (error) { sessionDisposables.dispose(); throw error; } } - private async createCopilotSession(sdkSession: Session, sessionManager: SessionManager, disposables: IDisposable,): Promise { + private async createCopilotSession(sdkSession: Session, sessionManager: internal.CLISessionManager, permissionHandler: CopilotCLIPermissionsHandler, disposables: IDisposable,): Promise { const sessionDisposables = this._register(new DisposableStore()); sessionDisposables.add(disposables); try { sessionDisposables.add(toDisposable(() => { this._sessionWrappers.deleteAndLeak(sdkSession.sessionId); - // sdkSession.abort(); - // sessionManager.closeSession(sdkSession); + sdkSession.abort(); + void sessionManager.closeSession(sdkSession.sessionId); })); - const session = this.instantiationService.createInstance(CopilotCLISession, sdkSession); + const session = this.instantiationService.createInstance(CopilotCLISession, sdkSession, permissionHandler); session.add(sessionDisposables); session.add(session.onDidChangeStatus(() => this._onDidChangeSessions.fire())); + // We have no way of tracking Chat Editor life cycle. + // Hence when we're done with a request, lets dispose the chat session (say 60s after). + // If in the mean time we get another request, we'll clear the timeout. + // When vscode shuts the sessions will be disposed anyway. + // This code is to avoid leaving these sessions alive forever in memory. + session.add(session.onDidChangeStatus(e => { + if (session.status === undefined || session.status === ChatSessionStatus.Completed || session.status === ChatSessionStatus.Failed) { + // We're done with this session, start timeout to dispose it + this.sessionTerminators.set(session.sessionId, disposableTimeout(() => { + session.dispose(); + this.sessionTerminators.deleteAndDispose(session.sessionId); + }, SESSION_SHUTDOWN_TIMEOUT_MS)); + } else { + // Session is busy. + this.sessionTerminators.deleteAndDispose(session.sessionId); + } + })); + this._sessionWrappers.set(sdkSession.sessionId, session); return session; } catch (error) { @@ -277,10 +309,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS // Delete from session manager first const sessionManager = await this.getSessionManager(); - const sdkSession = await sessionManager.getSession(sessionId); - if (sdkSession) { - await sessionManager.deleteSession(sdkSession); - } + await sessionManager.deleteSession(sessionId); } catch (error) { this.logService.error(`Failed to delete session ${sessionId}: ${error}`); diff --git a/src/extension/agents/copilotcli/node/nodePtyShim.ts b/src/extension/agents/copilotcli/node/nodePtyShim.ts index 317f935bb..03177d55d 100644 --- a/src/extension/agents/copilotcli/node/nodePtyShim.ts +++ b/src/extension/agents/copilotcli/node/nodePtyShim.ts @@ -5,78 +5,136 @@ import { promises as fs } from 'fs'; import * as path from 'path'; +import { ILogService } from '../../../../platform/log/common/logService'; let shimCreated: Promise | undefined = undefined; +const RETRIABLE_COPY_ERROR_CODES = new Set(['EPERM', 'EBUSY']); +const MAX_COPY_ATTEMPTS = 6; +const RETRY_DELAY_BASE_MS = 50; +const RETRY_DELAY_CAP_MS = 500; +const MATERIALIZATION_TIMEOUT_MS = 4000; +const MATERIALIZATION_POLL_INTERVAL_MS = 100; + /** - * Creates a node-pty ESM shim that @github/copilot can import. + * Copies the node-pty files from VS Code's installation into a @github/copilot location * * MUST be called before any `import('@github/copilot/sdk')` or `import('@github/copilot')`. * - * @github/copilot has hardcoded ESM imports: import{spawn}from"node-pty" - * We create a shim module that uses createRequire to load VS Code's bundled node-pty. + * @github/copilot bundles the node-pty code and its no longer possible to shim the package. * * @param extensionPath The extension's path (where to create the shim) * @param vscodeAppRoot VS Code's installation path (where node-pty is located) */ -export async function ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string): Promise { +export async function ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string, logService: ILogService): Promise { if (shimCreated) { return shimCreated; } - shimCreated = _ensureNodePtyShim(extensionPath, vscodeAppRoot); + const creation = _ensureNodePtyShim(extensionPath, vscodeAppRoot, logService); + shimCreated = creation.catch(error => { + shimCreated = undefined; + throw error; + }); return shimCreated; } -async function _ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string): Promise { - const nodePtyDir = path.join(extensionPath, 'node_modules', 'node-pty'); - const vscodeNodePtyPath = path.join(vscodeAppRoot, 'node_modules', 'node-pty', 'lib', 'index.js'); +async function _ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string, logService: ILogService): Promise { + const nodePtyDir = path.join(extensionPath, 'node_modules', '@github', 'copilot', 'prebuilds', process.platform + "-" + process.arch); + const vscodeNodePtyPath = path.join(vscodeAppRoot, 'node_modules', 'node-pty', 'build', 'Release'); + + logService.info(`Creating node-pty shim: source=${vscodeNodePtyPath}, dest=${nodePtyDir}`); try { - // Remove any existing node-pty (might be from other packages' dependencies) + await fs.mkdir(nodePtyDir, { recursive: true }); + const entries = await fs.readdir(vscodeNodePtyPath); + const uniqueEntries = [...new Set(entries)]; + logService.info(`Found ${uniqueEntries.length} entries to copy${uniqueEntries.length !== entries.length ? ` (${entries.length - uniqueEntries.length} duplicates ignored)` : ''}: ${uniqueEntries.join(', ')}`); + + await copyNodePtyWithRetries(vscodeNodePtyPath, nodePtyDir, uniqueEntries, logService); + } catch (error: any) { + logService.error(`Failed to create node-pty shim (vscode dir: ${vscodeNodePtyPath}, extension dir: ${nodePtyDir})`, error); + throw error; + } +} + +async function copyNodePtyWithRetries(sourceDir: string, destDir: string, entries: string[], logService: ILogService): Promise { + const primaryBinary = entries.find(entry => entry.endsWith('.node')); + for (let attempt = 1; attempt <= MAX_COPY_ATTEMPTS; attempt++) { try { - await fs.rm(nodePtyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist + await fs.cp(sourceDir, destDir, { + recursive: true, + dereference: true, + force: true, + filter: async (srcPath) => shouldCopyEntry(srcPath, logService) + }); + logService.trace(`Copied node-pty prebuilds to ${destDir} (attempt ${attempt})`); + return; + } catch (error: any) { + if (await waitForMaterializedShim(destDir, primaryBinary, logService)) { + logService.trace(`Detected node-pty shim materialized at ${destDir} by another extension host`); + return; + } + + if (!RETRIABLE_COPY_ERROR_CODES.has(error?.code) || attempt === MAX_COPY_ATTEMPTS) { + throw error; + } + + const delayMs = Math.min(RETRY_DELAY_BASE_MS * Math.pow(2, attempt - 1), RETRY_DELAY_CAP_MS); + logService.warn(`Retryable error (${error.code}) copying node-pty shim. Retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_COPY_ATTEMPTS})`); + await new Promise(resolve => setTimeout(resolve, delayMs)); } + } +} - await fs.mkdir(nodePtyDir, { recursive: true }); +async function shouldCopyEntry(srcPath: string, logService: ILogService): Promise { + try { + const stat = await fs.stat(srcPath); + if (stat.isDirectory()) { + return true; + } - // Create package.json with ESM type - const packageJson = { - name: 'node-pty', - version: '1.0.0', - type: 'module', - exports: './index.mjs' - }; - await fs.writeFile( - path.join(nodePtyDir, 'package.json'), - JSON.stringify(packageJson, null, 2) - ); - - // Create index.mjs that dynamically loads VS Code's node-pty at runtime - // Use the full absolute path to VS Code's node-pty to avoid module resolution issues - const indexMjs = `// ESM wrapper for VS Code's bundled node-pty -// This shim allows @github/copilot (ESM) to import node-pty from VS Code (CommonJS) - -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); - -// Load VS Code's node-pty (CommonJS) using absolute path -const nodePty = require('${vscodeNodePtyPath.replace(/\\/g, '\\\\')}'); - -// Re-export all named exports -export const spawn = nodePty.spawn; -export const IPty = nodePty.IPty; -export const native = nodePty.native; - -// Re-export default -export default nodePty; -`; - await fs.writeFile(path.join(nodePtyDir, 'index.mjs'), indexMjs); - - } catch (error) { - console.warn('Failed to create node-pty shim:', error); - throw error; + if (stat.size === 0) { + logService.trace(`Skipping ${path.basename(srcPath)}: zero-byte file (likely symlink or special file)`); + return false; + } + + return true; + } catch (error: any) { + logService.warn(`Failed to stat ${srcPath}: ${error?.message ?? error}`); + return false; + } +} + +async function waitForMaterializedShim(destDir: string, primaryBinary: string | undefined, logService: ILogService): Promise { + const deadline = Date.now() + MATERIALIZATION_TIMEOUT_MS; + while (Date.now() <= deadline) { + if (await isShimMaterialized(destDir, primaryBinary)) { + logService.trace(`Reusing node-pty shim that materialized at ${destDir}`); + return true; + } + + await new Promise(resolve => setTimeout(resolve, MATERIALIZATION_POLL_INTERVAL_MS)); } + + return false; } + +async function isShimMaterialized(destDir: string, primaryBinary: string | undefined): Promise { + if (primaryBinary) { + const binaryStat = await fs.stat(path.join(destDir, primaryBinary)).catch(() => undefined); + if (binaryStat && binaryStat.isFile() && binaryStat.size > 0) { + return true; + } + } + + const entries = await fs.readdir(destDir).catch(() => []); + for (const entry of entries) { + const stat = await fs.stat(path.join(destDir, entry)).catch(() => undefined); + if (stat && stat.isFile() && stat.size > 0) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/src/extension/agents/copilotcli/node/permissionHelpers.ts b/src/extension/agents/copilotcli/node/permissionHelpers.ts index cfacf898a..f0327097c 100644 --- a/src/extension/agents/copilotcli/node/permissionHelpers.ts +++ b/src/extension/agents/copilotcli/node/permissionHelpers.ts @@ -3,19 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AgentOptions } from '@github/copilot/sdk'; +import type { SessionOptions } from '@github/copilot/sdk'; import { ToolName } from '../../../tools/common/toolNames'; -export interface PermissionToolParams { - tool: string; - input: unknown; +type CoreTerminalConfirmationToolParams = { + tool: ToolName.CoreTerminalConfirmationTool; + input: { + message: string; + command: string | undefined; + isBackground: boolean; + }; +} + +type CoreConfirmationToolParams = { + tool: ToolName.CoreConfirmationTool; + input: { + title: string; + message: string; + confirmationType: 'basic'; + }; } /** * Pure function mapping a Copilot CLI permission request -> tool invocation params. * Keeps logic out of session class for easier unit testing. */ -export function getConfirmationToolParams(permissionRequest: PermissionRequest): PermissionToolParams { +export function getConfirmationToolParams(permissionRequest: PermissionRequest): CoreTerminalConfirmationToolParams | CoreConfirmationToolParams { if (permissionRequest.kind === 'shell') { return { tool: ToolName.CoreTerminalConfirmationTool, @@ -86,4 +99,4 @@ function codeBlock(obj: Record): string { /** * A permission request which will be used to check tool or path usage against config and/or request user approval. */ -export declare type PermissionRequest = Parameters>[0] | { kind: 'read'; intention: string; path: string }; \ No newline at end of file +export declare type PermissionRequest = Parameters>[0] | { kind: 'read'; intention: string; path: string }; \ No newline at end of file diff --git a/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts b/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts new file mode 100644 index 000000000..dae3e9363 --- /dev/null +++ b/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SessionOptions } from '@github/copilot/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService'; +import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; +import { ILogService } from '../../../../../platform/log/common/logService'; +import { DisposableStore, IDisposable } from '../../../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { CancellationTokenSource, ChatSessionStatus, EventEmitter } from '../../../../../vscodeTypes'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../copilotCli'; +import { ICopilotCLISession } from '../copilotcliSession'; +import { CopilotCLISessionService } from '../copilotcliSessionService'; + +// --- Minimal SDK & dependency stubs --------------------------------------------------------- + +interface TestSdkSession { + readonly sessionId: string; + readonly startTime: Date; + messages: {}[]; + events: {}[]; + aborted: boolean; + getChatContextMessages(): Promise<{}[]>; + getEvents(): {}[]; + abort(): void; + // Methods used by real wrapper but not all tests exercise them; provide no-op/throwing impls + setAuthInfo?: (..._args: {}[]) => void; + getSelectedModel?: () => Promise; + setSelectedModel?: (_model: string) => Promise; + send?: (_opts: {}) => Promise; + emit?: (..._args: {}[]) => void; + on?: (..._args: {}[]) => void; +} + +class FakeSdkSession implements TestSdkSession { + public aborted = false; + public messages: {}[] = []; + public events: {}[] = []; + constructor(public readonly sessionId: string, public readonly startTime: Date) { } + getChatContextMessages(): Promise<{}[]> { return Promise.resolve(this.messages); } + getEvents(): {}[] { return this.events; } + abort(): void { this.aborted = true; } +} + +class FakeCLISessionManager { + public sessions = new Map(); + constructor(_opts: {}) { } + createSession(_options: SessionOptions) { + const id = `sess_${Math.random().toString(36).slice(2, 10)}`; + const s = new FakeSdkSession(id, new Date()); + this.sessions.set(id, s); + return Promise.resolve(s); + } + getSession(opts: SessionOptions & { sessionId: string }, _writable: boolean) { + if (opts && opts.sessionId && this.sessions.has(opts.sessionId)) { + return Promise.resolve(this.sessions.get(opts.sessionId)); + } + return Promise.resolve(undefined); + } + listSessions() { + return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime }))); + } + deleteSession(id: string) { this.sessions.delete(id); return Promise.resolve(); } + closeSession(_id: string) { return Promise.resolve(); } +} + + +describe('CopilotCLISessionService', () => { + const disposables = new DisposableStore(); + let logService: ILogService; + let instantiationService: IInstantiationService; + let optionsService: ICopilotCLISessionOptionsService; + let service: CopilotCLISessionService; + let manager: FakeCLISessionManager; + + beforeEach(async () => { + vi.useRealTimers(); + optionsService = { + _serviceBrand: undefined, + createOptions: vi.fn(async (opts: any) => opts), + }; + const sdk = { + getPackage: vi.fn(async () => ({ internal: { CLISessionManager: FakeCLISessionManager } })) + } as unknown as ICopilotCLISDK; + + const services = disposables.add(createExtensionUnitTestingServices()); + services.set(ICopilotCLISessionOptionsService, optionsService); + const accessor = services.createTestingAccessor(); + logService = accessor.get(ILogService); + instantiationService = { + createInstance: (_: unknown, { sessionId }: { sessionId: string }) => { + const disposables = new DisposableStore(); + const _onDidChangeStatus = disposables.add(new EventEmitter()); + const cliSession: (ICopilotCLISession & DisposableStore) = { + sessionId, + status: undefined, + onDidChangeStatus: _onDidChangeStatus.event, + handleRequest: vi.fn(async () => { }), + addUserMessage: vi.fn(), + addUserAssistantMessage: vi.fn(), + getSelectedModelId: vi.fn(async () => 'gpt-test'), + getChatHistory: vi.fn(async () => []), + get isDisposed() { return disposables.isDisposed; }, + dispose: () => { disposables.dispose(); }, + add: (d: IDisposable) => disposables.add(d) + } as unknown as ICopilotCLISession & DisposableStore; + return cliSession; + } + } as unknown as IInstantiationService; + + service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, optionsService, new NullNativeEnvService(), new MockFileSystemService())); + manager = await service.getSessionManager() as unknown as FakeCLISessionManager; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + disposables.clear(); + }); + + function createToken() { + return disposables.add(new CancellationTokenSource()); + } + + // --- Tests ---------------------------------------------------------------------------------- + + describe('CopilotCLISessionService.createSession', () => { + it('get session will return the same session created using createSession', async () => { + const session = await service.createSession(' ', 'gpt-test', '/tmp', createToken().token); + expect(optionsService.createOptions).toHaveBeenCalledWith({ model: 'gpt-test', workingDirectory: '/tmp' }, expect.anything()); + + const existingSession = await service.getSession(session.sessionId, undefined, undefined, false, createToken().token); + + expect(existingSession).toBe(session); + }); + it('get session will return new once previous session is disposed', async () => { + const session = await service.createSession(' ', 'gpt-test', '/tmp', createToken().token); + expect(optionsService.createOptions).toHaveBeenCalledWith({ model: 'gpt-test', workingDirectory: '/tmp' }, expect.anything()); + + session.dispose(); + await new Promise(resolve => setTimeout(resolve, 0)); // allow dispose async cleanup to run + const existingSession = await service.getSession(session.sessionId, undefined, undefined, false, createToken().token); + + expect(existingSession).not.toBe(session); + }); + }); + + describe('CopilotCLISessionService.getSession missing', () => { + it('returns undefined when underlying manager has no session', async () => { + const result = await service.getSession('does-not-exist', undefined, undefined, true, createToken().token); + + expect(result).toBeUndefined(); + }); + }); + + describe('CopilotCLISessionService.getAllSessions', () => { + it('will not list created sessions', async () => { + await service.createSession(' ', 'gpt-test', '/tmp', createToken().token); + + const s1 = new FakeSdkSession('s1', new Date(0)); + s1.messages.push({ role: 'user', content: 'a'.repeat(100) }); + const tsStr = '2024-01-01T00:00:00.000Z'; + s1.events.push({ type: 'assistant.message', timestamp: tsStr }); + manager.sessions.set(s1.sessionId, s1); + + const result = await service.getAllSessions(createToken().token); + + expect(result.length).toBe(1); + const item = result[0]; + expect(item.id).toBe('s1'); + expect(item.label.endsWith('...')).toBe(true); // truncated + expect(item.label.length).toBeLessThanOrEqual(50); + expect(item.timestamp.toISOString()).toBe(new Date(tsStr).toISOString()); + }); + }); + + describe('CopilotCLISessionService.deleteSession', () => { + it('disposes active wrapper, removes from manager and fires change event', async () => { + const session = await service.createSession('to delete', undefined, undefined, createToken().token); + const id = session!.sessionId; + let fired = false; + disposables.add(service.onDidChangeSessions(() => { fired = true; })); + await service.deleteSession(id); + + expect(manager.sessions.has(id)).toBe(false); + expect(fired).toBe(true); + + expect(await service.getSession(id, undefined, undefined, false, createToken().token)).toBeUndefined(); + }); + }); + + describe('CopilotCLISessionService.label generation', () => { + it('uses first user message line when present', async () => { + const s = new FakeSdkSession('lab1', new Date()); + s.messages.push({ role: 'user', content: 'Line1\nLine2' }); + manager.sessions.set(s.sessionId, s); + + const sessions = await service.getAllSessions(createToken().token); + const item = sessions.find(i => i.id === 'lab1'); + expect(item?.label).toBe('Line1'); + }); + }); + + describe('CopilotCLISessionService.auto disposal timeout', () => { + it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => { + vi.useFakeTimers(); + const session = await service.createSession('will timeout', undefined, undefined, createToken().token); + + vi.advanceTimersByTime(31000); + await Promise.resolve(); // allow any pending promises to run + + // dispose should have been called by timeout + expect(session.isDisposed).toBe(true); + }); + }); +}); diff --git a/src/extension/agents/copilotcli/node/test/copilotcliSession.spec.ts b/src/extension/agents/copilotcli/node/test/copilotcliSession.spec.ts new file mode 100644 index 000000000..f18007774 --- /dev/null +++ b/src/extension/agents/copilotcli/node/test/copilotcliSession.spec.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Session, SessionOptions } from '@github/copilot/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatParticipantToolToken, LanguageModelToolInvocationOptions, LanguageModelToolResult2 } from 'vscode'; +import { ILogService } from '../../../../../platform/log/common/logService'; +import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService'; +import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; +import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; +import * as path from '../../../../../util/vs/base/common/path'; +import { ChatSessionStatus, LanguageModelTextPart, Uri } from '../../../../../vscodeTypes'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { MockChatResponseStream } from '../../../../test/node/testHelpers'; +import { IToolsService, NullToolsService } from '../../../../tools/common/toolsService'; +import { ExternalEditTracker } from '../../../common/externalEditTracker'; +import { CopilotCLIPermissionsHandler, ICopilotCLISessionOptionsService } from '../copilotCli'; +import { CopilotCLISession } from '../copilotcliSession'; +import { CopilotCLIToolNames } from '../copilotcliToolInvocationFormatter'; + +// Minimal shapes for types coming from the Copilot SDK we interact with +interface MockSdkEventHandler { (payload: unknown): void } +type MockSdkEventMap = Map>; + +class MockSdkSession { + onHandlers: MockSdkEventMap = new Map(); + public sessionId = 'mock-session-id'; + public _selectedModel: string | undefined = 'modelA'; + public authInfo: any; + + on(event: string, handler: MockSdkEventHandler) { + if (!this.onHandlers.has(event)) { + this.onHandlers.set(event, new Set()); + } + this.onHandlers.get(event)!.add(handler); + return () => this.onHandlers.get(event)!.delete(handler); + } + + emit(event: string, data: any) { + this.onHandlers.get(event)?.forEach(h => h({ data })); + } + + async send({ prompt }: { prompt: string }) { + // Simulate a normal successful turn with a message + this.emit('assistant.turn_start', {}); + this.emit('assistant.message', { content: `Echo: ${prompt}` }); + this.emit('assistant.turn_end', {}); + } + + setAuthInfo(info: any) { this.authInfo = info; } + async getSelectedModel() { return this._selectedModel; } + async setSelectedModel(model: string) { this._selectedModel = model; } + async getEvents() { return []; } +} + +// Mocks for services +function createSessionOptionsService() { + const auth: Partial = { + createOptions: async () => { + return { + authInfo: { + token: 'copilot-token', + tokenType: 'test', + expiresAt: Date.now() + 60_000, + copilotPlan: 'pro' + } + } as unknown as SessionOptions; + } + }; + return auth as ICopilotCLISessionOptionsService; +} + +function createWorkspaceService(root: string): IWorkspaceService { + const rootUri = Uri.file(root); + return new class extends TestWorkspaceService { + override getWorkspaceFolders() { + return [ + rootUri + ]; + } + override getWorkspaceFolder(uri: Uri) { + return uri.fsPath.startsWith(rootUri.fsPath) ? rootUri : undefined; + } + }; +} +function createToolsService(invocationBehavior: { approve: boolean; throws?: boolean } | undefined, logger: ILogService,): IToolsService { + return new class extends NullToolsService { + override invokeTool = vi.fn(async (_tool: string, _options: LanguageModelToolInvocationOptions, _token: CancellationToken): Promise => { + if (invocationBehavior?.throws) { + throw new Error('tool failed'); + } + return { + content: [new LanguageModelTextPart(invocationBehavior?.approve ? 'yes' : 'no')] + }; + }); + }(logger); +} + + +describe('CopilotCLISession', () => { + const invocationToken: ChatParticipantToolToken = {} as never; + const disposables = new DisposableStore(); + let sdkSession: MockSdkSession; + let permissionHandler: CopilotCLIPermissionsHandler; + let workspaceService: IWorkspaceService; + let toolsService: IToolsService; + let logger: ILogService; + let sessionOptionsService: ICopilotCLISessionOptionsService; + + beforeEach(() => { + const services = disposables.add(createExtensionUnitTestingServices()); + const accessor = services.createTestingAccessor(); + logger = accessor.get(ILogService); + + sdkSession = new MockSdkSession(); + permissionHandler = new CopilotCLIPermissionsHandler(); + sessionOptionsService = createSessionOptionsService(); + workspaceService = createWorkspaceService('/workspace'); + toolsService = createToolsService({ approve: true }, logger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + disposables.clear(); + }); + + + function createSession() { + return disposables.add(new CopilotCLISession( + sdkSession as unknown as Session, + permissionHandler, + logger, + workspaceService, + toolsService, + sessionOptionsService, + )); + } + + it('handles a successful request and streams assistant output', async () => { + const session = createSession(); + const stream = new MockChatResponseStream(); + + await session.handleRequest('Hello', [], undefined, stream, invocationToken, CancellationToken.None); + + expect(session.status).toBe(ChatSessionStatus.Completed); + expect(stream.output.join('\n')).toContain('Echo: Hello'); + // Listeners are disposed after completion, so we only assert original streamed content. + }); + + it('switches model when different modelId provided', async () => { + const session = createSession(); + const stream = new MockChatResponseStream(); + + await session.handleRequest('Hi', [], 'modelB', stream, invocationToken, CancellationToken.None); + + expect(sdkSession._selectedModel).toBe('modelB'); + }); + + it('fails request when underlying send throws', async () => { + // Force send to throw + sdkSession.send = async () => { throw new Error('network'); }; + const session = createSession(); + const stream = new MockChatResponseStream(); + + await session.handleRequest('Boom', [], undefined, stream, invocationToken, CancellationToken.None); + + expect(session.status).toBe(ChatSessionStatus.Failed); + expect(stream.output.join('\n')).toContain('Error: network'); + }); + + it('emits status events on successful request', async () => { + const session = createSession(); + const statuses: (ChatSessionStatus | undefined)[] = []; + const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s))); + const stream = new MockChatResponseStream(); + + await session.handleRequest('Status OK', [], 'modelA', stream, invocationToken, CancellationToken.None); + listener.dispose?.(); + + expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]); + expect(session.status).toBe(ChatSessionStatus.Completed); + }); + + it('emits status events on failed request', async () => { + // Force failure + sdkSession.send = async () => { throw new Error('boom'); }; + const session = createSession(); + const statuses: (ChatSessionStatus | undefined)[] = []; + const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s))); + const stream = new MockChatResponseStream(); + + await session.handleRequest('Will Fail', [], undefined, stream, invocationToken, CancellationToken.None); + listener.dispose?.(); + + expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Failed]); + expect(session.status).toBe(ChatSessionStatus.Failed); + expect(stream.output.join('\n')).toContain('Error: boom'); + }); + + it('auto-approves read permission inside workspace without invoking tool', async () => { + // Keep session active while requesting permission + let resolveSend: () => void; + sdkSession.send = async ({ prompt }: any) => new Promise(r => { resolveSend = r; }).then(() => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` }); + sdkSession.emit('assistant.turn_end', {}); + }); + const session = createSession(); + const stream = new MockChatResponseStream(); + const handlePromise = session.handleRequest('Test', [], undefined, stream, invocationToken, CancellationToken.None); + + // Path must be absolute within workspace + const result = await permissionHandler.getPermissions({ kind: 'read', path: path.join('/workspace', 'file.ts'), intention: 'Read file' }); + resolveSend!(); + await handlePromise; + expect(result).toEqual({ kind: 'approved' }); + expect(toolsService.invokeTool).not.toHaveBeenCalled(); + }); + + it('prompts for write permission and approves when tool returns yes', async () => { + toolsService = createToolsService({ approve: true }, logger); + const session = createSession(); + let resolveSend: () => void; + sdkSession.send = async ({ prompt }: any) => new Promise(r => { resolveSend = r; }).then(() => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` }); + sdkSession.emit('assistant.turn_end', {}); + }); + const stream = new MockChatResponseStream(); + const handlePromise = session.handleRequest('Write', [], undefined, stream, invocationToken, CancellationToken.None); + + const result = await permissionHandler.getPermissions({ kind: 'write', fileName: 'a.ts', intention: 'Update file', diff: '' }); + resolveSend!(); + await handlePromise; + expect(toolsService.invokeTool).toHaveBeenCalled(); + expect(result).toEqual({ kind: 'approved' }); + }); + + it('denies write permission when tool returns no', async () => { + toolsService = createToolsService({ approve: false }, logger); + const session = createSession(); + let resolveSend: () => void; + sdkSession.send = async ({ prompt }: any) => new Promise(r => { resolveSend = r; }).then(() => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` }); + sdkSession.emit('assistant.turn_end', {}); + }); + const stream = new MockChatResponseStream(); + const handlePromise = session.handleRequest('Write', [], undefined, stream, invocationToken, CancellationToken.None); + + const result = await permissionHandler.getPermissions({ kind: 'write', fileName: 'b.ts', intention: 'Update file', diff: '' }); + resolveSend!(); + await handlePromise; + expect(toolsService.invokeTool).toHaveBeenCalled(); + expect(result).toEqual({ kind: 'denied-interactively-by-user' }); + }); + + it('denies permission when tool invocation throws', async () => { + toolsService = createToolsService({ approve: true, throws: true }, logger); + const session = createSession(); + let resolveSend: () => void; + sdkSession.send = async ({ prompt }: any) => new Promise(r => { resolveSend = r; }).then(() => { + sdkSession.emit('assistant.turn_start', {}); + sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` }); + sdkSession.emit('assistant.turn_end', {}); + }); + const stream = new MockChatResponseStream(); + const handlePromise = session.handleRequest('Write', [], undefined, stream, invocationToken, CancellationToken.None); + + const result = await permissionHandler.getPermissions({ kind: 'write', fileName: 'err.ts', intention: 'Update file', diff: '' }); + resolveSend!(); + await handlePromise; + expect(toolsService.invokeTool).toHaveBeenCalled(); + expect(result).toEqual({ kind: 'denied-interactively-by-user' }); + }); + + it('preserves order of edit toolCallIds and permissions for multiple pending edits', async () => { + // Arrange a deferred send so we can emit tool events before request finishes + let resolveSend: () => void; + sdkSession.send = async () => new Promise(r => { resolveSend = r; }); + // Use approval for write permissions + toolsService = createToolsService({ approve: true }, logger); + const session = createSession(); + const stream = new MockChatResponseStream(); + + // Spy on trackEdit to capture ordering (we don't want to depend on externalEdit mechanics here) + const trackedOrder: string[] = []; + const trackSpy = vi.spyOn(ExternalEditTracker.prototype, 'trackEdit').mockImplementation(async function (this: any, editKey: string) { + trackedOrder.push(editKey); + // Immediately resolve to avoid hanging on externalEdit lifecycle + return Promise.resolve(); + }); + + // Act: start handling request (do not await yet) + const requestPromise = session.handleRequest('Edits', [], undefined, stream, invocationToken, CancellationToken.None); + + // Wait a tick to ensure event listeners are registered inside handleRequest + await new Promise(r => setTimeout(r, 0)); + + // Emit 10 edit tool start events in rapid succession for the same file + const filePath = '/workspace/abc.py'; + for (let i = 1; i <= 10; i++) { + sdkSession.emit('tool.execution_start', { + toolCallId: String(i), + toolName: CopilotCLIToolNames.StrReplaceEditor, + arguments: { command: 'str_replace', path: filePath } + }); + } + + // Now request permissions sequentially AFTER all tool calls have been emitted + const permissionResults: any[] = []; + for (let i = 1; i <= 10; i++) { + // Each permission request should dequeue the next toolCallId for the file + const result = await permissionHandler.getPermissions({ + kind: 'write', + fileName: filePath, + intention: 'Apply edit', + diff: '' + }); + permissionResults.push(result); + // Complete the edit so the tracker (if it were real) would finish; emit completion event + sdkSession.emit('tool.execution_complete', { + toolCallId: String(i), + toolName: CopilotCLIToolNames.StrReplaceEditor, + arguments: { command: 'str_replace', path: filePath }, + success: true, + result: { content: '' } + }); + } + + // Allow the request to finish + resolveSend!(); + await requestPromise; + + // Assert ordering of trackEdit invocations exactly matches toolCallIds 1..10 + expect(trackedOrder).toEqual(Array.from({ length: 10 }, (_, i) => String(i + 1))); + expect(permissionResults.every(r => r.kind === 'approved')).toBe(true); + expect(trackSpy).toHaveBeenCalledTimes(10); + + trackSpy.mockRestore(); + }); +}); diff --git a/src/extension/agents/copilotcli/node/test/copilotcliToolInvocationFormatter.spec.ts b/src/extension/agents/copilotcli/node/test/copilotcliToolInvocationFormatter.spec.ts index ebe78dcab..f7c510fae 100644 --- a/src/extension/agents/copilotcli/node/test/copilotcliToolInvocationFormatter.spec.ts +++ b/src/extension/agents/copilotcli/node/test/copilotcliToolInvocationFormatter.spec.ts @@ -4,97 +4,223 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it } from 'vitest'; -import { ChatRequestTurn2, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../../vscodeTypes'; -import { buildChatHistoryFromEvents, CopilotCLIToolNames, createCopilotCLIToolInvocation, processToolExecutionComplete, processToolExecutionStart, stripReminders } from '../copilotcliToolInvocationFormatter'; +import { + ChatRequestTurn2, + ChatResponseMarkdownPart, + ChatResponsePullRequestPart, + ChatResponseThinkingProgressPart, + ChatResponseTurn2, + ChatToolInvocationPart, + MarkdownString +} from '../../../../../vscodeTypes'; +import { + buildChatHistoryFromEvents, + CopilotCLIToolNames, + createCopilotCLIToolInvocation, + getAffectedUrisForEditTool, + isCopilotCliEditToolCall, + processToolExecutionComplete, + processToolExecutionStart, + stripReminders +} from '../copilotcliToolInvocationFormatter'; -// Minimal SDK event shapes for testing -interface UserMessageEvent { type: 'user.message'; data: { content?: string; attachments?: { path: string; type: 'file'; displayName: string }[] } } -interface AssistantMessageEvent { type: 'assistant.message'; data: { content?: string } } -interface ToolExecutionStart { type: 'tool.execution_start'; data: { toolName: string; toolCallId: string; arguments: any } } -interface ToolExecutionComplete { type: 'tool.execution_complete'; data: { toolCallId: string; success: boolean; error?: { code: string; message?: string } } } +// Helper to extract invocation message text independent of MarkdownString vs string +function getInvocationMessageText(part: ChatToolInvocationPart | undefined): string { + if (!part) { return ''; } + const msg: any = part.invocationMessage; + if (!msg) { return ''; } + if (typeof msg === 'string') { return msg; } + if (msg instanceof MarkdownString) { return (msg as any).value ?? ''; } + return msg.value ?? ''; +} -type SessionEvent = UserMessageEvent | AssistantMessageEvent | ToolExecutionStart | ToolExecutionComplete; - -describe('copilotcliToolInvocationFormatter', () => { - it('stripReminders removes reminder, datetime and pr_metadata tags', () => { - const input = '\nDo not say this Keep this text 2025-10-29 and final.'; - const output = stripReminders(input); - expect(output).toBe('Keep this text and final.'); +describe('CopilotCLI ToolInvocationFormatter', () => { + describe('isCopilotCliEditToolCall', () => { + it('detects StrReplaceEditor edit commands (non-view)', () => { + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.StrReplaceEditor, { command: 'str_replace', path: '/tmp/a' })).toBe(true); + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.StrReplaceEditor, { command: 'insert', path: '/tmp/a' })).toBe(true); + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.StrReplaceEditor, { command: 'create', path: '/tmp/a' })).toBe(true); + }); + it('excludes StrReplaceEditor view command', () => { + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.StrReplaceEditor, { command: 'view', path: '/tmp/a' })).toBe(false); + }); + it('always true for Edit & Create tools', () => { + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.Edit, {})).toBe(true); + expect(isCopilotCliEditToolCall(CopilotCLIToolNames.Create, {})).toBe(true); + }); }); - it('buildChatHistoryFromEvents constructs user and assistant turns and strips reminders', () => { - const events: SessionEvent[] = [ - { type: 'user.message', data: { content: 'ignoreUser question', attachments: [{ path: '/workspace/file.txt', type: 'file', displayName: 'file.txt' }] } }, - { type: 'assistant.message', data: { content: 'Here is the answer' } }, - { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.Think, toolCallId: 'think-1', arguments: { thought: 'Reasoning…' } } }, - { type: 'tool.execution_complete', data: { toolCallId: 'think-1', success: true } } - ]; - const turns = buildChatHistoryFromEvents(events as any); - expect(turns.length).toBe(2); - expect(turns[0]).toBeInstanceOf(ChatRequestTurn2); - expect(turns[1]).toBeInstanceOf(ChatResponseTurn2); - // Basic sanity: user content had reminder stripped - const userTurn = turns[0] as ChatRequestTurn2 & { content?: string }; - const rawContent = userTurn.prompt || ''; - expect(rawContent).not.toMatch(/reminder>/); + describe('getAffectedUrisForEditTool', () => { + it('returns URI for edit tool with path', () => { + const [uri] = getAffectedUrisForEditTool(CopilotCLIToolNames.StrReplaceEditor, { command: 'str_replace', path: '/tmp/file.txt' }); + expect(uri.toString()).toContain('/tmp/file.txt'); + }); + it('returns empty for non-edit view command', () => { + expect(getAffectedUrisForEditTool(CopilotCLIToolNames.StrReplaceEditor, { command: 'view', path: '/tmp/file.txt' })).toHaveLength(0); + }); }); - it('createCopilotCLIToolInvocation returns undefined for report_intent and think handled separately', () => { - const reportIntent = createCopilotCLIToolInvocation(CopilotCLIToolNames.ReportIntent, 'id1', {}); - expect(reportIntent).toBeUndefined(); - const thinkInvocation = createCopilotCLIToolInvocation(CopilotCLIToolNames.Think, 'id2', { thought: 'A chain of thought' }); - expect(thinkInvocation).toBeInstanceOf(ChatResponseThinkingProgressPart); + describe('stripReminders', () => { + it('removes reminder blocks and trims', () => { + const input = ' Keep this private\nContent'; + expect(stripReminders(input)).toBe('Content'); + }); + it('removes current datetime blocks', () => { + const input = '2025-10-10 Now'; + expect(stripReminders(input)).toBe('Now'); + }); + it('removes pr_metadata tags', () => { + const input = ' Body'; + expect(stripReminders(input)).toBe('Body'); + }); + it('removes multiple constructs mixed', () => { + const input = 'xOney Two'; + // Current behavior compacts content without guaranteeing spacing + expect(stripReminders(input)).toBe('OneTwo'); + }); }); - it('createCopilotCLIToolInvocation formats str_replace_editor view with range', () => { - const invocation = createCopilotCLIToolInvocation(CopilotCLIToolNames.StrReplaceEditor, 'id3', { command: 'view', path: '/tmp/file.ts', view_range: [1, 5] }) as ChatToolInvocationPart; - expect(invocation).toBeInstanceOf(ChatToolInvocationPart); - const msg = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage?.value; - expect(msg).toMatch(/Read/); - expect(msg).toMatch(/file.ts/); - }); + describe('buildChatHistoryFromEvents', () => { + it('builds turns with user and assistant messages including PR metadata', () => { + const events: any[] = [ + { type: 'user.message', data: { content: 'Hello', attachments: [] } }, + { type: 'assistant.message', data: { content: 'This is the PR body.' } } + ]; + const turns = buildChatHistoryFromEvents(events); + expect(turns).toHaveLength(2); // request + response + expect(turns[0]).toBeInstanceOf(ChatRequestTurn2); + expect(turns[1]).toBeInstanceOf(ChatResponseTurn2); + const responseParts: any = (turns[1] as any).response; + // ResponseParts is private-ish; fallback to accessing parts array property variations + const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts); + // First part should be PR metadata + const prPart = parts.find(p => p instanceof ChatResponsePullRequestPart); + expect(prPart).toBeTruthy(); + const markdownPart = parts.find(p => p instanceof ChatResponseMarkdownPart); + expect(markdownPart).toBeTruthy(); + if (prPart) { + expect((prPart as any).title).toBe('Fix&Improve'); // & unescaped + // uri is stored as a Uri + expect((prPart as any).uri.toString()).toContain('https://example.com/pr/1'); + } + if (markdownPart) { + expect((markdownPart as any).value?.value || (markdownPart as any).value).toContain('This is the PR body.'); + } + }); + + it('createCopilotCLIToolInvocation formats str_replace_editor view with range', () => { + const invocation = createCopilotCLIToolInvocation(CopilotCLIToolNames.StrReplaceEditor, 'id3', { command: 'view', path: '/tmp/file.ts', view_range: [1, 5] }) as ChatToolInvocationPart; + expect(invocation).toBeInstanceOf(ChatToolInvocationPart); + const msg = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage?.value; + expect(msg).toMatch(/Read/); + expect(msg).toMatch(/file.ts/); + }); - it('createCopilotCLIToolInvocation formats bash invocation with command and description', () => { - const invocation = createCopilotCLIToolInvocation(CopilotCLIToolNames.Bash, 'bash-1', { command: 'echo "hi"', description: 'Run echo' }); - expect(invocation).toBeInstanceOf(ChatToolInvocationPart); - // @ts-expect-error internal props - expect(invocation?.toolSpecificData?.language).toBe('bash'); - // @ts-expect-error invocationMessage internal - expect(invocation?.invocationMessage?.value).toBe('Run echo'); + it('includes tool invocation parts and thinking progress without duplication', () => { + const events: any[] = [ + { type: 'user.message', data: { content: 'Run a command', attachments: [] } }, + { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.Think, toolCallId: 'think-1', arguments: { thought: 'Considering options' } } }, + { type: 'tool.execution_complete', data: { toolName: CopilotCLIToolNames.Think, toolCallId: 'think-1', success: true } }, + { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-1', arguments: { command: 'echo hi', description: 'Echo' } } }, + { type: 'tool.execution_complete', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-1', success: true } } + ]; + const turns = buildChatHistoryFromEvents(events); + expect(turns).toHaveLength(2); // request + response + const responseTurn = turns[1] as ChatResponseTurn2; + const responseParts: any = (responseTurn as any).response; + const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts); + const thinkingParts = parts.filter(p => p instanceof ChatResponseThinkingProgressPart); + expect(thinkingParts).toHaveLength(1); // not duplicated on completion + const toolInvocations = parts.filter(p => p instanceof ChatToolInvocationPart); + expect(toolInvocations).toHaveLength(1); // bash only + const bashInvocation = toolInvocations[0] as ChatToolInvocationPart; + expect(getInvocationMessageText(bashInvocation)).toContain('Echo'); + }); }); - it('createCopilotCLIToolInvocation handles generic tool', () => { - const invocation = createCopilotCLIToolInvocation('custom_tool', 'custom-1', { foo: 'bar' }) as ChatToolInvocationPart; - expect(invocation).toBeInstanceOf(ChatToolInvocationPart); - // invocationMessage may be a plain string for generic tools - const msg = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage?.value; - expect(msg).toMatch(/Used tool: custom_tool/); + describe('createCopilotCLIToolInvocation', () => { + it('returns undefined for report_intent', () => { + expect(createCopilotCLIToolInvocation(CopilotCLIToolNames.ReportIntent, 'id', {})).toBeUndefined(); + }); + it('creates thinking progress part for think tool', () => { + const part = createCopilotCLIToolInvocation(CopilotCLIToolNames.Think, 'tid', { thought: 'Analyzing' }); + expect(part).toBeInstanceOf(ChatResponseThinkingProgressPart); + }); + it('formats bash tool invocation with description', () => { + const part = createCopilotCLIToolInvocation(CopilotCLIToolNames.Bash, 'b1', { command: 'ls', description: 'List files' }); + expect(part).toBeInstanceOf(ChatToolInvocationPart); + expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('List files'); + }); + it('formats str_replace_editor create', () => { + const part = createCopilotCLIToolInvocation(CopilotCLIToolNames.StrReplaceEditor, 'e1', { command: 'create', path: '/tmp/x.ts' }); + expect(part).toBeInstanceOf(ChatToolInvocationPart); + const msg = getInvocationMessageText(part as ChatToolInvocationPart); + expect(msg).toMatch(/Created/); + }); }); - it('processToolExecutionStart stores invocation and processToolExecutionComplete updates status on success', () => { - const pending = new Map(); - const startEvt: ToolExecutionStart = { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.View, toolCallId: 'call-1', arguments: { command: 'view', path: '/x.ts' } } }; - const part = processToolExecutionStart(startEvt as any, pending); - expect(part).toBeInstanceOf(ChatToolInvocationPart); - const completeEvt: ToolExecutionComplete = { type: 'tool.execution_complete', data: { toolCallId: 'call-1', success: true } }; - const completed = processToolExecutionComplete(completeEvt as any, pending) as ChatToolInvocationPart; - expect(completed.isComplete).toBe(true); - expect(completed.isError).toBe(false); - expect(completed.isConfirmed).toBe(true); + describe('process tool execution lifecycle', () => { + it('marks tool invocation complete and confirmed on success', () => { + const pending = new Map(); + const startEvent: any = { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-1', arguments: { command: 'echo hi' } } }; + const part = processToolExecutionStart(startEvent, pending); + expect(part).toBeInstanceOf(ChatToolInvocationPart); + const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-1', success: true } }; + const completed = processToolExecutionComplete(completeEvent, pending) as ChatToolInvocationPart; + expect(completed.isComplete).toBe(true); + expect(completed.isError).toBe(false); + expect(completed.isConfirmed).toBe(true); + }); + it('marks tool invocation error and unconfirmed when denied', () => { + const pending = new Map(); + processToolExecutionStart({ type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-2', arguments: { command: 'rm *' } } } as any, pending); + const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: CopilotCLIToolNames.Bash, toolCallId: 'bash-2', success: false, error: { message: 'Denied', code: 'denied' } } }; + const completed = processToolExecutionComplete(completeEvent, pending) as ChatToolInvocationPart; + expect(completed.isComplete).toBe(true); + expect(completed.isError).toBe(true); + expect(completed.isConfirmed).toBe(false); + expect(getInvocationMessageText(completed)).toContain('Denied'); + }); }); - it('processToolExecutionComplete marks rejected error invocation', () => { - const pending = new Map(); - const startEvt: ToolExecutionStart = { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.View, toolCallId: 'call-err', arguments: { command: 'view', path: '/y.ts' } } }; - const part = processToolExecutionStart(startEvt as any, pending) as ChatToolInvocationPart; - expect(part).toBeInstanceOf(ChatToolInvocationPart); - const completeEvt: ToolExecutionComplete = { type: 'tool.execution_complete', data: { toolCallId: 'call-err', success: false, error: { code: 'rejected', message: 'Denied' } } }; - const completed = processToolExecutionComplete(completeEvt as any, pending) as ChatToolInvocationPart; - expect(completed.isComplete).toBe(true); - expect(completed.isError).toBe(true); - expect(completed.isConfirmed).toBe(false); - // message could be a string after error override - const msg = typeof completed.invocationMessage === 'string' ? completed.invocationMessage : completed.invocationMessage?.value; - expect(msg).toMatch(/Denied/); + describe('integration edge cases', () => { + it('ignores report_intent events inside history build', () => { + const events: any[] = [ + { type: 'user.message', data: { content: 'Hi', attachments: [] } }, + { type: 'tool.execution_start', data: { toolName: CopilotCLIToolNames.ReportIntent, toolCallId: 'ri-1', arguments: {} } }, + { type: 'tool.execution_complete', data: { toolName: CopilotCLIToolNames.ReportIntent, toolCallId: 'ri-1', success: true } } + ]; + const turns = buildChatHistoryFromEvents(events); + expect(turns).toHaveLength(1); // Only user turn, no response parts because no assistant/tool parts were added + }); + + it('handles multiple user messages flushing response parts correctly', () => { + const events: any[] = [ + { type: 'assistant.message', data: { content: 'Hello' } }, + { type: 'user.message', data: { content: 'Follow up', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Response 2' } } + ]; + const turns = buildChatHistoryFromEvents(events); + // Expect: first assistant message buffered until user msg -> becomes response turn, then user request, then second assistant -> another response + expect(turns.filter(t => t instanceof ChatResponseTurn2)).toHaveLength(2); + expect(turns.filter(t => t instanceof ChatRequestTurn2)).toHaveLength(1); + }); + + it('creates markdown part only when cleaned content not empty after stripping PR metadata', () => { + const events: any[] = [ + { type: 'assistant.message', data: { content: '' } } + ]; + const turns = buildChatHistoryFromEvents(events); + // Single response turn with ONLY PR part (no markdown text) + const responseTurns = turns.filter(t => t instanceof ChatResponseTurn2) as ChatResponseTurn2[]; + expect(responseTurns).toHaveLength(1); + const responseParts: any = (responseTurns[0] as any).response; + const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts); + const prCount = parts.filter(p => p instanceof ChatResponsePullRequestPart).length; + const mdCount = parts.filter(p => p instanceof ChatResponseMarkdownPart).length; + expect(prCount).toBe(1); + expect(mdCount).toBe(0); + }); }); }); + diff --git a/src/extension/agents/copilotcli/node/test/permissionHelpers.spec.ts b/src/extension/agents/copilotcli/node/test/permissionHelpers.spec.ts index 041d43bfd..a19e55a7c 100644 --- a/src/extension/agents/copilotcli/node/test/permissionHelpers.spec.ts +++ b/src/extension/agents/copilotcli/node/test/permissionHelpers.spec.ts @@ -2,25 +2,153 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { describe, expect, it } from 'vitest'; import { ToolName } from '../../../../tools/common/toolNames'; -import { getConfirmationToolParams } from '../permissionHelpers'; +import { getConfirmationToolParams, PermissionRequest } from '../permissionHelpers'; -describe('permissionHelpers.getConfirmationToolParams', () => { - it('maps shell requests to terminal confirmation tool', () => { - const result = getConfirmationToolParams({ kind: 'shell', fullCommandText: 'rm -rf /tmp/test', canOfferSessionApproval: true, commands: [], hasWriteFileRedirection: true, intention: '', possiblePaths: [] }); - expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool); - }); - it('maps write requests with filename', () => { - const result = getConfirmationToolParams({ kind: 'write', fileName: 'foo.ts', diff: '', intention: '' }); - expect(result.tool).toBe(ToolName.CoreConfirmationTool); - const input = result.input as any; - expect(input.message).toContain('Edit foo.ts'); +describe('CopilotCLI permissionHelpers', () => { + describe('getConfirmationToolParams', () => { + it('shell: uses intention over command text and sets terminal confirmation tool', () => { + const req: PermissionRequest = { kind: 'shell', intention: 'List workspace files', fullCommandText: 'ls -la' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreTerminalConfirmationTool) { + expect.fail('Expected CoreTerminalConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool); + expect(result.input.message).toBe('List workspace files'); + expect(result.input.command).toBe('ls -la'); + expect(result.input.isBackground).toBe(false); + }); + + it('shell: falls back to fullCommandText when no intention', () => { + const req: PermissionRequest = { kind: 'shell', fullCommandText: 'echo "hi"' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreTerminalConfirmationTool) { + expect.fail('Expected CoreTerminalConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool); + expect(result.input.message).toBe('echo "hi"'); + expect(result.input.command).toBe('echo "hi"'); + }); + + it('shell: falls back to codeBlock when neither intention nor command text provided', () => { + const req: PermissionRequest = { kind: 'shell' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreTerminalConfirmationTool) { + expect.fail('Expected CoreTerminalConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool); + // codeBlock starts with two newlines then ``` + expect(result.input.message).toMatch(/^\n\n```/); + }); + + it('write: uses intention as title and fileName for message', () => { + const req: PermissionRequest = { kind: 'write', intention: 'Modify configuration', fileName: 'config.json' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + expect(result.input.title).toBe('Modify configuration'); + expect(result.input.message).toBe('Edit config.json'); + expect(result.input.confirmationType).toBe('basic'); + }); + + it('write: falls back to default title and codeBlock message when no intention and no fileName', () => { + const req: PermissionRequest = { kind: 'write' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + expect(result.input.title).toBe('Copilot CLI Permission Request'); + expect(result.input.message).toMatch(/"kind": "write"/); + }); + + it('mcp: formats with serverName, toolTitle and args JSON', () => { + const req: PermissionRequest = { kind: 'mcp', serverName: 'files', toolTitle: 'List Files', toolName: 'list', args: { path: '/tmp' } } as any; + const result = getConfirmationToolParams(req); + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.input.title).toBe('List Files'); + expect(result.input.message).toContain('Server: files'); + expect(result.input.message).toContain('"path": "/tmp"'); + }); + + it('mcp: falls back to generated title and full JSON when no serverName', () => { + const req: PermissionRequest = { kind: 'mcp', toolName: 'info', args: { detail: true } } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.input.title).toBe('MCP Tool: info'); + expect(result.input.message).toMatch(/```json/); + expect(result.input.message).toContain('"detail": true'); + }); + + it('mcp: uses Unknown when neither toolTitle nor toolName provided', () => { + const req: PermissionRequest = { kind: 'mcp', args: {} } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.input.title).toBe('MCP Tool: Unknown'); + }); + + it('read: returns specialized title and intention message', () => { + const req: PermissionRequest = { kind: 'read', intention: 'Read 2 files', path: '/tmp/a' } as any; + const result = getConfirmationToolParams(req); + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.input.title).toBe('Read file(s)'); + expect(result.input.message).toBe('Read 2 files'); + }); + + it('read: falls through to default when intention empty string', () => { + const req: PermissionRequest = { kind: 'read', intention: '', path: '/tmp/a' } as any; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.input.title).toBe('Copilot CLI Permission Request'); + expect(result.input.message).toMatch(/"kind": "read"/); + }); + + it('default: unknown kind uses generic confirmation and wraps JSON in code block', () => { + const req: any = { kind: 'some_new_kind', extra: 1 }; + const result = getConfirmationToolParams(req); + if (result.tool !== ToolName.CoreConfirmationTool) { + expect.fail('Expected CoreConfirmationTool'); + } + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + expect(result.input.title).toBe('Copilot CLI Permission Request'); + expect(result.input.message).toMatch(/^\n\n```/); + expect(result.input.message).toContain('"some_new_kind"'); + }); }); - it('maps mcp requests', () => { - const result = getConfirmationToolParams({ kind: 'mcp', serverName: 'srv', toolTitle: 'Tool', toolName: 'run', args: { a: 1 }, readOnly: false }); - expect(result.tool).toBe(ToolName.CoreConfirmationTool); + describe('getConfirmationToolParams', () => { + it('maps shell requests to terminal confirmation tool', () => { + const result = getConfirmationToolParams({ kind: 'shell', fullCommandText: 'rm -rf /tmp/test', canOfferSessionApproval: true, commands: [], hasWriteFileRedirection: true, intention: '', possiblePaths: [] }); + expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool); + }); + + it('maps write requests with filename', () => { + const result = getConfirmationToolParams({ kind: 'write', fileName: 'foo.ts', diff: '', intention: '' }); + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + const input = result.input as any; + expect(input.message).toContain('Edit foo.ts'); + }); + + it('maps mcp requests', () => { + const result = getConfirmationToolParams({ kind: 'mcp', serverName: 'srv', toolTitle: 'Tool', toolName: 'run', args: { a: 1 }, readOnly: false }); + expect(result.tool).toBe(ToolName.CoreConfirmationTool); + }); }); }); diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index 38b3b1c7d..c9b582acb 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -14,7 +14,7 @@ import { ServiceCollection } from '../../../util/vs/platform/instantiation/commo import { ClaudeAgentManager } from '../../agents/claude/node/claudeCodeAgent'; import { ClaudeCodeSdkService, IClaudeCodeSdkService } from '../../agents/claude/node/claudeCodeSdkService'; import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../../agents/claude/node/claudeCodeSessionService'; -import { CopilotCLIModels, CopilotCLISDK, ICopilotCLIModels, ICopilotCLISDK } from '../../agents/copilotcli/node/copilotCli'; +import { CopilotCLIModels, CopilotCLISDK, CopilotCLISessionOptionsService, ICopilotCLIModels, ICopilotCLISDK, ICopilotCLISessionOptionsService } from '../../agents/copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver'; import { CopilotCLISessionService, ICopilotCLISessionService } from '../../agents/copilotcli/node/copilotcliSessionService'; import { ILanguageModelServer, LanguageModelServer } from '../../agents/node/langModelServer'; @@ -90,6 +90,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliAgentInstaService = instantiationService.createChild( new ServiceCollection( [ICopilotCLISessionService, new SyncDescriptor(CopilotCLISessionService)], + [ICopilotCLISessionOptionsService, new SyncDescriptor(CopilotCLISessionOptionsService)], [ICopilotCLIModels, new SyncDescriptor(CopilotCLIModels)], [ICopilotCLISDK, new SyncDescriptor(CopilotCLISDK)], [ILanguageModelServer, new SyncDescriptor(LanguageModelServer)], diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 2b9c7d463..b80f0ec40 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -20,22 +20,45 @@ import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { ConfirmationResult, CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; +/** Option ID for model selection in chat session options */ const MODELS_OPTION_ID = 'model'; + +/** Option ID for worktree isolation in chat session options */ const ISOLATION_OPTION_ID = 'isolation'; -// Track model selections per session -// TODO@rebornix: we should have proper storage for the session model preference (revisit with API) +/** + * Tracks model selections per session. + * Maps session IDs to their selected model options. + * TODO@rebornix: we should have proper storage for the session model preference (revisit with API) + */ const _sessionModel: Map = new Map(); +/** + * Manages git worktrees for Copilot CLI chat sessions. + * Handles creation, storage, and retrieval of isolated worktrees for sessions. + */ export class CopilotCLIWorktreeManager { + /** Storage key for default session isolation preference */ static COPILOT_CLI_DEFAULT_ISOLATION_MEMENTO_KEY = 'github.copilot.cli.sessionIsolation'; + + /** Storage key for session-to-worktree path mappings */ static COPILOT_CLI_SESSION_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees'; + /** Maps session IDs to their isolation preference */ private _sessionIsolation: Map = new Map(); + + /** Maps session IDs to their worktree paths */ private _sessionWorktrees: Map = new Map(); + constructor( @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext) { } + /** + * Creates a git worktree for a session if isolation is enabled. + * @param sessionId - The unique identifier of the chat session + * @param stream - The chat response stream for progress messages + * @returns The path to the created worktree, or undefined if isolation is disabled or creation failed + */ async createWorktreeIfNeeded(sessionId: string, stream: vscode.ChatResponseStream): Promise { const isolationEnabled = this._sessionIsolation.get(sessionId) ?? false; if (!isolationEnabled) { @@ -56,6 +79,11 @@ export class CopilotCLIWorktreeManager { return undefined; } + /** + * Stores the worktree path for a session in both memory and persistent storage. + * @param sessionId - The unique identifier of the chat session + * @param workingDirectory - The path to the worktree + */ async storeWorktreePath(sessionId: string, workingDirectory: string): Promise { this._sessionWorktrees.set(sessionId, workingDirectory); const sessionWorktrees = this.extensionContext.globalState.get>(CopilotCLIWorktreeManager.COPILOT_CLI_SESSION_WORKTREE_MEMENTO_KEY, {}); @@ -63,6 +91,11 @@ export class CopilotCLIWorktreeManager { await this.extensionContext.globalState.update(CopilotCLIWorktreeManager.COPILOT_CLI_SESSION_WORKTREE_MEMENTO_KEY, sessionWorktrees); } + /** + * Retrieves the worktree path for a session from memory or persistent storage. + * @param sessionId - The unique identifier of the chat session + * @returns The worktree path, or undefined if not found + */ getWorktreePath(sessionId: string): string | undefined { let workingDirectory = this._sessionWorktrees.get(sessionId); if (!workingDirectory) { @@ -75,6 +108,12 @@ export class CopilotCLIWorktreeManager { return workingDirectory; } + /** + * Gets the relative (display) name of the worktree for a session. + * Extracts the worktree name from its full path. + * @param sessionId - The unique identifier of the chat session + * @returns The worktree name (last path segment), or undefined if no worktree exists + */ getWorktreeRelativePath(sessionId: string): string | undefined { const worktreePath = this.getWorktreePath(sessionId); if (!worktreePath) { @@ -87,6 +126,12 @@ export class CopilotCLIWorktreeManager { } + /** + * Gets the isolation preference for a session. + * Falls back to the global default isolation setting if not set for this session. + * @param sessionId - The unique identifier of the chat session + * @returns true if isolation is enabled for this session, false otherwise + */ getIsolationPreference(sessionId: string): boolean { if (!this._sessionIsolation.has(sessionId)) { const defaultIsolation = this.extensionContext.globalState.get(CopilotCLIWorktreeManager.COPILOT_CLI_DEFAULT_ISOLATION_MEMENTO_KEY, false); @@ -95,6 +140,11 @@ export class CopilotCLIWorktreeManager { return this._sessionIsolation.get(sessionId) ?? false; } + /** + * Sets the isolation preference for a session and updates the global default. + * @param sessionId - The unique identifier of the chat session + * @param enabled - Whether isolation should be enabled + */ async setIsolationPreference(sessionId: string, enabled: boolean): Promise { this._sessionIsolation.set(sessionId, enabled); await this.extensionContext.globalState.update(CopilotCLIWorktreeManager.COPILOT_CLI_DEFAULT_ISOLATION_MEMENTO_KEY, enabled); @@ -102,20 +152,37 @@ export class CopilotCLIWorktreeManager { } +/** + * Utility namespace for converting between session IDs and VS Code URIs. + * Uses the 'copilotcli' URI scheme to represent CLI sessions. + */ namespace SessionIdForCLI { + /** + * Converts a session ID to a VS Code URI. + * @param sessionId - The session ID to convert + * @returns A URI with the 'copilotcli' scheme + */ export function getResource(sessionId: string): vscode.Uri { return vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}`, }); } + /** + * Extracts the session ID from a VS Code URI. + * @param resource - The URI to parse + * @returns The session ID (path without leading slash) + */ export function parse(resource: vscode.Uri): string { return resource.path.slice(1); } } /** - * Escape XML special characters + * Escapes XML special characters in a string. + * Used to safely embed text in XML attributes and elements. + * @param text - The text to escape + * @returns The escaped text with HTML/XML entities (&, <, >, ", ') */ function escapeXml(text: string): string { return text @@ -126,10 +193,16 @@ function escapeXml(text: string): string { .replace(/'/g, '''); } +/** + * Provides chat session items for Copilot CLI sessions. + * Implements VS Code's ChatSessionItemProvider interface to display CLI sessions in the chat panel. + */ export class CopilotCLIChatSessionItemProvider extends Disposable implements vscode.ChatSessionItemProvider { + /** Event fired when the list of chat session items changes */ private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); public readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + /** Event fired when a chat session item is committed (e.g., renamed after creation) */ private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; constructor( @@ -144,37 +217,68 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc })); } + /** + * Refreshes the list of chat session items. + * Triggers a refresh in the chat panel UI. + */ public refresh(): void { this._onDidChangeChatSessionItems.fire(); } + /** + * Swaps a chat session item with a modified version. + * Used when updating session metadata (e.g., after creation). + * @param original - The original session item + * @param modified - The modified session item + */ public swap(original: vscode.ChatSessionItem, modified: vscode.ChatSessionItem): void { this._onDidCommitChatSessionItem.fire({ original, modified }); } + /** + * Provides all chat session items for display in the chat panel. + * @param token - Cancellation token + * @returns Array of chat session items + */ public async provideChatSessionItems(token: vscode.CancellationToken): Promise { const sessions = await this.copilotcliSessionService.getAllSessions(token); const diskSessions = sessions.map(session => this._toChatSessionItem(session)); + // Update context for UI state (e.g., showing empty state) const count = diskSessions.length; vscode.commands.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0); return diskSessions; } + /** + * Converts a session object to a VS Code chat session item. + * @param session - The session data to convert + * @returns A chat session item for display in the UI + */ private _toChatSessionItem(session: { id: string; label: string; timestamp: Date; status?: vscode.ChatSessionStatus }): vscode.ChatSessionItem { + // Create a URI resource for this session using the 'copilotcli' scheme const resource = SessionIdForCLI.getResource(session.id); + + // Use provided label or default to 'Copilot CLI' const label = session.label || 'Copilot CLI'; + + // Check if this session has an associated worktree for isolation const worktreePath = this.worktreeManager.getWorktreeRelativePath(session.id); let description: vscode.MarkdownString | undefined; if (worktreePath) { + // Display worktree name with git icon in the description description = new vscode.MarkdownString(`$(git-merge) ${worktreePath}`); description.supportThemeIcons = true; } + + // Build tooltip with session details const tooltipLines = [`Copilot CLI session: ${label}`]; if (worktreePath) { tooltipLines.push(`Worktree: ${worktreePath}`); } + + // Default to Completed status if not specified const status = session.status ?? vscode.ChatSessionStatus.Completed; return { resource, @@ -186,12 +290,21 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc }; } + /** + * Creates and opens a new Copilot CLI terminal. + * The terminal name can be customized via the COPILOTCLI_TERMINAL_TITLE environment variable. + */ public async createCopilotCLITerminal(): Promise { // TODO@rebornix should be set by CLI const terminalName = process.env.COPILOTCLI_TERMINAL_TITLE || 'Copilot CLI'; await this.terminalIntegration.openTerminal(terminalName); } + /** + * Resumes a Copilot CLI session in a terminal. + * Opens a terminal and passes the --resume flag with the session ID. + * @param sessionItem - The session item to resume + */ public async resumeCopilotCLISessionInTerminal(sessionItem: vscode.ChatSessionItem): Promise { const id = SessionIdForCLI.parse(sessionItem.resource); const terminalName = sessionItem.label || id; @@ -200,6 +313,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } } +/** + * Provides content and options for Copilot CLI chat sessions. + * Implements VS Code's ChatSessionContentProvider interface to manage session state and options. + */ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionContentProvider { constructor( private readonly worktreeManager: CopilotCLIWorktreeManager, @@ -208,28 +325,52 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC @IConfigurationService private readonly configurationService: IConfigurationService, ) { } + /** + * Provides the content and initial state for a chat session. + * Loads session history, selected model, and configuration options. + * @param resource - The URI identifying the chat session + * @param token - Cancellation token + * @returns A chat session object with history and options + */ async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise { + // Fetch available models and default model in parallel for efficiency const [models, defaultModel] = await Promise.all([ this.copilotCLIModels.getAvailableModels(), this.copilotCLIModels.getDefaultModel() ]); + + // Extract session ID from the resource URI const copilotcliSessionId = SessionIdForCLI.parse(resource); + + // Determine the preferred model for this session const preferredModelId = _sessionModel.get(copilotcliSessionId)?.id; const preferredModel = (preferredModelId ? models.find(m => m.id === preferredModelId) : undefined) ?? defaultModel; - const existingSession = await this.sessionService.getSession(copilotcliSessionId, undefined, false, token); + // Check if session has an associated worktree + const workingDirectory = this.worktreeManager.getWorktreePath(copilotcliSessionId); + + // Try to load existing session from disk + const existingSession = await this.sessionService.getSession(copilotcliSessionId, undefined, workingDirectory, false, token); + + // Get the model selected in the existing session (if any) const selectedModelId = await existingSession?.getSelectedModelId(); const selectedModel = selectedModelId ? models.find(m => m.id === selectedModelId) : undefined; + + // Build options for the session UI const options: Record = { [MODELS_OPTION_ID]: _sessionModel.get(copilotcliSessionId)?.id ?? defaultModel.id, }; + // For new sessions, add isolation option if the feature is enabled if (!existingSession && this.configurationService.getConfig(ConfigKey.Internal.CLIIsolationEnabled)) { const isolationEnabled = this.worktreeManager.getIsolationPreference(copilotcliSessionId); options[ISOLATION_OPTION_ID] = isolationEnabled ? 'enabled' : 'disabled'; } + + // Load chat history from existing session, or use empty array for new sessions const history = await existingSession?.getChatHistory() || []; + // Cache the selected model for this session if not already set if (!_sessionModel.get(copilotcliSessionId)) { _sessionModel.set(copilotcliSessionId, selectedModel ?? preferredModel); } @@ -242,6 +383,10 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC }; } + /** + * Provides the available options for chat sessions (e.g., model selection, isolation). + * @returns Configuration options for the chat session provider + */ async provideChatSessionProviderOptions(): Promise { return { optionGroups: [ @@ -264,30 +409,46 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC }; } - // Handle option changes for a session (store current state in a map) + /** + * Handles changes to session options (e.g., model selection, isolation preference). + * Stores the updated settings in memory and persists user preferences. + * @param resource - The URI identifying the chat session + * @param updates - Array of option updates to apply + * @param token - Cancellation token + */ async provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray, token: vscode.CancellationToken): Promise { const sessionId = SessionIdForCLI.parse(resource); const models = await this.copilotCLIModels.getAvailableModels(); + + // Process each option update for (const update of updates) { if (update.optionId === MODELS_OPTION_ID) { + // Handle model selection changes if (typeof update.value === 'undefined') { + // Clear model selection if value is undefined _sessionModel.set(sessionId, undefined); } else { + // Find and store the selected model const model = models.find(m => m.id === update.value); _sessionModel.set(sessionId, model); - // Persist the user's choice to global state + + // Persist the user's choice to global state for future sessions if (model) { this.copilotCLIModels.setDefaultModel(model); } } } else if (update.optionId === ISOLATION_OPTION_ID) { - // Handle isolation option changes + // Handle isolation (worktree) option changes await this.worktreeManager.setIsolationPreference(sessionId, update.value === 'enabled'); } } } } +/** + * Handles chat requests for Copilot CLI sessions. + * Orchestrates request processing, delegation to cloud agents, and session management. + */ export class CopilotCLIChatSessionParticipant { constructor( private readonly promptResolver: CopilotCLIPromptResolver, @@ -301,10 +462,23 @@ export class CopilotCLIChatSessionParticipant { @ITelemetryService private readonly telemetryService: ITelemetryService, ) { } + /** + * Creates a chat request handler function. + * @returns A bound handler function for processing chat requests + */ createHandler(): ChatExtendedRequestHandler { return this.handleRequest.bind(this); } + /** + * Handles an incoming chat request. + * Routes requests to appropriate handlers based on session state and request type. + * @param request - The chat request to process + * @param context - The chat context including session information + * @param stream - The response stream for sending messages + * @param token - Cancellation token + * @returns A chat result or void + */ private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { const { chatSessionContext } = context; @@ -318,123 +492,175 @@ export class CopilotCLIChatSessionParticipant { "hasDelegatePrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the prompt is a /delegate command." } } */ + // Send telemetry about the chat request this.telemetryService.sendMSFTTelemetryEvent('copilotcli.chat.invoke', { hasChatSessionItem: String(!!chatSessionContext?.chatSessionItem), isUntitled: String(chatSessionContext?.isUntitled), hasDelegatePrompt: String(request.prompt.startsWith('/delegate')) }); + // Handle requests without session context (invoked from normal chat or cloud button) if (!chatSessionContext) { if (request.acceptedConfirmationData || request.rejectedConfirmationData) { stream.warning(vscode.l10n.t('No chat session context available for confirmation data handling.')); return {}; } - /* Invoked from a 'normal' chat or 'cloud button' without CLI session context */ - // Handle confirmation data + // Create a new CLI session and push chat content to it return await this.handlePushConfirmationData(request, context, stream, token); } + // Determine which model to use for this request const defaultModel = await this.copilotCLIModels.getDefaultModel(); const { resource } = chatSessionContext.chatSessionItem; const id = SessionIdForCLI.parse(resource); const preferredModel = _sessionModel.get(id); // For existing sessions we cannot fall back, as the model info would be updated in _sessionModel const modelId = this.copilotCLIModels.toModelProvider(preferredModel?.id || defaultModel.id); + + // Resolve the prompt and any attachments from the request const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, token); + // Handle new (untitled) sessions - create session and optional worktree if (chatSessionContext.isUntitled) { - const untitledCopilotcliSessionId = SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource); - const session = await this.sessionService.createSession(prompt, modelId, token); - const workingDirectory = await this.worktreeManager.createWorktreeIfNeeded(untitledCopilotcliSessionId, stream); + const workingDirectory = await this.worktreeManager.createWorktreeIfNeeded(id, stream); + const session = await this.sessionService.createSession(prompt, modelId, workingDirectory, token); + + // Store worktree path if one was created + if (workingDirectory) { + await this.worktreeManager.storeWorktreePath(session.sessionId, workingDirectory); + } - await session.handleRequest(prompt, attachments, request.toolInvocationToken, stream, modelId, workingDirectory, token); + // Process the request with the CLI session + await session.handleRequest(prompt, attachments, modelId, stream, request.toolInvocationToken, token); + // Update the session item with the actual session ID and user's prompt as the label this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(session.sessionId), label: request.prompt ?? 'CopilotCLI' }); - if (workingDirectory) { - await this.worktreeManager.storeWorktreePath(session.sessionId, workingDirectory); - } return {}; } - const session = await this.sessionService.getSession(id, undefined, false, token); + // Handle existing sessions - retrieve session from storage + const workingDirectory = this.worktreeManager.getWorktreePath(id); + const session = await this.sessionService.getSession(id, undefined, workingDirectory, false, token); if (!session) { stream.warning(vscode.l10n.t('Chat session not found.')); return {}; } + // Handle confirmation responses (e.g., user accepted/rejected delegation with uncommitted changes) if (request.acceptedConfirmationData || request.rejectedConfirmationData) { return await this.handleConfirmationData(session, request, context, stream, token); } + // Handle /delegate command to delegate work to cloud agent if (request.prompt.startsWith('/delegate')) { await this.handleDelegateCommand(session, request, context, stream, token); return {}; } - const workingDirectory = this.worktreeManager.getWorktreePath(id); - await session.handleRequest(prompt, attachments, request.toolInvocationToken, stream, modelId, workingDirectory, token); + // Process normal chat request with the CLI session + await session.handleRequest(prompt, attachments, modelId, stream, request.toolInvocationToken, token); return {}; } + /** + * Handles the /delegate command to delegate work to a cloud agent. + * Creates a pull request with the current chat context and history. + * @param session - The current CLI session + * @param request - The chat request containing the delegate command + * @param context - The chat context + * @param stream - The response stream + * @param token - Cancellation token + */ private async handleDelegateCommand(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + // Verify cloud agent is available if (!this.cloudSessionProvider) { stream.warning(localize('copilotcli.missingCloudAgent', "No cloud agent available")); return {}; } - // Check for uncommitted changes + // Check for uncommitted changes in the workspace const currentRepository = this.gitService.activeRepository.get(); const hasChanges = (currentRepository?.changes?.indexChanges && currentRepository.changes.indexChanges.length > 0); + // Warn user if there are uncommitted changes if (hasChanges) { stream.warning(localize('copilotcli.uncommittedChanges', "You have uncommitted changes in your workspace. The cloud agent will start from the last committed state. Consider committing your changes first if you want to include them.")); } + // Summarize the conversation history to provide context to the cloud agent const history = await this.summarizer.provideChatSummary(context, token); + + // Extract the actual prompt by removing the '/delegate' prefix const prompt = request.prompt.substring('/delegate'.length).trim(); + + // Try to handle uncommitted changes scenario, or create delegated session if (!await this.cloudSessionProvider.tryHandleUncommittedChanges({ prompt: prompt, history: history, chatContext: context }, stream, token)) { + // Create a new cloud agent session (which creates a PR) const prInfo = await this.cloudSessionProvider.createDelegatedChatSession({ prompt, history, chatContext: context }, stream, token); + + // Record the delegation in the CLI session history if (prInfo) { await this.recordPushToSession(session, request.prompt, prInfo, token); } } } + /** + * Handles user confirmation responses for actions like delegating with uncommitted changes. + * @param session - The current CLI session + * @param request - The chat request with confirmation data + * @param context - The chat context + * @param stream - The response stream + * @param token - Cancellation token + * @returns A chat result + */ private async handleConfirmationData(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + // Collect all confirmation results (both accepted and rejected) const results: ConfirmationResult[] = []; + + // Add accepted confirmations results.push(...(request.acceptedConfirmationData?.map(data => ({ step: data.step, accepted: true, metadata: data?.metadata })) ?? [])); + + // Add rejected confirmations (excluding duplicates already in accepted) results.push(...((request.rejectedConfirmationData ?? []).filter(data => !results.some(r => r.step === data.step)).map(data => ({ step: data.step, accepted: false, metadata: data?.metadata })))); + // Process each confirmation result for (const data of results) { switch (data.step) { case 'uncommitted-changes': { + // Handle response to uncommitted changes confirmation if (!data.accepted || !data.metadata) { stream.markdown(vscode.l10n.t('Cloud agent delegation request cancelled.')); return {}; } + + // User accepted - proceed with delegation const prInfo = await this.cloudSessionProvider?.createDelegatedChatSession({ prompt: data.metadata.prompt, history: data.metadata.history, chatContext: context }, stream, token); + + // Record the delegation in session history if (prInfo) { await this.recordPushToSession(session, request.prompt, prInfo, token); } return {}; } default: + // Warn about unknown confirmation steps stream.warning(`Unknown confirmation step: ${data.step}\n\n`); break; } @@ -442,6 +668,15 @@ export class CopilotCLIChatSessionParticipant { return {}; } + /** + * Handles pushing chat content to a new CLI session when invoked without session context. + * Creates a new session and opens it with the current prompt and history. + * @param request - The chat request + * @param context - The chat context + * @param stream - The response stream + * @param token - Cancellation token + * @returns A chat result or void + */ private async handlePushConfirmationData( request: vscode.ChatRequest, context: vscode.ChatContext, @@ -449,16 +684,32 @@ export class CopilotCLIChatSessionParticipant { token: vscode.CancellationToken ): Promise { const prompt = request.prompt; + + // Get conversation history if available, or summarize the context const history = context.chatSummary?.history ?? await this.summarizer.provideChatSummary(context, token); + // Combine prompt with history summary for context const requestPrompt = history ? `${prompt}\n**Summary**\n${history}` : prompt; - const session = await this.sessionService.createSession(requestPrompt, undefined, token); + + // Create a new CLI session with the combined prompt + const session = await this.sessionService.createSession(requestPrompt, undefined, undefined, token); + // Open the new CLI session in the UI await vscode.commands.executeCommand('vscode.open', SessionIdForCLI.getResource(session.sessionId)); + + // Submit the prompt to the newly opened session await vscode.commands.executeCommand('workbench.action.chat.submit', { inputValue: requestPrompt }); return {}; } + /** + * Records a delegation to cloud agent in the session history. + * Adds user and assistant messages with PR metadata. + * @param session - The CLI session to update + * @param userPrompt - The user's original prompt + * @param prInfo - Information about the created pull request + * @param token - Cancellation token + */ private async recordPushToSession( session: ICopilotCLISession, userPrompt: string, @@ -474,23 +725,39 @@ export class CopilotCLIChatSessionParticipant { } } +/** + * Registers VS Code commands for Copilot CLI chat sessions. + * Includes commands for refreshing, deleting, and managing CLI sessions and terminals. + * @param copilotcliSessionItemProvider - The session item provider + * @param copilotCLISessionService - The session service + * @returns A disposable for unregistering all commands + */ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCLIChatSessionItemProvider, copilotCLISessionService: ICopilotCLISessionService): IDisposable { const disposableStore = new DisposableStore(); + + // Register refresh command (legacy namespace) disposableStore.add(vscode.commands.registerCommand('github.copilot.copilotcli.sessions.refresh', () => { copilotcliSessionItemProvider.refresh(); })); + + // Register refresh command (current namespace) disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.refresh', () => { copilotcliSessionItemProvider.refresh(); })); + + // Register delete session command with confirmation dialog disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => { if (sessionItem?.resource) { const deleteLabel = l10n.t('Delete'); + + // Show confirmation dialog before deleting const result = await vscode.window.showWarningMessage( l10n.t('Are you sure you want to delete the session?'), { modal: true }, deleteLabel ); + // Proceed with deletion if user confirmed if (result === deleteLabel) { const id = SessionIdForCLI.parse(sessionItem.resource); await copilotCLISessionService.deleteSession(id); @@ -498,14 +765,18 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL } } })); + + // Register command to resume session in terminal disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.resumeInTerminal', async (sessionItem?: vscode.ChatSessionItem) => { if (sessionItem?.resource) { await copilotcliSessionItemProvider.resumeCopilotCLISessionInTerminal(sessionItem); } })); + // Register command to create a new terminal session disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.newTerminalSession', async () => { await copilotcliSessionItemProvider.createCopilotCLITerminal(); })); + return disposableStore; } \ No newline at end of file