From a8403458220299793a5437dd8100864563ca37b1 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 30 Oct 2025 11:01:41 +1100 Subject: [PATCH 1/8] Update to latest version of copilit SDK --- package-lock.json | 501 +----------------- package.json | 2 +- .../agents/copilotcli/node/copilotCli.ts | 198 +++++++ .../copilotcli/node/copilotcliAgentManager.ts | 266 ---------- .../copilotcli/node/copilotcliSession.ts | 193 +++++++ .../node/copilotcliSessionService.ts | 371 ++++++++----- .../node/copilotcliToolInvocationFormatter.ts | 40 +- .../agents/copilotcli/node/nodePtyShim.ts | 66 +-- .../test/copilotCliSessionService.spec.ts | 328 ++++++++++++ .../chatSessions/vscode-node/chatSessions.ts | 27 +- .../copilotCLIChatSessionsContribution.ts | 272 ++++------ ...copilotCLIChatSessionsContribution.spec.ts | 473 +++++++++++++++++ 12 files changed, 1607 insertions(+), 1130 deletions(-) create mode 100644 src/extension/agents/copilotcli/node/copilotCli.ts delete mode 100644 src/extension/agents/copilotcli/node/copilotcliAgentManager.ts create mode 100644 src/extension/agents/copilotcli/node/copilotcliSession.ts create mode 100644 src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts create mode 100644 src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionsContribution.spec.ts diff --git a/package-lock.json b/package-lock.json index f8c35ceba0..4f0cdeaad2 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.63.0", - "@github/copilot": "^0.0.343", + "@github/copilot": "^0.0.353", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -718,6 +718,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": { @@ -3061,13 +3062,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.353", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.353.tgz", + "integrity": "sha512-OYgCB4Jf7Y/Wor8mNNQcXEt1m1koYm/WwjGsr5mwABSVYXArWUeEfXqVbx+7O87ld5b+aWy2Zaa2bzKV8dmqaw==", + "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "index.js" }, @@ -3225,15 +3223,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", @@ -3342,38 +3331,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", @@ -3390,38 +3347,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", @@ -3466,50 +3391,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", @@ -3532,107 +3413,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", @@ -9062,6 +8842,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" @@ -14005,23 +13786,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", @@ -16297,257 +16061,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 93d069bd21..e30743dcba 100644 --- a/package.json +++ b/package.json @@ -4405,7 +4405,7 @@ "dependencies": { "@anthropic-ai/claude-code": "^1.0.120", "@anthropic-ai/sdk": "^0.63.0", - "@github/copilot": "^0.0.343", + "@github/copilot": "^0.0.353", "@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 new file mode 100644 index 0000000000..aca018d7d5 --- /dev/null +++ b/src/extension/agents/copilotcli/node/copilotCli.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ModelProvider, 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; +} + +export interface ICopilotCLIModels { + _serviceBrand: undefined; + toModelProvider(modelId: string): ModelProvider; + getDefaultModel(): Promise; + setDefaultModel(model: ChatSessionProviderOptionItem): Promise; + getAvailableModels(): Promise; +} + +export const ICopilotCLIModels = createServiceIdentifier('ICopilotCLIModels'); + +export class CopilotCLIModels implements ICopilotCLIModels { + declare _serviceBrand: undefined; + private readonly _availableModels: Lazy>; + constructor( + @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); + } + 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 preferredModelId = this.extensionContext.globalState.get(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id); + + return models.find(m => m.id === preferredModelId) ?? defaultModel; + } + + public async setDefaultModel(model: ChatSessionProviderOptionItem): Promise { + await this.extensionContext.globalState.update(COPILOT_CLI_MODEL_MEMENTO_KEY, model.id); + } + + public async getAvailableModels(): Promise { + // No need to query sdk multiple times, cache the result, this cannot change during a vscode session. + return this._availableModels.value; + } + + private async _getAvailableModels(): Promise { + return [{ + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5' + }, + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4' + }, + { + id: 'gpt-5', + name: 'GPT-5' + }]; + } +} + +export class CopilotCLISessionOptionsService { + 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.getCopilotToken(); + const workingDirectory = await this.getWorkspaceFolderPath(); + const allOptions: SessionOptions = { + copilotToken: copilotToken.token, + env: { + ...process.env, + COPILOTCLI_DISABLE_NONESSENTIAL_TRAFFIC: '1' + }, + logger: getCopilotLogger(this.logService), + requestPermission: async (permissionRequest) => { + return await permissionHandler.getPermissions(permissionRequest); + }, + ...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; + } +} + +/** + * Service interface to abstract dynamic import of the Copilot CLI SDK for easier unit testing. + * Tests can provide a mock implementation returning a stubbed SDK shape. + */ +export interface ICopilotCLISDK { + readonly _serviceBrand: undefined; + getPackage(): Promise; +} + +export const ICopilotCLISDK = createServiceIdentifier('ICopilotCLISDK'); + +export class CopilotCLISDK implements ICopilotCLISDK { + declare _serviceBrand: undefined; + + constructor( + @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @IEnvService private readonly envService: IEnvService, + @ILogService private readonly logService: ILogService, + ) { } + + 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, this.logService); + return await import('@github/copilot/sdk'); + } catch (error) { + this.logService.error(`[CopilotCLISDK] Failed to load @github/copilot/sdk: ${error}`); + throw error; + } + } +} + +/** + * 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/copilotcliAgentManager.ts b/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts deleted file mode 100644 index 04faed6ea0..0000000000 --- a/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts +++ /dev/null @@ -1,266 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * 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 * as vscode 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 { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { Disposable } from '../../../../util/vs/base/common/lifecycle'; -import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseThinkingProgressPart, LanguageModelTextPart } from '../../../../vscodeTypes'; -import { IToolsService } from '../../../tools/common/toolsService'; -import { ExternalEditTracker } from '../../common/externalEditTracker'; -import { getAffectedUrisForEditTool } from '../common/copilotcliTools'; -import { CopilotCLIPromptResolver } from './copilotcliPromptResolver'; -import { ICopilotCLISessionService } from './copilotcliSessionService'; -import { processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; -import { getCopilotLogger } from './logger'; -import { ensureNodePtyShim } from './nodePtyShim'; -import { getConfirmationToolParams, PermissionRequest } from './permissionHelpers'; - -export class CopilotCLIAgentManager extends Disposable { - constructor( - private readonly promptResolver: CopilotCLIPromptResolver, - @ILogService private readonly logService: ILogService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, - ) { - super(); - } - - /** - * Find session by SDK session ID - */ - public findSession(sessionId: string): CopilotCLISession | undefined { - return this.sessionService.findSessionWrapper(sessionId); - } - - async handleRequest( - copilotcliSessionId: string | undefined, - request: vscode.ChatRequest, - context: vscode.ChatContext, - stream: vscode.ChatResponseStream, - modelId: ModelProvider | undefined, - token: vscode.CancellationToken - ): Promise<{ copilotcliSessionId: string | undefined }> { - const isNewSession = !copilotcliSessionId; - const sessionIdForLog = copilotcliSessionId ?? 'new'; - this.logService.trace(`[CopilotCLIAgentManager] Handling request for sessionId=${sessionIdForLog}.`); - - const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, token); - // Check if we already have a session wrapper - let session = copilotcliSessionId ? this.sessionService.findSessionWrapper(copilotcliSessionId) : undefined; - - if (session) { - this.logService.trace(`[CopilotCLIAgentManager] Reusing CopilotCLI session ${copilotcliSessionId}.`); - } else { - const sdkSession = await this.sessionService.getOrCreateSDKSession(copilotcliSessionId, prompt); - session = this.instantiationService.createInstance(CopilotCLISession, sdkSession); - this.sessionService.trackSessionWrapper(sdkSession.sessionId, session); - } - - if (isNewSession) { - this.sessionService.setPendingRequest(session.sessionId); - } - - await session.invoke(prompt, attachments, request.toolInvocationToken, stream, modelId, token); - - return { copilotcliSessionId: session.sessionId }; - } -} - -export class CopilotCLISession extends Disposable { - private _abortController = new AbortController(); - private _pendingToolInvocations = new Map(); - private _editTracker = new ExternalEditTracker(); - public readonly sessionId: string; - - constructor( - private readonly _sdkSession: Session, - @ILogService private readonly logService: ILogService, - @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IAuthenticationService private readonly _authenticationService: IAuthenticationService, - @IToolsService private readonly toolsService: IToolsService, - @IEnvService private readonly envService: IEnvService, - @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, - ) { - super(); - this.sessionId = _sdkSession.sessionId; - } - - public override dispose(): void { - this._abortController.abort(); - super.dispose(); - } - - async *query(prompt: string, attachments: Attachment[], options: AgentOptions): AsyncGenerator { - // Ensure node-pty shim exists before importing SDK - // @github/copilot has hardcoded: import{spawn}from"node-pty" - await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot); - - // Dynamically import the SDK - const { Agent } = await import('@github/copilot/sdk'); - const agent = new Agent(options); - yield* agent.query(prompt, attachments); - } - - public async invoke( - prompt: string, - attachments: Attachment[], - toolInvocationToken: vscode.ChatParticipantToolToken, - stream: vscode.ChatResponseStream, - modelId: ModelProvider | undefined, - token: vscode.CancellationToken - ): Promise { - if (this._store.isDisposed) { - throw new Error('Session disposed'); - } - - this.logService.trace(`[CopilotCLISession] Invoking session ${this.sessionId}`); - const copilotToken = await this._authenticationService.getCopilotToken(); - - const options: AgentOptions = { - modelProvider: modelId ?? { - type: 'anthropic', - model: 'claude-sonnet-4.5', - }, - abortController: this._abortController, - // TODO@rebornix handle workspace properly - workingDirectory: this.workspaceService.getWorkspaceFolders().at(0)?.fsPath, - 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); - } - ], - postToolUse: [ - async (input: PostToolUseHookInput) => { - const editKey = getEditOperationKey(input.toolName, input.toolArgs); - await this._onDidEditTool(editKey); - } - ] - } - }; - - try { - for await (const event of this.query(prompt, attachments, options)) { - if (token.isCancellationRequested) { - break; - } - - this._processEvent(event, stream, toolInvocationToken); - } - } catch (error) { - this.logService.error(`CopilotCLI session error: ${error}`); - stream.markdown(`\n\n❌ Error: ${error instanceof Error ? error.message : String(error)}`); - } - } - - 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': { - const responsePart = processToolExecutionStart(event, this._toolNames, this._pendingToolInvocations); - const toolName = this._toolNames.get(event.data.toolCallId); - if (responsePart instanceof ChatResponseThinkingProgressPart) { - stream.push(responsePart); - } - this.logService.trace(`Start Tool ${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, - toolInvocationToken: vscode.ChatParticipantToolToken - ): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> { - try { - const { tool, input } = getConfirmationToolParams(permissionRequest); - const result = await this.toolsService.invokeTool(tool, - { input, toolInvocationToken }, - CancellationToken.None); - - const firstResultPart = result.content.at(0); - if (firstResultPart instanceof LanguageModelTextPart && firstResultPart.value === 'yes') { - return { kind: 'approved' }; - } - } catch (error) { - this.logService.error(`[CopilotCLISession] Permission request error: ${error}`); - } - - 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/copilotcliSession.ts b/src/extension/agents/copilotcli/node/copilotcliSession.ts new file mode 100644 index 0000000000..5f644cc3c1 --- /dev/null +++ b/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Attachment, ModelProvider, Session, SessionOptions } from '@github/copilot/sdk'; +import type * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { CancellationError } from '../../../../util/vs/base/common/errors'; +import { DisposableStore, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { ChatRequestTurn2, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatSessionStatus, EventEmitter, LanguageModelTextPart, Uri } from '../../../../vscodeTypes'; +import { IToolsService } from '../../../tools/common/toolsService'; +import { CopilotCLIPermissionsHandler } from './copilotCli'; +import { buildChatHistoryFromEvents, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; +import { getConfirmationToolParams } from './permissionHelpers'; + +export interface ICopilotCLISession { + readonly sessionId: string; + readonly status: vscode.ChatSessionStatus | undefined; + readonly onDidChangeStatus: vscode.Event; + + handleRequest( + prompt: string, + attachments: Attachment[], + toolInvocationToken: vscode.ChatParticipantToolToken, + stream: vscode.ChatResponseStream, + modelId: ModelProvider | undefined, + token: vscode.CancellationToken + ): Promise; + + addUserMessage(content: string): void; + addUserAssistantMessage(content: string): void; + getSelectedModelId(): Promise; + getChatHistory(): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]>; +} + +export class CopilotCLISession extends DisposableStore implements ICopilotCLISession { + private _pendingToolInvocations = new Map(); + public get sessionId(): string { + return this._session.sessionId; + } + + private _status?: vscode.ChatSessionStatus; + public get status(): vscode.ChatSessionStatus | undefined { + return this._status; + } + private readonly _statusChange = this.add(new EventEmitter()); + + public readonly onDidChangeStatus = this._statusChange.event; + + constructor( + private readonly _permissionHandler: CopilotCLIPermissionsHandler, + private readonly _session: Session, + @ILogService private readonly _logService: ILogService, + @IToolsService private readonly toolsService: IToolsService, + @IWorkspaceService private readonly _workspaceService: IWorkspaceService + ) { + super(); + } + + public async handleRequest( + prompt: string, + attachments: Attachment[], + toolInvocationToken: vscode.ChatParticipantToolToken, + stream: vscode.ChatResponseStream, + modelId: ModelProvider | undefined, + token: vscode.CancellationToken + ): Promise { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const disposables = this.add(new DisposableStore()); + + if (modelId) { + console.log(this._session.messageCount); + const currentModel = await this._session.getSelectedModel(); + if (currentModel !== modelId.model) { + await this._session.setSelectedModel(modelId.model); + } + } + + disposables.add(token.onCancellationRequested(() => this._session.abort())); + disposables.add(this._permissionHandler.onDidRequestPermissions(async (permissionRequest) => { + return await this.requestPermission(permissionRequest, toolInvocationToken); + })); + + const toolNames = new Map(); + + disposables.add(toDisposable(this._session.on('*', (event) => this._logService.trace(`CopilotCLI Event: ${JSON.stringify(event, null, 2)}`)))); + disposables.add(toDisposable(this._session.on('assistant.turn_start', () => toolNames.clear()))); + disposables.add(toDisposable(this._session.on('assistant.turn_end', () => toolNames.clear()))); + disposables.add(toDisposable(this._session.on('assistant.message', (event) => { + if (typeof event.data.content === 'string' && event.data.content.length) { + stream.markdown(event.data.content); + } + }))); + disposables.add(toDisposable(this._session.on('tool.execution_start', (event) => { + const responsePart = processToolExecutionStart(event, toolNames, this._pendingToolInvocations); + const toolName = toolNames.get(event.data.toolCallId); + if (responsePart instanceof ChatResponseThinkingProgressPart) { + stream.push(responsePart); + } + this._logService.trace(`Start Tool ${toolName || ''}`); + }))); + disposables.add(toDisposable(this._session.on('tool.execution_complete', (event) => { + const responsePart = processToolExecutionComplete(event, this._pendingToolInvocations); + if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) { + stream.push(responsePart); + } + + 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(`Complete Tool ${toolName}, ${parts}`); + }))); + disposables.add(toDisposable(this._session.on('session.error', (event) => { + this._logService.error(`CopilotCLI error: (${event.data.errorType}), ${event.data.message}`); + stream.markdown(`\n\n❌ Error: (${event.data.errorType}) ${event.data.message}`); + }))); + + try { + this._logService.trace(`[CopilotCLISession] Invoking session ${this._session.sessionId}`); + this._status = ChatSessionStatus.InProgress; + this._statusChange.fire(this._status); + + await this._session.send({ prompt, attachments }); + + this._status = ChatSessionStatus.Completed; + } catch (error) { + this._status = ChatSessionStatus.Failed; + throw error; + } finally { + this._statusChange.fire(this._status); + disposables.dispose(); + } + } + + addUserMessage(content: string) { + this._session.emit('user.message', { content }); + } + + addUserAssistantMessage(content: string) { + this._session.emit('assistant.message', { + messageId: `msg_${Date.now()}`, + content + }); + } + + public getSelectedModelId() { + return this._session.getSelectedModel(); + } + + public async getChatHistory(): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]> { + const events = await this._session.getEvents(); + return buildChatHistoryFromEvents(events); + } + + private async requestPermission( + permissionRequest: Parameters>[0], + toolInvocationToken: vscode.ChatParticipantToolToken + ): Promise>> { + if (permissionRequest.kind === 'read') { + // If user is reading a file in the workspace, auto-approve read requests. + // Outisde workspace reads (e.g., /etc/passwd) will still require approval. + const data = Uri.file(permissionRequest.path); + if (this._workspaceService.getWorkspaceFolder(data)) { + this._logService.trace(`[CopilotCLISession] Auto Approving request to read workspace file ${permissionRequest.path}`); + return { kind: 'approved' }; + } + } + + try { + const { tool, input } = getConfirmationToolParams(permissionRequest as Record); + const result = await this.toolsService.invokeTool(tool, + { input, toolInvocationToken }, + CancellationToken.None + ); + + const firstResultPart = result.content.at(0); + if (firstResultPart instanceof LanguageModelTextPart && firstResultPart.value === 'yes') { + return { kind: 'approved' }; + } + } catch (error) { + this._logService.error(`[CopilotCLISession] Permission request error: ${error}`); + } + + return { kind: 'denied-interactively-by-user' }; + } +} diff --git a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts index faf0f1a441..557c86c2fe 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts @@ -3,117 +3,134 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Session, SessionManager } from '@github/copilot/sdk'; +import type { internal, ModelProvider, Session, SessionManager, SessionManagerOptions } from '@github/copilot/sdk'; import { ChatCompletionMessageParam } from 'openai/resources/chat/completions'; -import type { CancellationToken, ChatRequest, ChatSessionStatus } from 'vscode'; -import { IEnvService } from '../../../../platform/env/common/envService'; -import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; +import type { CancellationToken } from 'vscode'; import { ILogService } from '../../../../platform/log/common/logService'; import { createServiceIdentifier } from '../../../../util/common/services'; +import { coalesce } from '../../../../util/vs/base/common/arrays'; +import { disposableTimeout, raceCancellationError } from '../../../../util/vs/base/common/async'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; -import { DisposableMap, IDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { createSingleCallFunction } from '../../../../util/vs/base/common/functional'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatSessionStatus } from '../../../../vscodeTypes'; +import { CopilotCLIPermissionsHandler, CopilotCLISessionOptionsService, ICopilotCLISDK } from './copilotCli'; +import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession'; import { stripReminders } from './copilotcliToolInvocationFormatter'; import { getCopilotLogger } from './logger'; -import { ensureNodePtyShim } from './nodePtyShim'; -import { coalesce } from '../../../../util/vs/base/common/arrays'; -export interface ICopilotCLISession { +export interface ICopilotCLISessionItem { readonly id: string; - readonly sdkSession: Session; readonly label: string; readonly isEmpty: boolean; readonly timestamp: Date; + readonly status?: ChatSessionStatus; } -export type ExtendedChatRequest = ChatRequest & { prompt: string }; - export interface ICopilotCLISessionService { readonly _serviceBrand: undefined; onDidChangeSessions: Event; // Session metadata querying - getAllSessions(token: CancellationToken): Promise; - getSession(sessionId: string, token: CancellationToken): Promise; + getAllSessions(token: CancellationToken): Promise; // SDK session management - getSessionManager(): Promise; - getOrCreateSDKSession(sessionId: string | undefined, prompt: string): Promise; - deleteSession(sessionId: string): Promise; - setSessionStatus(sessionId: string, status: ChatSessionStatus): void; - getSessionStatus(sessionId: string): ChatSessionStatus | undefined; - - // Session wrapper tracking - trackSessionWrapper(sessionId: string, wrapper: T): void; - findSessionWrapper(sessionId: string): T | undefined; - - // Pending request tracking (for untitled sessions) - setPendingRequest(sessionId: string): void; - isPendingRequest(sessionId: string): boolean; - clearPendingRequest(sessionId: string): void; + getSession(sessionId: string, model: ModelProvider | undefined, readonly: boolean, token: CancellationToken): Promise; + createSession(prompt: string, model: ModelProvider | undefined, token: CancellationToken): Promise; + deleteSession(sessionId: string): Promise; } export const ICopilotCLISessionService = createServiceIdentifier('ICopilotCLISessionService'); -export class CopilotCLISessionService implements ICopilotCLISessionService { +const SESSION_SHUTDOWN_TIMEOUT_MS = 30 * 1000; + + +export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService { declare _serviceBrand: undefined; - private _sessionManager: SessionManager | undefined; - private _sessionWrappers = new DisposableMap(); - private _sessions = new Map(); - private _pendingRequests = new Set(); + _sessionManager: internal.CLISessionManager | undefined; + private _sessionWrappers = new DisposableMap(); + private _newActiveSessions = new Map(); private readonly _onDidChangeSessions = new Emitter(); public readonly onDidChangeSessions = this._onDidChangeSessions.event; - private readonly _sessionStatuses = new Map(); + private readonly _optionsService: CopilotCLISessionOptionsService; + + /** + * 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. + */ + private readonly sessionTerminators = new DisposableMap(); constructor( @ILogService private readonly logService: ILogService, - @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, - @IEnvService private readonly envService: IEnvService, - ) { } - - public async getSessionManager(): Promise { - if (!this._sessionManager) { - try { - // Ensure node-pty shim exists before importing SDK - // @github/copilot has hardcoded: import{spawn}from"node-pty" - await ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot); - - const { internal } = await import('@github/copilot/sdk'); - this._sessionManager = new internal.CLISessionManager({ - logger: getCopilotLogger(this.logService) - }); - } catch (error) { - this.logService.error(`Failed to initialize SessionManager: ${error}`); - throw error; - } + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ICopilotCLISDK private readonly copilotCliSdk: ICopilotCLISDK, + ) { + super(); + this._register(this._sessionWrappers); + this._register(this._onDidChangeSessions); + this._optionsService = instantiationService.createInstance(CopilotCLISessionOptionsService); + } + + public async getSessionManager(options: SessionManagerOptions = {}): Promise { + try { + const { internal } = await this.copilotCliSdk.getPackage(); + return new internal.CLISessionManager({ + ...options, + logger: getCopilotLogger(this.logService) + }); + } catch (error) { + this.logService.error(`Failed to initialize SessionManager: ${error}`); + throw error; + } + } + + private _getAllSessionsProgress: Promise | undefined; + async getAllSessions(token: CancellationToken): Promise { + if (!this._getAllSessionsProgress) { + this._getAllSessionsProgress = this._getAllSessions(token); } - return this._sessionManager; + return this._getAllSessionsProgress.finally(() => { + this._getAllSessionsProgress = undefined; + }); } - async getAllSessions(token: CancellationToken): Promise { + async _getAllSessions(token: CancellationToken): Promise { try { - const sessionManager = await this.getSessionManager(); - const sessionMetadataList = await sessionManager.listSessions(); + const sessionManager = await raceCancellationError(this.getSessionManager(), token); + const sessionMetadataList = await raceCancellationError(sessionManager.listSessions(), token); // Convert SessionMetadata to ICopilotCLISession - const diskSessions: ICopilotCLISession[] = coalesce(await Promise.all( + const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all( sessionMetadataList.map(async (metadata) => { + if (this._newActiveSessions.has(metadata.sessionId)) { + // This is a new session not yet persisted to disk by SDK + return undefined; + } + + let dispose: (() => Promise) | undefined = undefined; + let session: Session | undefined = undefined; try { // Get the full session to access chat messages - const sdkSession = await sessionManager.getSession(metadata.sessionId); - if (!sdkSession) { + ({ session, dispose } = (await this.getReadonlySdkSession(metadata.sessionId, token)) || {}); + if (!session) { this.logService.warn(`Copilot CLI session not found, ${metadata.sessionId}`); return; } - const chatMessages = await sdkSession.getChatMessages(); + const chatMessages = await raceCancellationError(session.getChatMessages(), token); const noUserMessages = !chatMessages.find(message => message.role === 'user'); - const label = await this._generateSessionLabel(sdkSession.sessionId, chatMessages, undefined); + const label = this._generateSessionLabel(session.sessionId, chatMessages as any, undefined); // Get timestamp from last SDK event, or fallback to metadata.startTime - const sdkEvents = sdkSession.getEvents(); + const sdkEvents = session.getEvents(); const lastEventWithTimestamp = [...sdkEvents].reverse().find(event => event.type !== 'session.import_legacy' && event.type !== 'session.start' @@ -123,118 +140,196 @@ export class CopilotCLISessionService implements ICopilotCLISessionService { ? new Date(lastEventWithTimestamp.timestamp) : metadata.startTime; - return { + const info: ICopilotCLISessionItem = { id: metadata.sessionId, - sdkSession, label, timestamp, isEmpty: noUserMessages }; + return info; } catch (error) { this.logService.warn(`Failed to load session ${metadata.sessionId}: ${error}`); + } finally { + await dispose?.(); } }) )); - // Merge with cached sessions (new sessions not yet persisted by SDK) - const diskSessionIds = new Set(diskSessions.map(s => s.id)); - const cachedSessions = Array.from(this._sessions.values()).filter(s => !diskSessionIds.has(s.id)); - const allSessions = [...diskSessions, ...cachedSessions]; + // Merge new sessions (not yet persisted by SDK) + const allSessions = diskSessions + .filter(session => !this._newActiveSessions.has(session.id) && !session.isEmpty) + .concat(Array.from(this._newActiveSessions.values())) + .map(session => { + return { + ...session, + status: this._sessionWrappers.get(session.id)?.status + } satisfies ICopilotCLISessionItem; + }); return allSessions; } catch (error) { this.logService.error(`Failed to get all sessions: ${error}`); - return Array.from(this._sessions.values()); + return coalesce(Array.from(this._newActiveSessions.values())); } } - async getSession(sessionId: string, token: CancellationToken): Promise { - const cached = this._sessions.get(sessionId); - if (cached) { - return cached; + 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, false); + if (!session) { + return undefined; } - - // Fall back to querying all sessions - const all = await this.getAllSessions(token); - return all.find(session => session.id === sessionId); + const dispose = createSingleCallFunction(async () => await sessionManager.closeSession(session).catch(() => { })); + if (token.isCancellationRequested) { + await dispose(); + return; + } + return { session, dispose }; } - public async getOrCreateSDKSession(sessionId: string | undefined, prompt: string): Promise { - const sessionManager = await this.getSessionManager(); - - if (sessionId) { - if (this._sessions.has(sessionId)) { - return this._sessions.get(sessionId)!.sdkSession; - } - - try { - const sdkSession = await sessionManager.getSession(sessionId); - - if (sdkSession) { - return sdkSession; + public async createSession(prompt: string, model: ModelProvider | undefined, token: CancellationToken): Promise { + const sessionDisposables = this._register(new DisposableStore()); + const modelProvider = model ?? { + type: 'anthropic', + model: 'claude-sonnet-4.5', + }; + const permissionHandler = sessionDisposables.add(new CopilotCLIPermissionsHandler()); + try { + const options = await raceCancellationError(this._optionsService.createOptions({ modelProvider }, permissionHandler), token); + const sessionManager = await raceCancellationError(this.getSessionManager(options), token); + + const sdkSession = await sessionManager.createSession(model ? { selectedModel: modelProvider.model } : undefined); + const chatMessages = await sdkSession.getChatMessages(); + const noUserMessages = !chatMessages.find(message => message.role === 'user'); + const label = this._generateSessionLabel(sdkSession.sessionId, chatMessages as any, prompt); + const newSession: ICopilotCLISessionItem = { + id: sdkSession.sessionId, + label, + timestamp: sdkSession.startTime, + isEmpty: noUserMessages + }; + this._newActiveSessions.set(sdkSession.sessionId, newSession); + this.logService.trace(`[CopilotCLIAgentManager] Created new CopilotCLI session ${sdkSession.sessionId}.`); + + sessionDisposables.add(toDisposable(() => this._newActiveSessions.delete(sdkSession.sessionId))); + + const session = await this.createCopilotSession(sdkSession, sessionManager, permissionHandler, sessionDisposables); + + sessionDisposables.add(session.onDidChangeStatus(() => { + if (session.status === ChatSessionStatus.Completed || session.status === ChatSessionStatus.Failed) { + this._newActiveSessions.delete(sdkSession.sessionId); } - } catch (error) { - // Fall through to create new session - } + })); + return session; + } catch (error) { + sessionDisposables.dispose(); + throw error; } + } - const sdkSession = await sessionManager.createSession(); - - // Cache the new session immediately - const chatMessages = await sdkSession.getChatMessages(); - const noUserMessages = !chatMessages.find(message => message.role === 'user'); - const label = await this._generateSessionLabel(sdkSession.sessionId, chatMessages, prompt); - const newSession: ICopilotCLISession = { - id: sdkSession.sessionId, - sdkSession, - label, - timestamp: sdkSession.startTime, - isEmpty: noUserMessages + public async getSession(sessionId: string, model: ModelProvider | undefined, readonly: boolean, token: CancellationToken): Promise { + this.sessionTerminators.deleteAndDispose(sessionId); + const session = this._sessionWrappers.get(sessionId); + const modelProvider = model ?? { + type: 'anthropic', + model: 'claude-sonnet-4.5', }; - this._sessions.set(sdkSession.sessionId, newSession); + if (session) { + this.logService.trace(`[CopilotCLIAgentManager] Reusing CopilotCLI session ${sessionId}.`); + return session; + } - return sdkSession; - } + const sessionDisposables = this._register(new DisposableStore()); + try { + const permissionHandler = sessionDisposables.add(new CopilotCLIPermissionsHandler()); + const options = await raceCancellationError(this._optionsService.createOptions({ modelProvider: modelProvider }, permissionHandler), token); + const sessionManager = await raceCancellationError(this.getSessionManager(options), token); + + const sdkSession = await sessionManager.getSession(sessionId, !readonly); + if (!sdkSession) { + this.logService.error(`[CopilotCLIAgentManager] CopilotCLI failed to get session ${sessionId}.`); + sessionDisposables.dispose(); + return undefined; + } - public setSessionStatus(sessionId: string, status: ChatSessionStatus): void { - this._sessionStatuses.set(sessionId, status); - this._onDidChangeSessions.fire(); + return this.createCopilotSession(sdkSession, sessionManager, permissionHandler, sessionDisposables); + } catch (error) { + sessionDisposables.dispose(); + throw error; + } } - public getSessionStatus(sessionId: string): ChatSessionStatus | undefined { - return this._sessionStatuses.get(sessionId); - } + private async createCopilotSession(sdkSession: Session, sessionManager: SessionManager, permissionHandler: CopilotCLIPermissionsHandler, disposables: IDisposable,): Promise { + this.sessionTerminators.deleteAndDispose(sdkSession.sessionId); + const sessionDisposables = this._register(new DisposableStore()); + sessionDisposables.add(disposables); + try { + sessionDisposables.add(toDisposable(() => { + this._sessionWrappers.deleteAndLeak(sdkSession.sessionId); + sdkSession.abort(); + sessionManager.closeSession(sdkSession); + })); + + const session = this.instantiationService.createInstance(CopilotCLISession, permissionHandler, sdkSession); + 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); + } + })); - public trackSessionWrapper(sessionId: string, wrapper: T): void { - this._sessionWrappers.set(sessionId, wrapper); + this._sessionWrappers.set(sdkSession.sessionId, session); + return session; + } catch (error) { + sessionDisposables.dispose(); + throw error; + } } - public findSessionWrapper(sessionId: string): T | undefined { - return this._sessionWrappers.get(sessionId) as T | undefined; - } - public async deleteSession(sessionId: string): Promise { + public async deleteSession(sessionId: string): Promise { try { - // Delete from session manager first - const sessionManager = await this.getSessionManager(); - const sdkSession = await sessionManager.getSession(sessionId); - if (sdkSession) { - await sessionManager.deleteSession(sdkSession); + { + const session = this._sessionWrappers.get(sessionId); + if (session) { + session.dispose(); + this.logService.warn(`Delete an active session ${sessionId}.`); + } } - // Clean up local caches - this._sessions.delete(sessionId); - this._sessionWrappers.deleteAndDispose(sessionId); - this._onDidChangeSessions.fire(); + const sessionManager = await this.getSessionManager(); + const session = await sessionManager.getSession(sessionId, false); + if (session) { + await sessionManager.deleteSession(session); + } - return true; } catch (error) { this.logService.error(`Failed to delete session ${sessionId}: ${error}`); - return false; + } finally { + this._newActiveSessions.delete(sessionId); + this._sessionWrappers.deleteAndLeak(sessionId); + this.sessionTerminators.deleteAndLeak(sessionId); + // Possible the session was deleted in another vscode session or the like. + this._onDidChangeSessions.fire(); } } - private async _generateSessionLabel(sessionId: string, chatMessages: readonly ChatCompletionMessageParam[], prompt: string | undefined): Promise { + private _generateSessionLabel(sessionId: string, chatMessages: readonly ChatCompletionMessageParam[], prompt: string | undefined): string { try { // Find the first user message const firstUserMessage = chatMessages.find(msg => msg.role === 'user'); @@ -265,15 +360,5 @@ export class CopilotCLISessionService implements ICopilotCLISessionService { return `Session ${sessionId.slice(0, 8)}`; } - public setPendingRequest(sessionId: string): void { - this._pendingRequests.add(sessionId); - } - - public isPendingRequest(sessionId: string): boolean { - return this._pendingRequests.has(sessionId); - } - - public clearPendingRequest(sessionId: string): void { - this._pendingRequests.delete(sessionId); - } } + diff --git a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts index c6a5cef7a4..cd80b07fa4 100644 --- a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts +++ b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts @@ -15,6 +15,8 @@ import { isCopilotCliEditToolCall } from '../common/copilotcliTools'; */ export const enum CopilotCLIToolNames { StrReplaceEditor = 'str_replace_editor', + edit = 'edit', + create = 'create', View = 'view', Bash = 'bash', Think = 'think', @@ -35,6 +37,22 @@ interface StrReplaceEditorArgs { file_text?: string; } +interface CreateArgs { + path: string; + file_text?: string; +} + +interface ViewArgs { + path: string; + view_range?: [number, number]; +} + +interface EditArgs { + path: string; + old_str?: string; + new_str?: string; +} + interface BashArgs { command: string; description?: string; @@ -217,10 +235,14 @@ export function createCopilotCLIToolInvocation( // Format based on tool name if (toolName === CopilotCLIToolNames.StrReplaceEditor) { formatStrReplaceEditorInvocation(invocation, args as StrReplaceEditorArgs); + } else if (toolName === CopilotCLIToolNames.edit) { + formatEditInvocation(invocation, args as EditArgs); + } else if (toolName === CopilotCLIToolNames.create) { + formatCreateInvocation(invocation, args as CreateArgs); } else if (toolName === CopilotCLIToolNames.Bash) { formatBashInvocation(invocation, args as BashArgs); } else if (toolName === CopilotCLIToolNames.View) { - formatViewToolInvocation(invocation, args as StrReplaceEditorArgs); + formatViewToolInvocation(invocation, args as ViewArgs); } else { formatGenericInvocation(invocation, toolName, args); } @@ -228,7 +250,7 @@ export function createCopilotCLIToolInvocation( return invocation; } -function formatViewToolInvocation(invocation: ChatToolInvocationPart, args: StrReplaceEditorArgs): void { +function formatViewToolInvocation(invocation: ChatToolInvocationPart, args: ViewArgs): void { const path = args.path ?? ''; const display = path ? formatUriForMessage(path) : ''; @@ -265,6 +287,20 @@ function formatStrReplaceEditorInvocation(invocation: ChatToolInvocationPart, ar } } +function formatEditInvocation(invocation: ChatToolInvocationPart, args: EditArgs): void { + const path = args.path ?? ''; + const display = path ? formatUriForMessage(path) : ''; + + invocation.invocationMessage = new MarkdownString(l10n.t("Edited {0}", display)); +} + +function formatCreateInvocation(invocation: ChatToolInvocationPart, args: CreateArgs): void { + const path = args.path ?? ''; + const display = path ? formatUriForMessage(path) : ''; + + invocation.invocationMessage = new MarkdownString(l10n.t("Created {0}", display)); +} + function formatBashInvocation(invocation: ChatToolInvocationPart, args: BashArgs): void { const command = args.command ?? ''; const description = args.description; diff --git a/src/extension/agents/copilotcli/node/nodePtyShim.ts b/src/extension/agents/copilotcli/node/nodePtyShim.ts index 317f935bb3..b43f6f2673 100644 --- a/src/extension/agents/copilotcli/node/nodePtyShim.ts +++ b/src/extension/agents/copilotcli/node/nodePtyShim.ts @@ -5,78 +5,44 @@ import { promises as fs } from 'fs'; import * as path from 'path'; +import { ILogService } from '../../../../platform/log/common/logService'; let shimCreated: Promise | undefined = undefined; /** - * 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); + shimCreated = _ensureNodePtyShim(extensionPath, vscodeAppRoot, logService); 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'); try { - // Remove any existing node-pty (might be from other packages' dependencies) - try { - await fs.rm(nodePtyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist - } - + const files = (await fs.readdir(vscodeNodePtyPath)).map(f => path.join(vscodeNodePtyPath, f)); await fs.mkdir(nodePtyDir, { recursive: 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); - + await Promise.all(files.map(async file => { + const dest = path.join(nodePtyDir, path.basename(file)); + if ((await fs.stat(dest).then(stat => stat.isFile()).catch(() => false)) === false) { + await fs.copyFile(file, dest); + } + })); } catch (error) { - console.warn('Failed to create node-pty shim:', error); + logService.error(`Failed to create node-pty shim (vscode dir: ${vscodeNodePtyPath}, extension dir: ${nodePtyDir})`, error); throw error; } } 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 0000000000..e78b625868 --- /dev/null +++ b/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Attachment, internal, Session, SessionEvent, SessionMetadata, SessionOptions } from '@github/copilot/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as vscode from 'vscode'; +import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication'; +import type { CopilotToken } from '../../../../../platform/authentication/common/copilotToken'; +import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; +import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; +import { SyncDescriptor } from '../../../../../util/vs/platform/instantiation/common/descriptors'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatSessionStatus } from '../../../../../vscodeTypes'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { MockChatResponseStream } from '../../../../test/node/testHelpers'; +import { ICopilotCLISDK } from '../copilotCli'; +import { CopilotCLISessionService } from '../copilotcliSessionService'; + +// ----------------------------------------------------------------------------- +// Minimal Mock SDK Implementation +// ----------------------------------------------------------------------------- + +type MockEvent = { type: string; data: unknown; timestamp?: number }; + +class MockSession implements Partial { + public readonly sessionId: string; + public readonly startTime: Date = new Date(); + public messageCount = 0; + private _selectedModel: string | undefined; + private _events: MockEvent[] = []; + public _chatMessages: { role: 'user' | 'assistant'; content: string }[] = []; + private _listeners = new Map void>>(); + private _aborted = false; + private readonly _requestPermission?: SessionOptions['requestPermission']; + + constructor(id: string, model?: string, requestPermission?: SessionOptions['requestPermission']) { + this.sessionId = id; + this._selectedModel = model; + this._requestPermission = requestPermission; + this._emit('session.start', { sessionId: id }); + } + + async abort() { + this._aborted = true; + this._emit('session.error', { errorType: 'aborted', message: 'Session aborted' }); + } + + getSelectedModel() { return Promise.resolve(this._selectedModel); } + async setSelectedModel(model: string) { this._selectedModel = model; } + + async send({ prompt }: { prompt: string; attachments: Attachment[] }) { + if (this._aborted) { throw new Error('aborted'); } + this.messageCount++; + // Simulate assistant turn + this._emit('assistant.turn_start', {}); + this._chatMessages.push({ role: 'user', content: prompt }); + // Simulate a permission request when prompt contains keyword + if (this._requestPermission && prompt.includes('PERMISSION-READ')) { + await this._requestPermission({ kind: 'read', path: '/workspace/file.txt' } as any); + } + if (this._requestPermission && prompt.includes('PERMISSION-OUTSIDE')) { + await this._requestPermission({ kind: 'read', path: '/etc/passwd' } as any); + } + if (prompt.includes('FAIL')) { + throw new Error('Forced failure'); + } + this._emit('assistant.message', { messageId: `msg_${Date.now()}`, content: 'Mock response: ' + prompt }); + this._chatMessages.push({ role: 'assistant', content: 'Mock response: ' + prompt }); + this._emit('assistant.turn_end', {}); + } + + async getChatMessages() { return this._chatMessages.slice(); } + getEvents() { + return this._events.slice() as unknown as readonly SessionEvent[]; + } + + emit(type: string, data: any) { this._emit(type, data); } + + private _emit(type: string, data: any) { + const evt: MockEvent = { type, data, timestamp: Date.now() }; + this._events.push(evt); + for (const listener of this._listeners.get(type)?.values() || []) { + listener({ data }); + } + for (const anyListener of this._listeners.get('*')?.values() || []) { + anyListener(evt); + } + } + + on(type: string, handler: (e: any) => void) { + if (!this._listeners.has(type)) { this._listeners.set(type, new Set()); } + this._listeners.get(type)!.add(handler); + return () => this._listeners.get(type)!.delete(handler); + } +} + +class MockCLISessionManager implements Partial { + private static _sessions = new Map(); + private _id = 1; + private readonly _requestPermission?: SessionOptions['requestPermission']; + + constructor(opts: { logger?: any } & SessionOptions) { + this._requestPermission = opts.requestPermission; + } + + async listSessions() { + return Array.from(MockCLISessionManager._sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime })) as unknown as SessionMetadata[]; + } + + async createSession(opts?: { selectedModel?: string }) { + const id = `sess-${this._id++}`; + const session = new MockSession(id, opts?.selectedModel, this._requestPermission); + MockCLISessionManager._sessions.set(id, session); + return session as unknown as any; // Session shape + } + + async getSession(id: string, _writable: boolean) { + return MockCLISessionManager._sessions.get(id) as unknown as any; + } + + async closeSession(_session: any) { /* no-op */ } + async deleteSession(session: any) { MockCLISessionManager._sessions.delete(session.sessionId); } +} + +class MockCopilotCLISDK implements ICopilotCLISDK { + declare _serviceBrand: undefined; + async getPackage(): Promise { + // We only need the internal CLISessionManager class. + return { + internal: { CLISessionManager: MockCLISessionManager } + } as any; + } +} + +// ----------------------------------------------------------------------------- +// Mock Authentication Service (avoid env var dependency for token retrieval) +// ----------------------------------------------------------------------------- +class MockAuthenticationService implements IAuthenticationService { + _serviceBrand: undefined; + isMinimalMode = false; + onDidAuthenticationChange = (() => ({ dispose() { } })) as any; + onDidAccessTokenChange = (() => ({ dispose() { } })) as any; + onDidAdoAuthenticationChange = (() => ({ dispose() { } })) as any; + anyGitHubSession = undefined; + permissiveGitHubSession = undefined; + copilotToken = undefined; + speculativeDecodingEndpointToken = undefined; + getAnyGitHubSession(): Promise { return Promise.resolve(undefined); } + getPermissiveGitHubSession(): Promise { return Promise.resolve(undefined); } + async getCopilotToken(): Promise { + return { token: 'test-token' } as any; // Minimal shape used by session options service + } + resetCopilotToken(): void { } + getAdoAccessTokenBase64(): Promise { return Promise.resolve(undefined); } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +describe('CopilotCLISessionService', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + // Inject mock SDK and authentication service + services.define(ICopilotCLISDK, new MockCopilotCLISDK()); + services.define(IAuthenticationService, new SyncDescriptor(MockAuthenticationService)); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + }); + + afterEach(() => { + store.clear(); + vi.useRealTimers(); + }); + + it('creates a new session and streams assistant output', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Hello CLI', undefined, CancellationToken.None); + expect(session.sessionId).toMatch(/^sess-/); + const stream = new MockChatResponseStream(); + await session.handleRequest('Hello CLI', [], {} as vscode.ChatParticipantToolToken, stream, undefined, CancellationToken.None); + expect(stream.output.join('\n')).toContain('Mock response: Hello CLI'); + }); + + it('reuses an existing live session instance', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const session1 = await service.createSession('First prompt', undefined, CancellationToken.None); + const session2 = await service.getSession(session1.sessionId, undefined, false, CancellationToken.None); + expect(session2).toBe(session1); + }); + + it('lists active (non-persisted) sessions', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + await service.createSession('List Me', undefined, CancellationToken.None); + const all = await service.getAllSessions(CancellationToken.None); + const labels = all.map(s => s.label); + expect(labels.some(l => l.includes('List Me'))).toBe(true); + }); + + it('generates truncated label for persisted session user message', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + // Create a raw SDK session directly so it appears as persisted (not tracked in _newActiveSessions) + const manager = await service.getSessionManager(); + const raw = await manager.createSession(); + // Inject a long user message + raw.emit('user.message', { content: 'u'.repeat(120) }); + // Also record chat message internally for getChatMessages + (raw as unknown as MockSession)._chatMessages?.push?.({ role: 'user', content: 'u'.repeat(120) }); + const all = await service.getAllSessions(CancellationToken.None); + // Persisted sessions exclude newActive; ensure we find truncated label + const target = all.find(s => s.id === raw.sessionId); + if (target) { + expect(target.label.length).toBeLessThanOrEqual(50); + expect(target.label.endsWith('...')).toBe(true); + } + }); + + it('deletes a session and removes it from listings', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Delete Me', undefined, CancellationToken.None); + await service.deleteSession(session.sessionId); + const all = await service.getAllSessions(CancellationToken.None); + expect(all.find(s => s.id === session.sessionId)).toBeUndefined(); + // Subsequent getSession should return undefined + const fetched = await service.getSession(session.sessionId, undefined, false, CancellationToken.None); + expect(fetched).toBeUndefined(); + }); + + it('fires onDidChangeSessions when deleting a session', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + let eventCount = 0; + service.onDidChangeSessions(() => { eventCount++; }); + const session = await service.createSession('Delete Event', undefined, CancellationToken.None); + // No status changes yet, so we expect zero events prior to deletion + expect(eventCount).toBe(0); + await service.deleteSession(session.sessionId); + // Deletion should always fire the change event exactly once here + expect(eventCount).toBe(1); + }); + + it('returns undefined for non-existent session id', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const fetched = await service.getSession('sess-does-not-exist', undefined, false, CancellationToken.None); + expect(fetched).toBeUndefined(); + }); + + it('updates status from InProgress to Completed on success', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Status test', undefined, CancellationToken.None); + const stream = new MockChatResponseStream(); + await session.handleRequest('Status test', [], {} as vscode.ChatParticipantToolToken, stream, undefined, CancellationToken.None); + expect(session.status).toBe(ChatSessionStatus.Completed); + }); + + it('updates status to Failed on error', async () => { + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Will fail', undefined, CancellationToken.None); + const stream = new MockChatResponseStream(); + await expect(session.handleRequest('FAIL this request', [], {} as vscode.ChatParticipantToolToken, stream, undefined, CancellationToken.None)) + .rejects.toThrow('Forced failure'); + expect(session.status).toBe(ChatSessionStatus.Failed); + }); + + it('disposes session after completion timeout', async () => { + vi.useFakeTimers(); + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Timeout test', undefined, CancellationToken.None); + const stream = new MockChatResponseStream(); + await session.handleRequest('Timeout test', [], {} as vscode.ChatParticipantToolToken, stream, undefined, CancellationToken.None); + // Ensure session reached Completed state and disposal timer scheduled + expect(session.status).toBe(ChatSessionStatus.Completed); + // Advance timers by > 30s (shutdown timeout) + vi.advanceTimersByTime(31_000); + // Flush any remaining timers/microtasks so disposal callback fires + vi.runAllTimers(); + await Promise.resolve(); + // Directly assert original session wrapper disposed. Calling getSession cancels pending terminator. + expect((session as any)._isDisposed).toBe(true); + }); + + it('reuses a single session across staggered intervals before eventual disposal', async () => { + vi.useFakeTimers(); + const service = instantiationService.createInstance(CopilotCLISessionService); + const session = await service.createSession('Interval test 1', undefined, CancellationToken.None); + + async function invoke(prompt: string) { + const stream = new MockChatResponseStream(); + await session.handleRequest(prompt, [], {} as vscode.ChatParticipantToolToken, stream, undefined, CancellationToken.None); + expect(session.status).toBe(ChatSessionStatus.Completed); + expect((session as any)._isDisposed).toBeFalsy(); + } + + // Initial invocation schedules disposal at +30s + await invoke('Interval test 1'); + vi.advanceTimersByTime(15_000); // +15s, not disposed yet + expect((session as any)._isDisposed).toBeFalsy(); + + // Reuse before timeout cancels existing terminator + const reused1 = await service.getSession(session.sessionId, undefined, false, CancellationToken.None); + expect(reused1).toBe(session); + await invoke('Interval test 2'); // schedules new disposal at current time +30s + vi.advanceTimersByTime(15_000); // another 15s + expect((session as any)._isDisposed).toBeFalsy(); + + const reused2 = await service.getSession(session.sessionId, undefined, false, CancellationToken.None); + expect(reused2).toBe(session); + await invoke('Interval test 3'); + vi.advanceTimersByTime(15_000); // cumulative 45s from start, last timer scheduled at 30s mark +15s + expect((session as any)._isDisposed).toBeFalsy(); + + const reused3 = await service.getSession(session.sessionId, undefined, false, CancellationToken.None); + expect(reused3).toBe(session); + await invoke('Interval test 4'); + vi.advanceTimersByTime(15_000); // cumulative 60s from start, last timer scheduled at ~45s + expect((session as any)._isDisposed).toBeFalsy(); + + // Now allow >30s of inactivity since last completion (scheduled at ~45s -> disposal at ~75s) + vi.advanceTimersByTime(31_000); // move past 75s threshold + vi.runAllTimers(); + await Promise.resolve(); + expect((session as any)._isDisposed).toBe(true); + }); +}); diff --git a/src/extension/chatSessions/vscode-node/chatSessions.ts b/src/extension/chatSessions/vscode-node/chatSessions.ts index aa9b8a14ea..17c726bcd7 100644 --- a/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -15,7 +15,8 @@ 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 { CopilotCLIAgentManager } from '../../agents/copilotcli/node/copilotcliAgentManager'; +import { CopilotCLIModels, CopilotCLISDK, ICopilotCLIModels, ICopilotCLISDK } 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'; import { IExtensionContribution } from '../../common/contributions'; @@ -27,7 +28,6 @@ import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { CopilotChatSessionsProvider } from './copilotCloudSessionsProvider'; import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService'; -import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver'; // https://github.com/microsoft/vscode-pull-request-github/blob/8a5c9a145cd80ee364a3bed9cf616b2bd8ac74c2/src/github/copilotApi.ts#L56-L71 @@ -97,33 +97,32 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib })); // Copilot CLI sessions provider - const copilotCLISessionService = claudeAgentInstaService.createInstance(CopilotCLISessionService); - + const copilotTerminalIntegration = this._register(claudeAgentInstaService.createInstance(CopilotCLITerminalIntegration)); const copilotcliAgentInstaService = instantiationService.createChild( new ServiceCollection( - [ICopilotCLISessionService, copilotCLISessionService], + [ICopilotCLISessionService, new SyncDescriptor(CopilotCLISessionService)], + [ICopilotCLIModels, new SyncDescriptor(CopilotCLIModels)], + [ICopilotCLISDK, new SyncDescriptor(CopilotCLISDK)], [ILanguageModelServer, new SyncDescriptor(LanguageModelServer)], - [ICopilotCLITerminalIntegration, new SyncDescriptor(CopilotCLITerminalIntegration)], + [ICopilotCLITerminalIntegration, copilotTerminalIntegration], )); - + const copilotCliSessionService: ICopilotCLISessionService = this._register(copilotcliAgentInstaService.createInstance(CopilotCLISessionService)); const copilotcliSessionItemProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLIChatSessionItemProvider)); this._register(vscode.chat.registerChatSessionItemProvider(this.copilotcliSessionType, copilotcliSessionItemProvider)); - const promptResolver = copilotcliAgentInstaService.createInstance(CopilotCLIPromptResolver); - const copilotcliAgentManager = this._register(copilotcliAgentInstaService.createInstance(CopilotCLIAgentManager, promptResolver)); + const copilotcliPromptResolver = copilotcliAgentInstaService.createInstance(CopilotCLIPromptResolver); const copilotcliChatSessionContentProvider = copilotcliAgentInstaService.createInstance(CopilotCLIChatSessionContentProvider); const summarizer = copilotcliAgentInstaService.createInstance(ChatSummarizerProvider); const copilotcliChatSessionParticipant = copilotcliAgentInstaService.createInstance( CopilotCLIChatSessionParticipant, - copilotcliAgentManager, - copilotCLISessionService, + copilotcliPromptResolver, copilotcliSessionItemProvider, copilotSessionsProvider, - summarizer + summarizer, ); const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); - this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); - this._register(registerCLIChatCommands(copilotcliSessionItemProvider, copilotCLISessionService)); + this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant, { supportsInterruptions: true })); + this._register(registerCLIChatCommands(copilotcliSessionItemProvider, copilotCliSessionService)); } private updateCopilotCloudRegistration() { diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 62f1ef6ea6..0a74f6fc59 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -5,14 +5,15 @@ import * as vscode from 'vscode'; import { ChatExtendedRequestHandler, l10n, Uri } from 'vscode'; -import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IGitService } from '../../../platform/git/common/gitService'; +import { ILogService } from '../../../platform/log/common/logService'; import { Emitter, Event } from '../../../util/vs/base/common/event'; import { Disposable, DisposableStore, IDisposable } from '../../../util/vs/base/common/lifecycle'; import { localize } from '../../../util/vs/nls'; -import { CopilotCLIAgentManager } from '../../agents/copilotcli/node/copilotcliAgentManager'; +import { ICopilotCLIModels } from '../../agents/copilotcli/node/copilotCli'; +import { CopilotCLIPromptResolver } from '../../agents/copilotcli/node/copilotcliPromptResolver'; +import { ICopilotCLISession } from '../../agents/copilotcli/node/copilotcliSession'; import { ICopilotCLISessionService } from '../../agents/copilotcli/node/copilotcliSessionService'; -import { buildChatHistoryFromEvents } from '../../agents/copilotcli/node/copilotcliToolInvocationFormatter'; import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { ConfirmationResult, CopilotChatSessionsProvider } from './copilotCloudSessionsProvider'; @@ -23,30 +24,6 @@ const MODELS_OPTION_ID = 'model'; // TODO@rebornix: we should have proper storage for the session model preference (revisit with API) const _sessionModel: Map = new Map(); -/** - * Convert a model ID to a ModelProvider object for the Copilot CLI SDK - */ -function getModelProvider(modelId: string | undefined): { type: 'anthropic' | 'openai'; model: string } | undefined { - if (!modelId) { - return undefined; - } - - // Map model IDs to their provider and model name - if (modelId.startsWith('claude-')) { - return { - type: 'anthropic', - model: modelId - }; - } else if (modelId.startsWith('gpt-')) { - return { - type: 'openai', - model: modelId - }; - } - - return undefined; -} - namespace SessionIdForCLI { export function getResource(sessionId: string): vscode.Uri { return vscode.Uri.from({ @@ -59,8 +36,6 @@ namespace SessionIdForCLI { } } -const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; - /** * Escape XML special characters */ @@ -84,7 +59,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc @ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration, ) { super(); - this._register(this.terminalIntegration); this._register(this.copilotcliSessionService.onDidChangeSessions(() => { this.refresh(); })); @@ -100,25 +74,27 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc public async provideChatSessionItems(token: vscode.CancellationToken): Promise { const sessions = await this.copilotcliSessionService.getAllSessions(token); - const diskSessions = sessions.filter(session => !this.copilotcliSessionService.isPendingRequest(session.id) && !session.isEmpty).map(session => ({ + const diskSessions = sessions.map(session => ({ resource: SessionIdForCLI.getResource(session.id), label: session.label, tooltip: `Copilot CLI session: ${session.label}`, timing: { startTime: session.timestamp.getTime() }, - status: this.copilotcliSessionService.getSessionStatus(session.id) ?? vscode.ChatSessionStatus.Completed, + status: session.status ?? vscode.ChatSessionStatus.Completed, } satisfies vscode.ChatSessionItem)); - const count = diskSessions.length; - vscode.commands.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0); + if (!token.isCancellationRequested) { + const count = diskSessions.length; + vscode.commands.executeCommand('setContext', 'github.copilot.chat.cliSessionsEmpty', count === 0); + } return diskSessions; } public async createCopilotCLITerminal(): Promise { // TODO@rebornix should be set by CLI - const terminalName = process.env.COPILOTCLI_TERMINAL_TITLE || 'Copilot CLI'; + const terminalName = process.env.COPILOTCLI_TERMINAL_TITLE || 'GitHub Copilot CLI'; await this.terminalIntegration.openTerminal(terminalName); } @@ -131,49 +107,41 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionContentProvider { - private readonly availableModels: vscode.ChatSessionProviderOptionItem[] = [ - { - id: 'claude-sonnet-4.5', - name: 'Claude Sonnet 4.5' - }, - { - id: 'claude-sonnet-4', - name: 'Claude Sonnet 4' - }, - { - id: 'gpt-5', - name: 'GPT-5' - } - ]; - - private get defaultModel(): vscode.ChatSessionProviderOptionItem { - return this.availableModels[0]; - } - constructor( - @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, + @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, - ) { } + ) { + } async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise { + const [models, defaultModel] = await Promise.all([ + this.copilotCLIModels.getAvailableModels(), + this.copilotCLIModels.getDefaultModel() + ]); const copilotcliSessionId = SessionIdForCLI.parse(resource); + const preferredModelId = _sessionModel.get(copilotcliSessionId)?.id; + const preferredModel = (preferredModelId ? models.find(m => m.id === preferredModelId) : undefined) ?? defaultModel; if (!_sessionModel.get(copilotcliSessionId)) { - // Get the user's preferred model from global state, default to claude-sonnet-4.5 - const preferredModelId = this.extensionContext.globalState.get(COPILOT_CLI_MODEL_MEMENTO_KEY, this.defaultModel.id); - const preferredModel = this.availableModels.find(m => m.id === preferredModelId) ?? this.defaultModel; // fallback to claude-sonnet-4.5 _sessionModel.set(copilotcliSessionId, preferredModel); } - - const existingSession = await this.sessionService.getSession(copilotcliSessionId, token); - const events = await existingSession?.sdkSession.getEvents(); - const history = buildChatHistoryFromEvents(events || []); - + const model = this.copilotCLIModels.toModelProvider(preferredModel.id); + const session = await this.sessionService.getSession(copilotcliSessionId, model, true, token); + if (!session) { + return { + history: [], + requestHandler: undefined, + options: { + [MODELS_OPTION_ID]: defaultModel.id + } + }; + } + const history = await session.getChatHistory(); return { history, activeResponseCallback: undefined, requestHandler: undefined, options: { - [MODELS_OPTION_ID]: _sessionModel.get(copilotcliSessionId)?.id ?? this.defaultModel.id + [MODELS_OPTION_ID]: _sessionModel.get(copilotcliSessionId)?.id ?? defaultModel.id } }; } @@ -185,25 +153,27 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC id: MODELS_OPTION_ID, name: 'Model', description: 'Select the language model to use', - items: this.availableModels + items: await this.copilotCLIModels.getAvailableModels() } ] }; } // Handle option changes for a session (store current state in a map) - provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray, token: vscode.CancellationToken): void { + async provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray, token: vscode.CancellationToken) { const sessionId = SessionIdForCLI.parse(resource); + console.log(token.isCancellationRequested); + const models = await this.copilotCLIModels.getAvailableModels(); for (const update of updates) { if (update.optionId === MODELS_OPTION_ID) { if (typeof update.value === 'undefined') { _sessionModel.set(sessionId, undefined); } else { - const model = this.availableModels.find(m => m.id === update.value); + const model = models.find(m => m.id === update.value); _sessionModel.set(sessionId, model); // Persist the user's choice to global state if (model) { - this.extensionContext.globalState.update(COPILOT_CLI_MODEL_MEMENTO_KEY, model.id); + this.copilotCLIModels.setDefaultModel(model); } } } @@ -213,12 +183,14 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC export class CopilotCLIChatSessionParticipant { constructor( - private readonly copilotcliAgentManager: CopilotCLIAgentManager, - private readonly sessionService: ICopilotCLISessionService, + private readonly cliPromptResolver: CopilotCLIPromptResolver, private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider, private readonly cloudSessionProvider: CopilotChatSessionsProvider | undefined, private readonly summarizer: ChatSummarizerProvider, - @IGitService private readonly gitService: IGitService + @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, + @ILogService private readonly logService: ILogService, + @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, + @IGitService private readonly gitService: IGitService, ) { } createHandler(): ChatExtendedRequestHandler { @@ -226,86 +198,85 @@ export class CopilotCLIChatSessionParticipant { } private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { - if (request.acceptedConfirmationData || request.rejectedConfirmationData) { - return await this.handleConfirmationData(request, context, stream, token); - } - const { chatSessionContext } = context; - if (chatSessionContext) { - if (chatSessionContext.isUntitled) { - const { copilotcliSessionId } = await this.copilotcliAgentManager.handleRequest(undefined, request, context, stream, undefined, token); - if (!copilotcliSessionId) { - stream.warning(localize('copilotcli.failedToCreateSession', "Failed to create a new CopilotCLI session.")); - return {}; - } - if (copilotcliSessionId) { - this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(copilotcliSessionId), label: request.prompt ?? 'CopilotCLI' }); - this.sessionService.clearPendingRequest(copilotcliSessionId); - } + if (!chatSessionContext) { + if (request.acceptedConfirmationData || request.rejectedConfirmationData) { + stream.warning(vscode.l10n.t('No chat session context available for confirmation data handling.')); return {}; } - const { resource } = chatSessionContext.chatSessionItem; - const id = SessionIdForCLI.parse(resource); + /* Invoked from a 'normal' chat or 'cloud button' without CLI session context */ + // Handle confirmation data + return await this.handlePushConfirmationData(request, context, stream, token); + } + const defaultModel = await this.copilotCLIModels.getDefaultModel(); + const { resource } = chatSessionContext.chatSessionItem; + const preferredModel = _sessionModel.get(SessionIdForCLI.parse(resource)); + // For existing sessions we cannot fall back, as the model would info would be updated in _sessionModel + const modelId = this.copilotCLIModels.toModelProvider(preferredModel?.id || defaultModel.id); - if (request.prompt.startsWith('/delegate')) { - if (!this.cloudSessionProvider) { - stream.warning(localize('copilotcli.missingCloudAgent', "No cloud agent available")); - return {}; - } + if (chatSessionContext.isUntitled) { + const { prompt, attachments } = await this.cliPromptResolver.resolvePrompt(request, token); + const session = await this.sessionService.createSession(prompt, modelId, token); + await session.handleRequest(prompt, attachments, request.toolInvocationToken, stream, undefined, token); - // Check for uncommitted changes - const currentRepository = this.gitService.activeRepository.get(); - const hasChanges = (currentRepository?.changes?.indexChanges && currentRepository.changes.indexChanges.length > 0); + this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(session.sessionId), label: request.prompt }); + return {}; + } - 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.")); - } + if (!preferredModel) { + this.logService.error(`No model preference found for CLI session ${SessionIdForCLI.parse(resource)}`); + } - const history = await this.summarizer.provideChatSummary(context, token); - const prompt = request.prompt.substring('/delegate'.length).trim(); - if (!await this.cloudSessionProvider.tryHandleUncommittedChanges({ - prompt: prompt, - history: history, - chatContext: context - }, stream, token)) { - const prInfo = await this.cloudSessionProvider.createDelegatedChatSession({ - prompt, - history, - chatContext: context - }, stream, token); - if (prInfo) { - await this.recordPushToSession(id, request.prompt, prInfo, token); - } - return {}; - } else { - return {}; - } - } + const sessionId = SessionIdForCLI.parse(resource); + const session = await this.sessionService.getSession(sessionId, modelId, false, token); + if (!session) { + stream.warning(vscode.l10n.t('Chat session not found.')); + return {}; + } - this.sessionService.setSessionStatus(id, vscode.ChatSessionStatus.InProgress); - await this.copilotcliAgentManager.handleRequest(id, request, context, stream, getModelProvider(_sessionModel.get(id)?.id), token); - this.sessionService.setSessionStatus(id, vscode.ChatSessionStatus.Completed); + if (request.acceptedConfirmationData || request.rejectedConfirmationData) { + return await this.handleConfirmationData(session, request, context, stream, token); + } + + if (request.prompt.startsWith('/delegate')) { + await this.handleDelegateCommand(session, request, context, stream, token); return {}; } - /* Invoked from a 'normal' chat or 'cloud button' without CLI session context */ - // Handle confirmation data - return await this.handlePushConfirmationData(request, context, stream, token); + const { prompt, attachments } = await this.cliPromptResolver.resolvePrompt(request, token); + await session.handleRequest(prompt, attachments, request.toolInvocationToken, stream, modelId, token); + return {}; } - private async handleConfirmationData(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + private async handleDelegateCommand(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + if (!this.cloudSessionProvider) { + stream.warning(localize('copilotcli.missingCloudAgent', "No cloud agent available")); + return; + } + + // Check for uncommitted changes + const currentRepository = this.gitService.activeRepository.get(); + const hasChanges = (currentRepository?.changes?.indexChanges && currentRepository.changes.indexChanges.length > 0); + 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.")); + } + + const history = await this.summarizer.provideChatSummary(context, token); + const prompt = request.prompt.substring('/delegate'.length).trim(); + const metadata = { prompt, history, chatContext: context }; + if (!await this.cloudSessionProvider.tryHandleUncommittedChanges(metadata, stream, token)) { + const prInfo = await this.cloudSessionProvider.createDelegatedChatSession(metadata, stream, token); + if (prInfo) { + await this.recordPushToSession(session, request.prompt, prInfo, token); + } + } + } + private async handleConfirmationData(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { const results: ConfirmationResult[] = []; results.push(...(request.acceptedConfirmationData?.map(data => ({ step: data.step, accepted: true, metadata: data?.metadata })) ?? [])); results.push(...((request.rejectedConfirmationData ?? []).filter(data => !results.some(r => r.step === data.step)).map(data => ({ step: data.step, accepted: false, metadata: data?.metadata })))); - const { chatSessionContext } = context; - if (!chatSessionContext) { - stream.warning(vscode.l10n.t('No chat session context available for confirmation data handling.')); - return {}; - } - const { resource } = chatSessionContext.chatSessionItem; - const id = SessionIdForCLI.parse(resource); for (const data of results) { switch (data.step) { case 'uncommitted-changes': @@ -320,7 +291,7 @@ export class CopilotCLIChatSessionParticipant { chatContext: context }, stream, token); if (prInfo) { - await this.recordPushToSession(id, request.prompt, prInfo, token); + await this.recordPushToSession(session, request.prompt, prInfo, token); } return {}; } @@ -342,41 +313,23 @@ export class CopilotCLIChatSessionParticipant { const history = context.chatSummary?.history ?? await this.summarizer.provideChatSummary(context, token); const requestPrompt = history ? `${prompt}\n**Summary**\n${history}` : prompt; - const sdkSession = await this.sessionService.getOrCreateSDKSession(undefined, requestPrompt); + const { sessionId } = await this.sessionService.createSession(requestPrompt, undefined, token); - await vscode.commands.executeCommand('vscode.open', SessionIdForCLI.getResource(sdkSession.sessionId)); + await vscode.commands.executeCommand('vscode.open', SessionIdForCLI.getResource(sessionId)); await vscode.commands.executeCommand('workbench.action.chat.submit', { inputValue: requestPrompt }); return {}; } private async recordPushToSession( - sessionId: string, + session: ICopilotCLISession, userPrompt: string, prInfo: { uri: string; title: string; description: string; author: string; linkTag: string }, token: vscode.CancellationToken ): Promise { - const session = await this.sessionService.getSession(sessionId, token); - if (!session) { - return; - } - - // Add user message event - session.sdkSession.addEvent({ - type: 'user.message', - data: { - content: userPrompt - } - }); - - // Add assistant message event with embedded PR metadata const assistantMessage = `GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n`; - session.sdkSession.addEvent({ - type: 'assistant.message', - data: { - messageId: `msg_${Date.now()}`, - content: assistantMessage - } - }); + + session.addUserMessage(userPrompt); + session.addUserAssistantMessage(assistantMessage); } } @@ -400,7 +353,6 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL if (result === deleteLabel) { const id = SessionIdForCLI.parse(sessionItem.resource); await copilotCLISessionService.deleteSession(id); - copilotcliSessionItemProvider.refresh(); } } })); diff --git a/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionsContribution.spec.ts b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionsContribution.spec.ts new file mode 100644 index 0000000000..f70d3aa00c --- /dev/null +++ b/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionsContribution.spec.ts @@ -0,0 +1,473 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { Emitter, Event } from '../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString, Uri } from '../../../../vscodeTypes'; +import { ICopilotCLIModels } from '../../../agents/copilotcli/node/copilotCli'; +import { CopilotCLIPromptResolver } from '../../../agents/copilotcli/node/copilotcliPromptResolver'; +import { ICopilotCLISession } from '../../../agents/copilotcli/node/copilotcliSession'; +import { ICopilotCLISessionService } from '../../../agents/copilotcli/node/copilotcliSessionService'; +import { ChatSummarizerProvider } from '../../../prompt/node/summarizer'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from '../copilotCLIChatSessionsContribution'; +import { ICopilotCLITerminalIntegration } from '../copilotCLITerminalIntegration'; +import { CopilotChatSessionsProvider } from '../copilotCloudSessionsProvider'; +// Mock PowerShell shim to avoid import-analysis errors +vi.mock('../copilotCLIShim.ps1', () => ({})); + +// -------------------------------------------------------------------------------------- +// Snapshot helper (mirrors claudeChatSessionContentProvider.spec.ts style) +// -------------------------------------------------------------------------------------- +function mapHistoryForSnapshot(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn2)[]) { + return history.map(turn => { + if (turn instanceof ChatRequestTurn) { + return { type: 'request', prompt: turn.prompt }; + } else if (turn instanceof ChatResponseTurn2) { + return { + type: 'response', + parts: turn.response.map(part => { + if (part instanceof ChatResponseMarkdownPart) { + return { type: 'markdown', content: part.value.value }; + } else if (part instanceof ChatToolInvocationPart) { + return { + type: 'tool', + toolName: part.toolName, + toolCallId: part.toolCallId, + isError: part.isError, + invocationMessage: part.invocationMessage ? (typeof part.invocationMessage === 'string' ? part.invocationMessage : part.invocationMessage.value) : undefined + }; + } + return { type: 'unknown' }; + }) + }; + } + return { type: 'unknown' }; + }); +} + +// -------------------------------------------------------------------------------------- +// Mocks +// -------------------------------------------------------------------------------------- +// --- Minimal vi.fn based mocks (preferred style) --- +interface MockSession extends ICopilotCLISession { _history: (vscode.ChatRequestTurn | vscode.ChatResponseTurn2)[] } + +function createMockSession(id: string): MockSession { + const history: (vscode.ChatRequestTurn | vscode.ChatResponseTurn2)[] = []; + return { + serviceBrand: undefined as any, + _sessionBrand: undefined as any, + status: vscode.ChatSessionStatus.Completed, + sessionId: id, + addUserMessage: (content: string) => { history.push(new ChatRequestTurn2(content, undefined, [], '', [], undefined)); }, + addUserAssistantMessage: (content: string) => { history.push(new ChatResponseTurn2([new ChatResponseMarkdownPart(new MarkdownString(content))], {}, '')); }, + handleRequest: async (prompt: string, _attachments: any[], _toolToken: any, stream: vscode.ChatResponseStream) => { + history.push(new ChatRequestTurn2(prompt, undefined, [], '', [], undefined)); + stream.markdown('Response: ' + prompt); + history.push(new ChatResponseTurn2([new ChatResponseMarkdownPart(new MarkdownString('Response: ' + prompt))], {}, '')); + }, + getChatHistory: async () => history.slice(), + getSelectedModelId: () => 'mock-model', + _history: history + } as any; +} + +function createMockSessionService() { + const onDidChange = new Emitter(); + const sessions = new Map(); + return { + _serviceBrand: undefined, + onDidChangeSessions: onDidChange.event, + getAllSessions: vi.fn(async () => Array.from(sessions.values()).map(s => ({ id: s.sessionId, label: 'Label ' + s.sessionId, timestamp: new Date(), status: s.status }))), + getSession: vi.fn(async (id: string) => sessions.get(id)), + createSession: vi.fn(async (prompt: string) => { const id = `sess-${prompt}-${sessions.size + 1}`; const s = createMockSession(id); sessions.set(id, s); onDidChange.fire(); return s; }), + deleteSession: vi.fn(async (id: string) => { sessions.delete(id); onDidChange.fire(); }) + } as unknown as ICopilotCLISessionService; +} + +function createMockModels(): ICopilotCLIModels { + const available = [ + { id: 'model-a', name: 'Model A', description: 'A' }, + { id: 'model-b', name: 'Model B', description: 'B' }, + { id: 'model-c', name: 'Model C', description: 'C' } + ]; + let def = available[0]; + return { + _serviceBrand: undefined, + getAvailableModels: vi.fn(async () => available), + getDefaultModel: vi.fn(async () => def), + setDefaultModel: vi.fn((m: vscode.ChatSessionProviderOptionItem) => { def = available.find(a => a.id === m.id) ?? def; }), + toModelProvider: vi.fn((id: string) => id) + } as any; +} + +function createMockTerminalIntegration(): ICopilotCLITerminalIntegration { + return { _serviceBrand: undefined, openTerminal: vi.fn(async () => { }) } as any; +} + +function createMockPromptResolver(): CopilotCLIPromptResolver { + return { resolvePrompt: vi.fn(async (request: vscode.ChatRequest) => ({ prompt: request.prompt, attachments: [] })) } as any; +} + +function createMockSummarizer(): ChatSummarizerProvider { + return { _serviceBrand: undefined, provideChatSummary: vi.fn(async () => 'Summary') } as any; +} + +function createMockCloudProvider(uncommittedChangesHandled: boolean = false): CopilotChatSessionsProvider { + return { + _serviceBrand: undefined, + onDidChangeChatSessionItems: Event.None, + onDidCommitChatSessionItem: Event.None, + refresh: () => { }, + provideChatSessionItems: vi.fn(async () => []), + provideChatSessionContent: vi.fn(async () => ({ history: [] })), + provideChatSessionProviderOptions: vi.fn(async () => ({ optionGroups: [] })), + provideHandleOptionsChange: vi.fn(async () => { }), + tryHandleUncommittedChanges: vi.fn(async () => uncommittedChangesHandled), + createDelegatedChatSession: vi.fn(async () => ({ uri: 'https://example.com/pr/1', title: 'T & ', description: 'Desc > details', author: 'Alice " Apos \'', linkTag: 'PR-1' })) + } as any; +} + +class CapturingStream implements Partial { + output: string[] = []; + markdown(v: string): void { this.output.push(v); } + warning(v: string): void { this.output.push('[warn] ' + v); } +} + +function createCapturingStream() { + return new CapturingStream() as unknown as (vscode.ChatResponseStream & { output: string[] }); +} + +describe('copilotCLIChatSessionsContribution', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + let sessionService: ICopilotCLISessionService; + let models: ICopilotCLIModels; + let terminal: ICopilotCLITerminalIntegration; + let promptResolver: CopilotCLIPromptResolver; + let summarizer: ChatSummarizerProvider; + let cloudProvider: CopilotChatSessionsProvider; + let itemProvider: CopilotCLIChatSessionItemProvider; + let contentProvider: CopilotCLIChatSessionContentProvider; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + + // Provide minimal vscode.commands/window stubs if missing + if (!(vscode as any).commands) { + (vscode as any).commands = { + executeCommand: vi.fn().mockResolvedValue(undefined), + registerCommand: vi.fn().mockImplementation((_id: string, _handler: Function) => ({ dispose() { } })) + }; + } else { + vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + if (!('registerCommand' in vscode.commands)) { + (vscode.commands as any).registerCommand = vi.fn().mockImplementation((_id: string, _handler: Function) => ({ dispose() { } })); + } + } + if (!(vscode as any).window) { + (vscode as any).window = { showWarningMessage: vi.fn().mockResolvedValue(undefined) } as any; + } else if (!(vscode.window as any).showWarningMessage) { + (vscode.window as any).showWarningMessage = vi.fn().mockResolvedValue(undefined); + } + // Minimal mocks + sessionService = createMockSessionService(); + models = createMockModels(); + terminal = createMockTerminalIntegration(); + promptResolver = createMockPromptResolver(); + summarizer = createMockSummarizer(); + cloudProvider = createMockCloudProvider(); + + services.define(ICopilotCLISessionService, sessionService as any); + services.define(ICopilotCLIModels, models as any); + services.define(ICopilotCLITerminalIntegration, terminal as any); + instantiationService = services.createTestingAccessor().get(IInstantiationService); + itemProvider = instantiationService.createInstance(CopilotCLIChatSessionItemProvider); + contentProvider = instantiationService.createInstance(CopilotCLIChatSessionContentProvider); + }); + + afterEach(() => { + store.clear(); + vi.clearAllMocks(); + }); + + function createSessionIdUri(id: string) { return URI.parse(`copilotcli:/${id}`); } + + // ---------------- Item Provider ---------------- + it('lists sessions and maps to ChatSessionItems', async () => { + await sessionService.createSession('Hello', undefined, CancellationToken.None); + + const items = await itemProvider.provideChatSessionItems(CancellationToken.None); + expect(items.length).toBe(1); + expect(items[0].resource.scheme).toBe('copilotcli'); + }); + + it('fires refresh event', async () => { + const spy = vi.fn(); + store.add(itemProvider.onDidChangeChatSessionItems(spy)); + + itemProvider.refresh(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('fires swap event', () => { + const spy = vi.fn(); + itemProvider.onDidCommitChatSessionItem(spy); + const original: vscode.ChatSessionItem = { resource: createSessionIdUri('temp'), label: 'temp', timing: { startTime: Date.now() } }; + const modified: vscode.ChatSessionItem = { resource: createSessionIdUri('real'), label: 'real', timing: { startTime: Date.now() } }; + + itemProvider.swap(original, modified); + + expect(spy).toHaveBeenCalledWith({ original, modified }); + }); + + // ---------------- Content Provider ---------------- + it('returns empty history when session missing', async () => { + const uri = createSessionIdUri('missing'); + + const result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + expect(result.history).toEqual([]); + expect(result.options?.model || result.options?.['model']).toBeDefined(); + }); + + it('returns existing session history', async () => { + // If we used the real SDK, then we'd have other items in history as well + // However this is enough to verify that user and assistant messages are captured correctly. + const s = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + s.addUserMessage('User turn'); + s.addUserAssistantMessage('Assistant turn'); + + const uri = createSessionIdUri(s.sessionId); + const result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + expect(mapHistoryForSnapshot(result.history)).toMatchInlineSnapshot(` + [ + { + "prompt": "User turn", + "type": "request", + }, + { + "parts": [ + { + "content": "Assistant turn", + "type": "markdown", + }, + ], + "type": "response", + }, + ] + `); + }); + + it('provides model option group', async () => { + const opts = await contentProvider.provideChatSessionProviderOptions(); + + expect(opts.optionGroups?.[0]?.id).toBe('model'); + expect(opts.optionGroups?.[0]?.items?.length).toBeGreaterThan(1); + }); + + it('updates model preference via provideHandleOptionsChange', async () => { + const s = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + const uri = createSessionIdUri(s.sessionId); + + await contentProvider.provideHandleOptionsChange(uri, [{ optionId: 'model', value: 'model-b' }], CancellationToken.None); + let result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + expect(result.options?.['model']).toBe('model-b'); + + // Change again + await contentProvider.provideHandleOptionsChange(uri, [{ optionId: 'model', value: 'model-c' }], CancellationToken.None); + result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + expect(result.options?.['model']).toBe('model-c'); + + // New sessions should get model-c as default now + const s2 = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + const uri2 = createSessionIdUri(s2.sessionId); + + result = await contentProvider.provideChatSessionContent(uri2, CancellationToken.None); + expect(result.options?.['model']).toBe('model-c'); + }); + + it('clears model preference when undefined', async () => { + const s = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + const uri = createSessionIdUri(s.sessionId); + + await contentProvider.provideHandleOptionsChange(uri, [{ optionId: 'model', value: 'model-b' }], CancellationToken.None); + await contentProvider.provideHandleOptionsChange(uri, [{ optionId: 'model', value: undefined }], CancellationToken.None); + const result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + + // Falls back to current default (persisted previously to model-b) + expect(result.options?.['model']).toBe('model-b'); + }); + + // ---------------- Participant ---------------- + it('creates new session for untitled context and swaps item', async () => { + // First verify that there are no sessions. + const itemsBefore = await itemProvider.provideChatSessionItems(CancellationToken.None); + expect(itemsBefore.length).toBe(0); + + const participant = new CopilotCLIChatSessionParticipant(promptResolver as any, itemProvider, cloudProvider, summarizer as any, sessionService as any, { error: vi.fn(), info: vi.fn() } as any, models as any, { activeRepository: { get: () => undefined } } as any); + const handler = participant.createHandler(); + const stream = createCapturingStream(); + const untitledItem: vscode.ChatSessionItem = { resource: createSessionIdUri('temp'), label: 'Temp', timing: { startTime: Date.now() } }; + const swapSpy = vi.fn(); + itemProvider.onDidCommitChatSessionItem(swapSpy); + + await handler({ prompt: 'Hello', toolInvocationToken: {} } as any, { chatSessionContext: { chatSessionItem: untitledItem, isUntitled: true } } as any, stream, CancellationToken.None); + + expect(swapSpy).toHaveBeenCalled(); + expect(stream.output.some(o => o.includes('Response'))).toBe(true); + + // Verify this session is in the item provider now + const items = await itemProvider.provideChatSessionItems(CancellationToken.None); + expect(items.length).toBe(1); + expect(items[0].resource.toString()).not.toBe(untitledItem.resource.toString()); + expect(items[0].label).toContain('Hello'); + }); + + it('attempt to use existing session will result in warning if missing', async () => { + const participant = new CopilotCLIChatSessionParticipant(promptResolver as any, itemProvider, cloudProvider, summarizer as any, sessionService as any, { error: vi.fn(), info: vi.fn() } as any, models as any, { activeRepository: { get: () => undefined } } as any); + const handler = participant.createHandler(); + const stream = createCapturingStream(); + const existingItem: vscode.ChatSessionItem = { resource: createSessionIdUri('does-not-exist'), label: 'X', timing: { startTime: Date.now() } }; + + await handler({ prompt: 'Hello', toolInvocationToken: {} } as any, { chatSessionContext: { chatSessionItem: existingItem, isUntitled: false } } as any, stream, CancellationToken.None); + + expect(stream.output.some(o => o.includes('Chat session not found'))).toBe(true); + }); + + it('handles /delegate command and records push assistant message', async () => { + const newSession = await sessionService.createSession('Initial', undefined, CancellationToken.None); + const participant = new CopilotCLIChatSessionParticipant(promptResolver as any, itemProvider, cloudProvider, summarizer as any, sessionService as any, { error: vi.fn(), info: vi.fn() } as any, models as any, { activeRepository: { get: () => ({ changes: { indexChanges: [] } }) } } as any); + const handler = participant.createHandler(); + const stream = createCapturingStream(); + const item: vscode.ChatSessionItem = { resource: createSessionIdUri(newSession.sessionId), label: 'S', timing: { startTime: Date.now() } }; + + await handler({ prompt: '/delegate Implement & ' } as any, { chatSessionContext: { chatSessionItem: item, isUntitled: false } } as any, stream, CancellationToken.None); + + const session = await sessionService.getSession(newSession.sessionId, undefined, false, CancellationToken.None); + const hist = await session!.getChatHistory(); + + // Last user & assistant message should contain the right messages + const userText = (hist[hist.length - 2] as ChatRequestTurn2).prompt; + expect(userText).toContain('Implement & '); + const assistantText = ((hist[hist.length - 1] as ChatResponseTurn2).response[0] as ChatResponseMarkdownPart).value.value; + expect(assistantText).toContain('GitHub Copilot cloud agent has begun working on your request'); + expect(assistantText).toContain('Follow its progress in the associated chat and pull request'); + }); + + it('handles /delegate command and does not records push assistant message', async () => { + const newSession = await sessionService.createSession('Initial', undefined, CancellationToken.None); + cloudProvider = createMockCloudProvider(true); + const participant = new CopilotCLIChatSessionParticipant(promptResolver as any, itemProvider, cloudProvider, summarizer as any, sessionService as any, { error: vi.fn(), info: vi.fn() } as any, models as any, { activeRepository: { get: () => ({ changes: { indexChanges: [] } }) } } as any); + const handler = participant.createHandler(); + const stream = createCapturingStream(); + const item: vscode.ChatSessionItem = { resource: createSessionIdUri(newSession.sessionId), label: 'S', timing: { startTime: Date.now() } }; + + await handler({ prompt: '/delegate Implement & ' } as any, { chatSessionContext: { chatSessionItem: item, isUntitled: false } } as any, stream, CancellationToken.None); + + const session = await sessionService.getSession(newSession.sessionId, undefined, false, CancellationToken.None); + const hist = await session!.getChatHistory(); + + expect(hist.length).toBe(0); // No messages recorded + }); + + it('push confirmation path creates new session when no context', async () => { + const participant = new CopilotCLIChatSessionParticipant(promptResolver as any, itemProvider, cloudProvider, summarizer as any, sessionService as any, { error: vi.fn(), info: vi.fn() } as any, models as any, { activeRepository: { get: () => undefined } } as any); + const handler = participant.createHandler(); + const stream = createCapturingStream(); + const openSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await handler({ prompt: 'Do push' } as any, { chatSessionContext: undefined } as any, stream, CancellationToken.None); + + // Expect two executeCommand calls: open + submit + expect(openSpy).toBeCalledTimes(2); + expect(openSpy.mock.calls[0][0]).toBe('vscode.open'); + expect((openSpy.mock.calls[0][1] as Uri).scheme).toBe('copilotcli'); + expect(openSpy.mock.calls[1][0]).toBe('workbench.action.chat.submit'); + expect(openSpy.mock.calls[1][1].inputValue).toContain('Do push'); + openSpy.mockRestore(); + }); + + // ---------------- Content Provider tool invocation history ---------------- + it('includes tool invocation part in snapshot', async () => { + const s = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + const toolPart = new ChatToolInvocationPart('bash', 'tool-1', false); + toolPart.isError = false; + toolPart.toolName = 'bash'; + toolPart.toolCallId = 'tool-1'; + + (s as MockSession)._history.push(new ChatResponseTurn2([toolPart], {}, '')); + + const uri = createSessionIdUri(s.sessionId); + const result = await contentProvider.provideChatSessionContent(uri, CancellationToken.None); + const snap = mapHistoryForSnapshot(result.history); + expect(JSON.stringify(snap)).toMatch(/"toolName":"bash"/); + }); + + // ---------------- Commands ---------------- + it('registers and invokes refresh commands', () => { + const regSpy = vi.spyOn(vscode.commands, 'registerCommand'); + const disposable = registerCLIChatCommands(itemProvider, sessionService); + const registrations = regSpy.mock.calls.map(c => ({ id: c[0] as string, handler: c[1] as Function })); + const refreshHandlers = registrations.filter(r => r.id.includes('refresh')); + + expect(refreshHandlers.length).toBe(2); + + const eventSpy = vi.fn(); + store.add(itemProvider.onDidChangeChatSessionItems(eventSpy)); + + refreshHandlers.forEach(r => r.handler()); + + expect(eventSpy).toHaveBeenCalledTimes(2); + disposable.dispose(); + }); + + it('invokes delete command after confirmation', async () => { + const regSpy = vi.spyOn(vscode.commands, 'registerCommand'); + vi.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue('Delete' as any); + const session = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + let items = await itemProvider.provideChatSessionItems(CancellationToken.None); + expect(items.length).toBe(1); + + store.add(registerCLIChatCommands(itemProvider, sessionService)); + const registrations = regSpy.mock.calls.map(c => ({ id: c[0] as string, handler: c[1] as Function })); + const del = registrations.find(r => r.id.endsWith('.delete'))!; + + await del.handler({ resource: createSessionIdUri(session.sessionId), label: 'X', timing: { startTime: Date.now() } }); + + items = await itemProvider.provideChatSessionItems(CancellationToken.None); + expect(items.length).toBe(0); + }); + + it('invokes resume terminnal commands', async () => { + const regSpy = vi.spyOn(vscode.commands, 'registerCommand'); + const session = await sessionService.createSession('Prompt', undefined, CancellationToken.None); + store.add(registerCLIChatCommands(itemProvider, sessionService)); + const registrations = regSpy.mock.calls.map(c => ({ id: c[0] as string, handler: c[1] as Function })); + const resume = registrations.find(r => r.id.endsWith('.resumeInTerminal'))!; + const openSpy = terminal.openTerminal as any; + + await resume.handler({ resource: createSessionIdUri(session.sessionId), label: 'X', timing: { startTime: Date.now() } }); + + expect(openSpy.mock.calls.length).toBe(1); + }); + + it('invokes newTerminalSession commands', async () => { + const regSpy = vi.spyOn(vscode.commands, 'registerCommand'); + store.add(registerCLIChatCommands(itemProvider, sessionService)); + const registrations = regSpy.mock.calls.map(c => ({ id: c[0] as string, handler: c[1] as Function })); + const newTerm = registrations.find(r => r.id.endsWith('.newTerminalSession'))!; + const openSpy = terminal.openTerminal as any; + + await newTerm.handler(); + + expect(openSpy.mock.calls.length).toBe(1); + }); +}); From 2a73e67bb28ea437fdc2eec045a11908f0132c0a Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 30 Oct 2025 11:09:15 +1100 Subject: [PATCH 2/8] Updates --- .../copilotcli/node/copilotcliSession.ts | 6 +- .../copilotcli/node/permissionHelpers.ts | 89 +------------------ 2 files changed, 5 insertions(+), 90 deletions(-) diff --git a/src/extension/agents/copilotcli/node/copilotcliSession.ts b/src/extension/agents/copilotcli/node/copilotcliSession.ts index 5f644cc3c1..0d6082d8a6 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSession.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -14,7 +14,7 @@ import { ChatRequestTurn2, ChatResponseThinkingProgressPart, ChatResponseTurn2, import { IToolsService } from '../../../tools/common/toolsService'; import { CopilotCLIPermissionsHandler } from './copilotCli'; import { buildChatHistoryFromEvents, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; -import { getConfirmationToolParams } from './permissionHelpers'; +import { getConfirmationToolParams, PermissionRequest } from './permissionHelpers'; export interface ICopilotCLISession { readonly sessionId: string; @@ -160,7 +160,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } private async requestPermission( - permissionRequest: Parameters>[0], + permissionRequest: PermissionRequest, toolInvocationToken: vscode.ChatParticipantToolToken ): Promise>> { if (permissionRequest.kind === 'read') { @@ -174,7 +174,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } try { - const { tool, input } = getConfirmationToolParams(permissionRequest as Record); + const { tool, input } = getConfirmationToolParams(permissionRequest); const result = await this.toolsService.invokeTool(tool, { input, toolInvocationToken }, CancellationToken.None diff --git a/src/extension/agents/copilotcli/node/permissionHelpers.ts b/src/extension/agents/copilotcli/node/permissionHelpers.ts index 16ccc8ef8f..90d8dc9eae 100644 --- a/src/extension/agents/copilotcli/node/permissionHelpers.ts +++ b/src/extension/agents/copilotcli/node/permissionHelpers.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { SessionOptions } from '@github/copilot/sdk'; import { ToolName } from '../../../tools/common/toolNames'; export interface PermissionToolParams { @@ -82,94 +83,8 @@ function codeBlock(obj: Record): string { /** TYPES FROM @github/copilot */ -declare type Command = { - readonly identifier: string; - readonly readOnly: boolean; -}; - - -/** - * This is just a type to warn that there's a good chance it's not a real path, because - * it was _very_ heuristically parsed out of a command. - */ -declare type PossiblePath = string; - - -/** - * A permission request for executing shell commands. - */ -declare type ShellPermissionRequest = { - readonly kind: "shell"; - /** The full command that the user is being asked to approve, e.g. `echo foo && find -exec ... && git push` */ - readonly fullCommandText: string; - /** A concise summary of the user's intention, e.g. "Echo foo and find a file and then run git push" */ - readonly intention: string; - /** - * The commands that are being invoked in the shell invocation. - * - * As a special case, which might be better represented in the type system, if there were no parsed commands - * e.g. `export VAR=value`, then this will have a single entry with identifier equal to the fullCommandText. - */ - readonly commands: ReadonlyArray; - /** - * Possible file paths that the command might access. - * - * This is entirely heuristic, so it's pretty untrustworthy. - */ - readonly possiblePaths: ReadonlyArray; - /** - * Indicates whether any command in the script has redirection to write to a file. - */ - readonly hasWriteFileRedirection: boolean; - /** - * If there are complicated constructs, then persistent approval is not supported. - * e.g. `cat $(echo "foo")` should not be persistently approvable because it's hard - * for the user to understand the implications. - */ - readonly canOfferSessionApproval: boolean; -}; - - -/** - * A permission request for writing to new or existing files. - */ -declare type WritePermissionRequest = { - readonly kind: "write"; - /** The intention of the edit operation, e.g. "Edit file" or "Create file" */ - readonly intention: string; - /** The name of the file being edited */ - readonly fileName: string; - /** The diff of the changes being made */ - readonly diff: string; -}; - - -/** - * A permission request for invoking an MCP tool. - */ -declare type MCPPermissionRequest = { - readonly kind: "mcp"; - /** The name of the MCP Server being targeted e.g. "github-mcp-server" */ - readonly serverName: string; - /** The name of the tool being targeted e.g. "list_issues" */ - readonly toolName: string; - /** The title of the tool being targeted e.g. "List Issues" */ - readonly toolTitle: string; - /** - * The _hopefully_ JSON arguments that will be passed to the MCP tool. - * - * This should be an object, but it's not parsed before this point so we can't guarantee that. - * */ - readonly args: unknown; - /** - * Whether the tool is read-only (e.g. a `view` operation) or not (e.g. an `edit` operation). - */ - readonly readOnly: boolean; -}; - - /** * A permission request which will be used to check tool or path usage against config and/or request user approval. */ -export declare type PermissionRequest = ShellPermissionRequest | WritePermissionRequest | MCPPermissionRequest | { kind: 'read'; intention: string }; +export declare type PermissionRequest = Parameters>[0] | { kind: 'read'; intention: string; path: string }; From 15a1bf4a885aa31ff30dd32651093851a5b1dfa0 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 30 Oct 2025 11:21:22 +1100 Subject: [PATCH 3/8] Refresh after deleting a session --- .../vscode-node/copilotCLIChatSessionsContribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 0a74f6fc59..511172af99 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -353,6 +353,7 @@ export function registerCLIChatCommands(copilotcliSessionItemProvider: CopilotCL if (result === deleteLabel) { const id = SessionIdForCLI.parse(sessionItem.resource); await copilotCLISessionService.deleteSession(id); + copilotcliSessionItemProvider.refresh(); } } })); From 412596a42f70c4a4433dbde2cf85ecf7a9b61759 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 30 Oct 2025 11:44:42 +1100 Subject: [PATCH 4/8] Update comments --- .../agents/copilotcli/node/copilotcliSessionService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts index 557c86c2fe..99d09b88a9 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts @@ -155,10 +155,10 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS }) )); - // Merge new sessions (not yet persisted by SDK) + // Do not include new active sessions (as they get added into treebiew by core) const allSessions = diskSessions - .filter(session => !this._newActiveSessions.has(session.id) && !session.isEmpty) - .concat(Array.from(this._newActiveSessions.values())) + .filter(session => !this._newActiveSessions.has(session.id)) + .filter(session => !session.isEmpty) .map(session => { return { ...session, @@ -217,6 +217,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS 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. if (session.status === ChatSessionStatus.Completed || session.status === ChatSessionStatus.Failed) { this._newActiveSessions.delete(sdkSession.sessionId); } From 7c814847f1031c248c4651a5c3fc9e183459b719 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 31 Oct 2025 05:29:37 +1100 Subject: [PATCH 5/8] Work around session not being usable once aborted --- .../agents/copilotcli/node/copilotcliSession.ts | 16 +++++++++++++++- .../copilotcli/node/copilotcliSessionService.ts | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/extension/agents/copilotcli/node/copilotcliSession.ts b/src/extension/agents/copilotcli/node/copilotcliSession.ts index 0d6082d8a6..d452204ef3 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSession.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -20,6 +20,8 @@ export interface ICopilotCLISession { readonly sessionId: string; readonly status: vscode.ChatSessionStatus | undefined; readonly onDidChangeStatus: vscode.Event; + readonly aborted: boolean; + readonly onDidAbort: vscode.Event; handleRequest( prompt: string, @@ -50,6 +52,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes public readonly onDidChangeStatus = this._statusChange.event; + private _aborted?: boolean; + public get aborted(): boolean { + return this._aborted ?? false; + } + private readonly _onDidAbort = this.add(new EventEmitter()); + + public readonly onDidAbort = this._onDidAbort.event; + constructor( private readonly _permissionHandler: CopilotCLIPermissionsHandler, private readonly _session: Session, @@ -81,7 +91,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } } - disposables.add(token.onCancellationRequested(() => this._session.abort())); + disposables.add(token.onCancellationRequested(() => { + this._session.abort(); + this._aborted = true; + this._onDidAbort.fire(); + })); disposables.add(this._permissionHandler.onDidRequestPermissions(async (permissionRequest) => { return await this.requestPermission(permissionRequest, toolInvocationToken); })); diff --git a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts index 99d09b88a9..5b9612858b 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSessionService.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSessionService.ts @@ -261,7 +261,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - private async createCopilotSession(sdkSession: Session, sessionManager: SessionManager, permissionHandler: CopilotCLIPermissionsHandler, disposables: IDisposable,): Promise { + private async createCopilotSession(sdkSession: Session, sessionManager: SessionManager, permissionHandler: CopilotCLIPermissionsHandler, disposables: IDisposable,): Promise { this.sessionTerminators.deleteAndDispose(sdkSession.sessionId); const sessionDisposables = this._register(new DisposableStore()); sessionDisposables.add(disposables); @@ -293,6 +293,11 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this.sessionTerminators.deleteAndDispose(session.sessionId); } })); + sessionDisposables.add(session.onDidAbort(() => { + // We need to start with a new session. + // https://github.com/microsoft/vscode/issues/274169 + session.dispose(); + })); this._sessionWrappers.set(sdkSession.sessionId, session); return session; From 49b5ea3bcdab94a2438a46253104fe5601e6c0ec Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 31 Oct 2025 10:00:45 +1100 Subject: [PATCH 6/8] Display edits --- .../copilotcli/node/copilotcliSession.ts | 33 +++++++++++++++++-- .../node/copilotcliToolInvocationFormatter.ts | 30 +++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/extension/agents/copilotcli/node/copilotcliSession.ts b/src/extension/agents/copilotcli/node/copilotcliSession.ts index d452204ef3..a6fbabe703 100644 --- a/src/extension/agents/copilotcli/node/copilotcliSession.ts +++ b/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -10,10 +10,12 @@ import { IWorkspaceService } from '../../../../platform/workspace/common/workspa import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { CancellationError } from '../../../../util/vs/base/common/errors'; import { DisposableStore, 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 { CopilotCLIPermissionsHandler } from './copilotCli'; -import { buildChatHistoryFromEvents, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; +import { buildChatHistoryFromEvents, getAffectedUrisForEditTool, processToolExecutionComplete, processToolExecutionStart } from './copilotcliToolInvocationFormatter'; import { getConfirmationToolParams, PermissionRequest } from './permissionHelpers'; export interface ICopilotCLISession { @@ -96,11 +98,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._aborted = true; this._onDidAbort.fire(); })); + + const toolNames = new Map(); + const editTracker = new ExternalEditTracker(); + const editFilesAndToolCallIds = new ResourceMap(); + disposables.add(this._permissionHandler.onDidRequestPermissions(async (permissionRequest) => { - return await this.requestPermission(permissionRequest, toolInvocationToken); + return await this.requestPermission(permissionRequest, stream, editTracker, + (file: Uri) => editFilesAndToolCallIds.get(file), + toolInvocationToken + ); })); - const toolNames = new Map(); disposables.add(toDisposable(this._session.on('*', (event) => this._logService.trace(`CopilotCLI Event: ${JSON.stringify(event, null, 2)}`)))); disposables.add(toDisposable(this._session.on('assistant.turn_start', () => toolNames.clear()))); @@ -116,9 +125,17 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes if (responsePart instanceof ChatResponseThinkingProgressPart) { stream.push(responsePart); } + // Track edits for edit tools. + const editUris = getAffectedUrisForEditTool(event.data.toolName, event.data.arguments || {}); + if (editUris.length) { + editUris.forEach(uri => editFilesAndToolCallIds.set(uri, event.data.toolCallId)); + } this._logService.trace(`Start Tool ${toolName || ''}`); }))); disposables.add(toDisposable(this._session.on('tool.execution_complete', (event) => { + // Mark the end of the edit if this was an edit tool. + editTracker.completeEdit(event.data.toolCallId); + const responsePart = processToolExecutionComplete(event, this._pendingToolInvocations); if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) { stream.push(responsePart); @@ -175,6 +192,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private async requestPermission( permissionRequest: PermissionRequest, + stream: vscode.ChatResponseStream, + editTracker: ExternalEditTracker, + getEditKeyForFile: (file: Uri) => string | undefined, toolInvocationToken: vscode.ChatParticipantToolToken ): Promise>> { if (permissionRequest.kind === 'read') { @@ -196,6 +216,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) { + await editTracker.trackEdit(editKey, [editFile], stream); + } + return { kind: 'approved' }; } } catch (error) { diff --git a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts index cd80b07fa4..f8e395a2d0 100644 --- a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts +++ b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts @@ -8,7 +8,6 @@ import * as l10n from '@vscode/l10n'; import type { ChatPromptReference, ExtendedChatResponsePart } from 'vscode'; import { URI } from '../../../../util/vs/base/common/uri'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString, Uri } from '../../../../vscodeTypes'; -import { isCopilotCliEditToolCall } from '../common/copilotcliTools'; /** * CopilotCLI tool names @@ -71,6 +70,33 @@ export function stripReminders(text: string): string { .trim(); } + +export function getAffectedUrisForEditTool(toolName: string, toolArgs: unknown): URI[] { + if (!toolArgs) { + return []; + } + switch (toolName) { + case CopilotCLIToolNames.StrReplaceEditor: { + const args = toolArgs as StrReplaceEditorArgs; + if (args.path && args.command !== 'view') { + return [URI.file(args.path)]; + } + return []; + } + case CopilotCLIToolNames.edit: + case CopilotCLIToolNames.View: + case CopilotCLIToolNames.create: { + const args = toolArgs as (EditArgs | ViewArgs | CreateArgs); + if (args.path) { + return [URI.file(args.path)]; + } + return []; + } + } + + return []; +} + /** * Extract PR metadata from assistant message content */ @@ -217,7 +243,7 @@ export function createCopilotCLIToolInvocation( if (toolName === CopilotCLIToolNames.ReportIntent) { return undefined; // Ignore these for now } - if (isCopilotCliEditToolCall(toolName, args)) { + if (getAffectedUrisForEditTool(toolName, args).length) { return undefined; } if (toolName === CopilotCLIToolNames.Think) { From 9e93e51348e5a63ce66597646b6f58eed8305133 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 31 Oct 2025 10:13:33 +1100 Subject: [PATCH 7/8] Fix test --- .../copilotcli/node/test/copilotCliSessionService.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts b/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts index e78b625868..4d8436c2fb 100644 --- a/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/src/extension/agents/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -194,12 +194,12 @@ describe('CopilotCLISessionService', () => { expect(session2).toBe(session1); }); - it('lists active (non-persisted) sessions', async () => { + it('should not lists active (non-persisted) sessions', async () => { const service = instantiationService.createInstance(CopilotCLISessionService); await service.createSession('List Me', undefined, CancellationToken.None); const all = await service.getAllSessions(CancellationToken.None); const labels = all.map(s => s.label); - expect(labels.some(l => l.includes('List Me'))).toBe(true); + expect(labels.some(l => l.includes('List Me'))).toBe(false); }); it('generates truncated label for persisted session user message', async () => { From 9a5175392282e346d592b82675c7855ce2c28e62 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 31 Oct 2025 10:14:56 +1100 Subject: [PATCH 8/8] Fix test --- .../agents/copilotcli/node/copilotcliToolInvocationFormatter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts index f8e395a2d0..a3b2c7f190 100644 --- a/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts +++ b/src/extension/agents/copilotcli/node/copilotcliToolInvocationFormatter.ts @@ -84,7 +84,6 @@ export function getAffectedUrisForEditTool(toolName: string, toolArgs: unknown): return []; } case CopilotCLIToolNames.edit: - case CopilotCLIToolNames.View: case CopilotCLIToolNames.create: { const args = toolArgs as (EditArgs | ViewArgs | CreateArgs); if (args.path) {