mirror of
https://github.com/VSCodium/vscodium.git
synced 2026-04-23 19:40:14 +10:00
5263 lines
200 KiB
Diff
5263 lines
200 KiB
Diff
diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts
|
||
index 2c44fc94..d85dadfe 100644
|
||
--- a/build/gulpfile.reh.ts
|
||
+++ b/build/gulpfile.reh.ts
|
||
@@ -30,3 +30,3 @@ import rceditCallback from 'rcedit';
|
||
import { compileBuildWithManglingTask } from './gulpfile.compile.ts';
|
||
-import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts';
|
||
+import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask } from './gulpfile.extensions.ts';
|
||
import { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } from './gulpfile.vscode.web.ts';
|
||
@@ -36,3 +36,2 @@ import buildfile from './buildfile.ts';
|
||
import { fetchUrls, fetchGithub } from './lib/fetch.ts';
|
||
-import { getCopilotExcludeFilter, copyCopilotNativeDeps, prepareBuiltInCopilotExtensionShims } from './lib/copilot.ts';
|
||
import jsonEditor from 'gulp-json-editor';
|
||
@@ -346,3 +345,2 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN
|
||
.pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`)))
|
||
- .pipe(filter(getCopilotExcludeFilter(platform, arch)))
|
||
.pipe(jsFilter)
|
||
@@ -465,13 +463,2 @@ function patchWin32DependenciesTask(destinationFolderName: string) {
|
||
|
||
-function copyCopilotNativeDepsTaskREH(platform: string, arch: string, destinationFolderName: string) {
|
||
- return async () => {
|
||
- const outputDir = path.join(BUILD_ROOT, destinationFolderName);
|
||
- const nodeModulesDir = path.join(outputDir, 'node_modules');
|
||
- copyCopilotNativeDeps(platform, arch, nodeModulesDir);
|
||
-
|
||
- const builtInCopilotExtensionDir = path.join(outputDir, 'extensions', 'copilot');
|
||
- prepareBuiltInCopilotExtensionShims(platform, arch, builtInCopilotExtensionDir, nodeModulesDir);
|
||
- };
|
||
-}
|
||
-
|
||
/**
|
||
@@ -525,3 +512,2 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) {
|
||
packageTask(type, platform, arch, sourceFolderName, destinationFolderName),
|
||
- copyCopilotNativeDepsTaskREH(platform, arch, destinationFolderName)
|
||
];
|
||
@@ -539,3 +525,2 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) {
|
||
compileNonNativeExtensionsBuildTask,
|
||
- compileCopilotExtensionBuildTask,
|
||
compileExtensionMediaBuildTask,
|
||
diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts
|
||
index 93b5f711..d8084ab1 100644
|
||
--- a/build/gulpfile.vscode.ts
|
||
+++ b/build/gulpfile.vscode.ts
|
||
@@ -31,5 +31,4 @@ import minimist from 'minimist';
|
||
import { compileBuildWithoutManglingTask } from './gulpfile.compile.ts';
|
||
-import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts';
|
||
+import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts';
|
||
import { copyCodiconsTask } from './lib/compilation.ts';
|
||
-import { getCopilotExcludeFilter, copyCopilotNativeDeps, prepareBuiltInCopilotExtensionShims } from './lib/copilot.ts';
|
||
import type { EmbeddedProductInfo } from './lib/embeddedType.ts';
|
||
@@ -446,3 +445,2 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
|
||
.pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`)))
|
||
- .pipe(filter(getCopilotExcludeFilter(platform, arch)))
|
||
.pipe(jsFilter)
|
||
@@ -453,3 +451,2 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
|
||
'**/@vscode/ripgrep/bin/*',
|
||
- '**/@github/copilot-*/**',
|
||
'**/node-pty/build/Release/*',
|
||
@@ -691,20 +688,2 @@ function patchWin32DependenciesTask(destinationFolderName: string) {
|
||
|
||
-function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) {
|
||
- const outputDir = path.join(path.dirname(root), destinationFolderName);
|
||
-
|
||
- return async () => {
|
||
- // On Windows with win32VersionedUpdate, app resources live under a
|
||
- // commit-hash prefix: {output}/{commitHash}/resources/app/
|
||
- const versionedResourcesFolder = util.getVersionedResourcesFolder(platform, commit!);
|
||
- const appBase = platform === 'darwin'
|
||
- ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app')
|
||
- : path.join(outputDir, versionedResourcesFolder, 'resources', 'app');
|
||
- const appNodeModulesDir = path.join(appBase, 'node_modules');
|
||
- copyCopilotNativeDeps(platform, arch, appNodeModulesDir);
|
||
-
|
||
- const builtInCopilotExtensionDir = path.join(appBase, 'extensions', 'copilot');
|
||
- prepareBuiltInCopilotExtensionShims(platform, arch, builtInCopilotExtensionDir, appNodeModulesDir);
|
||
- };
|
||
-}
|
||
-
|
||
const buildRoot = path.dirname(root);
|
||
@@ -734,3 +713,2 @@ BUILD_TARGETS.forEach(buildTarget => {
|
||
packageTask(platform, arch, sourceFolderName, destinationFolderName, opts),
|
||
- copyCopilotNativeDepsTask(platform, arch, destinationFolderName)
|
||
];
|
||
@@ -761,3 +739,2 @@ BUILD_TARGETS.forEach(buildTarget => {
|
||
compileNonNativeExtensionsBuildTask,
|
||
- compileCopilotExtensionBuildTask,
|
||
compileExtensionMediaBuildTask,
|
||
@@ -782,3 +759,2 @@ BUILD_TARGETS.forEach(buildTarget => {
|
||
compileNonNativeExtensionsBuildTask,
|
||
- compileCopilotExtensionBuildTask,
|
||
compileExtensionMediaBuildTask,
|
||
diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts
|
||
index 74b5422b..db8df90a 100644
|
||
--- a/build/npm/dirs.ts
|
||
+++ b/build/npm/dirs.ts
|
||
@@ -17,3 +17,2 @@ export const dirs = [
|
||
'extensions/configuration-editing',
|
||
- 'extensions/copilot',
|
||
'extensions/css-language-features',
|
||
diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts
|
||
index db659fa7..ee169a6f 100644
|
||
--- a/build/npm/postinstall.ts
|
||
+++ b/build/npm/postinstall.ts
|
||
@@ -316,33 +316,2 @@ async function main() {
|
||
fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents()));
|
||
-
|
||
- // Symlink .claude/ files to their canonical locations to test Claude agent harness
|
||
- const claudeDir = path.join(root, '.claude');
|
||
- fs.mkdirSync(claudeDir, { recursive: true });
|
||
-
|
||
- const claudeMdLink = path.join(claudeDir, 'CLAUDE.md');
|
||
- const claudeMdLinkType = ensureAgentHarnessLink(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink);
|
||
- if (claudeMdLinkType !== 'existing') {
|
||
- log('.', `Created ${claudeMdLinkType} .claude/CLAUDE.md -> .github/copilot-instructions.md`);
|
||
- }
|
||
-
|
||
- const claudeSkillsLink = path.join(claudeDir, 'skills');
|
||
- const claudeSkillsLinkType = ensureAgentHarnessLink(path.join('..', '.agents', 'skills'), claudeSkillsLink);
|
||
- if (claudeSkillsLinkType !== 'existing') {
|
||
- log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`);
|
||
- }
|
||
-
|
||
- // Temporary: patch @github/copilot-sdk session.js to fix ESM import
|
||
- // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32.
|
||
- // TODO: Remove once @github/copilot-sdk is updated to >=0.1.32
|
||
- for (const dir of ['', 'remote']) {
|
||
- const sessionFile = path.join(root, dir, 'node_modules', '@github', 'copilot-sdk', 'dist', 'session.js');
|
||
- if (fs.existsSync(sessionFile)) {
|
||
- const content = fs.readFileSync(sessionFile, 'utf8');
|
||
- const patched = content.replace(/from "vscode-jsonrpc\/node"/g, 'from "vscode-jsonrpc/node.js"');
|
||
- if (content !== patched) {
|
||
- fs.writeFileSync(sessionFile, patched);
|
||
- log(dir || '.', 'Patched @github/copilot-sdk session.js (vscode-jsonrpc ESM import fix)');
|
||
- }
|
||
- }
|
||
- }
|
||
}
|
||
diff --git a/package-lock.json b/package-lock.json
|
||
index 75b98719..86572201 100644
|
||
--- a/package-lock.json
|
||
+++ b/package-lock.json
|
||
@@ -12,5 +12,2 @@
|
||
"dependencies": {
|
||
- "@anthropic-ai/sandbox-runtime": "0.0.42",
|
||
- "@github/copilot": "^1.0.24",
|
||
- "@github/copilot-sdk": "^0.2.2",
|
||
"@microsoft/1ds-core-js": "^3.2.13",
|
||
@@ -188,31 +185,2 @@
|
||
},
|
||
- "node_modules/@anthropic-ai/sandbox-runtime": {
|
||
- "version": "0.0.42",
|
||
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz",
|
||
- "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==",
|
||
- "license": "Apache-2.0",
|
||
- "dependencies": {
|
||
- "@pondwader/socks5-server": "^1.0.10",
|
||
- "@types/lodash-es": "^4.17.12",
|
||
- "commander": "^12.1.0",
|
||
- "lodash-es": "^4.17.23",
|
||
- "shell-quote": "^1.8.3",
|
||
- "zod": "^3.24.1"
|
||
- },
|
||
- "bin": {
|
||
- "srt": "dist/cli.js"
|
||
- },
|
||
- "engines": {
|
||
- "node": ">=18.0.0"
|
||
- }
|
||
- },
|
||
- "node_modules/@anthropic-ai/sandbox-runtime/node_modules/commander": {
|
||
- "version": "12.1.0",
|
||
- "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||
- "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||
- "license": "MIT",
|
||
- "engines": {
|
||
- "node": ">=18"
|
||
- }
|
||
- },
|
||
"node_modules/@appium/logger": {
|
||
@@ -1073,138 +1041,2 @@
|
||
},
|
||
- "node_modules/@github/copilot": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.24.tgz",
|
||
- "integrity": "sha512-/nZ2GwhaGq0HeI3W+6LE0JGw25/bipC6tYVa+oQ5tIvAafBazuNt10CXkeaor+u9oBWLZtPbdTyAzE2tjy9NpQ==",
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "bin": {
|
||
- "copilot": "npm-loader.js"
|
||
- },
|
||
- "optionalDependencies": {
|
||
- "@github/copilot-darwin-arm64": "1.0.24",
|
||
- "@github/copilot-darwin-x64": "1.0.24",
|
||
- "@github/copilot-linux-arm64": "1.0.24",
|
||
- "@github/copilot-linux-x64": "1.0.24",
|
||
- "@github/copilot-win32-arm64": "1.0.24",
|
||
- "@github/copilot-win32-x64": "1.0.24"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-darwin-arm64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.24.tgz",
|
||
- "integrity": "sha512-lejn6KV+09rZEICX3nRx9a58DQFQ2kK3NJ3EICfVLngUCWIUmwn1BLezjeTQc9YNasHltA1hulvfsWqX+VjlMw==",
|
||
- "cpu": [
|
||
- "arm64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "darwin"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-darwin-arm64": "copilot"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-darwin-x64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.24.tgz",
|
||
- "integrity": "sha512-r2F3keTvr4Bunz3V+waRAvsHgqsVQGyIZFBebsNPWxBX1eh3IXgtBqxCR7vXTFyZonQ8VaiJH3YYEfAhyKsk9g==",
|
||
- "cpu": [
|
||
- "x64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "darwin"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-darwin-x64": "copilot"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-linux-arm64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.24.tgz",
|
||
- "integrity": "sha512-B3oANXKKKLhnKYozXa/W+DxfCQAHJCs0QKR5rBwNrwJbf656twNgALSxWTSJk+1rEP6MrHCswUAcwjwZL7Q+FQ==",
|
||
- "cpu": [
|
||
- "arm64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "linux"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-linux-arm64": "copilot"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-linux-x64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.24.tgz",
|
||
- "integrity": "sha512-NGTldizY54B+4Sfhu/GWoEQNMwqqUNgMwbSgBshFv+Hqy1EwuvNWKVov1Y0Vzhp4qAHc6ZxBk/OPIW8Ato9FUg==",
|
||
- "cpu": [
|
||
- "x64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "linux"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-linux-x64": "copilot"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-sdk": {
|
||
- "version": "0.2.2",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.2.2.tgz",
|
||
- "integrity": "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==",
|
||
- "license": "MIT",
|
||
- "dependencies": {
|
||
- "@github/copilot": "^1.0.21",
|
||
- "vscode-jsonrpc": "^8.2.1",
|
||
- "zod": "^4.3.6"
|
||
- },
|
||
- "engines": {
|
||
- "node": ">=20.0.0"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-sdk/node_modules/zod": {
|
||
- "version": "4.3.6",
|
||
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||
- "license": "MIT",
|
||
- "funding": {
|
||
- "url": "https://github.com/sponsors/colinhacks"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-win32-arm64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.24.tgz",
|
||
- "integrity": "sha512-/pd/kgef7/HIIg1SQq4q8fext39pDSC44jHB10KkhfgG1WaDFhQbc/aSSMQfxeldkRbQh6VANp8WtGQdwtMCBA==",
|
||
- "cpu": [
|
||
- "arm64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "win32"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-win32-arm64": "copilot.exe"
|
||
- }
|
||
- },
|
||
- "node_modules/@github/copilot-win32-x64": {
|
||
- "version": "1.0.24",
|
||
- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.24.tgz",
|
||
- "integrity": "sha512-RDvOiSvyEJwELqErwANJTrdRuMIHkwPE4QK7Le7WsmaSKxiuS4H1Pa8Yxnd2FWrMsCHEbase23GJlymbnGYLXQ==",
|
||
- "cpu": [
|
||
- "x64"
|
||
- ],
|
||
- "license": "SEE LICENSE IN LICENSE.md",
|
||
- "optional": true,
|
||
- "os": [
|
||
- "win32"
|
||
- ],
|
||
- "bin": {
|
||
- "copilot-win32-x64": "copilot.exe"
|
||
- }
|
||
- },
|
||
"node_modules/@gulp-sourcemaps/identity-map": {
|
||
@@ -2450,8 +2282,2 @@
|
||
},
|
||
- "node_modules/@pondwader/socks5-server": {
|
||
- "version": "1.0.10",
|
||
- "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||
- "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||
- "license": "MIT"
|
||
- },
|
||
"node_modules/@promptbook/utils": {
|
||
@@ -2778,17 +2604,2 @@
|
||
},
|
||
- "node_modules/@types/lodash": {
|
||
- "version": "4.17.23",
|
||
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||
- "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||
- "license": "MIT"
|
||
- },
|
||
- "node_modules/@types/lodash-es": {
|
||
- "version": "4.17.12",
|
||
- "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||
- "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||
- "license": "MIT",
|
||
- "dependencies": {
|
||
- "@types/lodash": "*"
|
||
- }
|
||
- },
|
||
"node_modules/@types/minimatch": {
|
||
@@ -13284,8 +13095,2 @@
|
||
},
|
||
- "node_modules/lodash-es": {
|
||
- "version": "4.18.1",
|
||
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||
- "license": "MIT"
|
||
- },
|
||
"node_modules/lodash.camelcase": {
|
||
@@ -17223,2 +17028,3 @@
|
||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||
+ "dev": true,
|
||
"license": "MIT",
|
||
@@ -19610,11 +19416,2 @@
|
||
},
|
||
- "node_modules/vscode-jsonrpc": {
|
||
- "version": "8.2.1",
|
||
- "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||
- "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||
- "license": "MIT",
|
||
- "engines": {
|
||
- "node": ">=14.0.0"
|
||
- }
|
||
- },
|
||
"node_modules/vscode-oniguruma": {
|
||
@@ -20328,2 +20125,3 @@
|
||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||
+ "dev": true,
|
||
"license": "MIT",
|
||
diff --git a/package.json b/package.json
|
||
index b925a690..86fdd1f6 100644
|
||
--- a/package.json
|
||
+++ b/package.json
|
||
@@ -23,3 +23,3 @@
|
||
"compile-check-ts-native": "tsgo --project ./src/tsconfig.json --noEmit --skipLibCheck",
|
||
- "watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions watch-copilot",
|
||
+ "watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions",
|
||
"watchd": "deemon npm run watch",
|
||
@@ -40,5 +40,2 @@
|
||
"kill-watch-extensionsd": "deemon --kill npm run watch-extensions",
|
||
- "watch-copilot": "npm --prefix extensions/copilot run watch",
|
||
- "watch-copilotd": "deemon npm run watch-copilot",
|
||
- "kill-watch-copilotd": "deemon --kill npm run watch-copilot",
|
||
"precommit": "node --experimental-strip-types build/hygiene.ts",
|
||
@@ -81,4 +78,2 @@
|
||
"perf": "node scripts/code-perf.js",
|
||
- "copilot:setup": "npm --prefix extensions/copilot run setup",
|
||
- "copilot:get_token": "npm --prefix extensions/copilot run get_token",
|
||
"update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)",
|
||
@@ -89,5 +84,2 @@
|
||
"dependencies": {
|
||
- "@anthropic-ai/sandbox-runtime": "0.0.42",
|
||
- "@github/copilot": "^1.0.24",
|
||
- "@github/copilot-sdk": "^0.2.2",
|
||
"@microsoft/1ds-core-js": "^3.2.13",
|
||
diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts
|
||
index d6b6d42f..c891d99c 100644
|
||
--- a/src/vs/platform/agentHost/node/agentHostMain.ts
|
||
+++ b/src/vs/platform/agentHost/node/agentHostMain.ts
|
||
@@ -16,3 +16,2 @@ import { AgentService } from './agentService.js';
|
||
import { IAgentHostTerminalManager } from './agentHostTerminalManager.js';
|
||
-import { CopilotAgent } from './copilot/copilotAgent.js';
|
||
import { ProtocolServerHandler } from './protocolServerHandler.js';
|
||
@@ -33,3 +32,2 @@ import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.
|
||
import { Schemas } from '../../../base/common/network.js';
|
||
-import { InstantiationService } from '../../instantiation/common/instantiationService.js';
|
||
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
|
||
@@ -93,4 +91,2 @@ function startAgentHost(): void {
|
||
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
|
||
- const instantiationService = new InstantiationService(diServices);
|
||
- agentService.registerProvider(instantiationService.createInstance(CopilotAgent));
|
||
} catch (err) {
|
||
diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts
|
||
index 2234c1de..767b4267 100644
|
||
--- a/src/vs/platform/agentHost/node/agentHostServerMain.ts
|
||
+++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts
|
||
@@ -29,5 +29,3 @@ import product from '../../product/common/product.js';
|
||
import { IProductService } from '../../product/common/productService.js';
|
||
-import { InstantiationService } from '../../instantiation/common/instantiationService.js';
|
||
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
|
||
-import { CopilotAgent } from './copilot/copilotAgent.js';
|
||
import { AgentService } from './agentService.js';
|
||
@@ -175,6 +173,2 @@ async function main(): Promise<void> {
|
||
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
|
||
- const instantiationService = new InstantiationService(diServices);
|
||
- const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
|
||
- agentService.registerProvider(copilotAgent);
|
||
- log('CopilotAgent registered');
|
||
}
|
||
diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts
|
||
index 13561b5e..d5b5517e 100644
|
||
--- a/src/vs/platform/agentHost/node/agentService.ts
|
||
+++ b/src/vs/platform/agentHost/node/agentService.ts
|
||
@@ -33,3 +33,3 @@ import { AgentHostStateManager } from './agentHostStateManager.js';
|
||
*/
|
||
-function extractSubagentMeta(start: IAgentToolStartEvent | undefined): { subagentDescription?: string; subagentAgentName?: string } {
|
||
+function extractSubagentMeta(start: IAgentToolStartEvent | undefined): { subagentDescription?: string; subagentAgentName?: string; } {
|
||
if (!start?.toolKind || start.toolKind !== 'subagent' || !start.toolArguments) {
|
||
@@ -558,3 +558,3 @@ export class AgentService extends Disposable implements IAgentService {
|
||
id: string;
|
||
- userMessage: { text: string };
|
||
+ userMessage: { text: string; };
|
||
responseParts: IResponsePart[];
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
|
||
deleted file mode 100644
|
||
index 565e4160..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
|
||
+++ /dev/null
|
||
@@ -1,696 +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 { CopilotClient } from '@github/copilot-sdk';
|
||
-import { rgPath } from '@vscode/ripgrep';
|
||
-import { Limiter, SequencerByKey } from '../../../../base/common/async.js';
|
||
-import { Emitter } from '../../../../base/common/event.js';
|
||
-import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
|
||
-import { FileAccess } from '../../../../base/common/network.js';
|
||
-import { delimiter, dirname } from '../../../../base/common/path.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { generateUuid } from '../../../../base/common/uuid.js';
|
||
-import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginParsers.js';
|
||
-import { IFileService } from '../../../files/common/files.js';
|
||
-import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||
-import { ILogService } from '../../../log/common/log.js';
|
||
-import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
|
||
-import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||
-import { ISessionDataService } from '../../common/sessionDataService.js';
|
||
-import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
|
||
-import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js';
|
||
-import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js';
|
||
-import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||
-import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js';
|
||
-import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
|
||
-import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
|
||
-import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
|
||
-import { createShellTools, ShellManager } from './copilotShellTools.js';
|
||
-
|
||
-/**
|
||
- * Agent provider backed by the Copilot SDK {@link CopilotClient}.
|
||
- */
|
||
-export class CopilotAgent extends Disposable implements IAgent {
|
||
- readonly id = 'copilot' as const;
|
||
-
|
||
- private readonly _onDidSessionProgress = this._register(new Emitter<IAgentProgressEvent>());
|
||
- readonly onDidSessionProgress = this._onDidSessionProgress.event;
|
||
-
|
||
- private _client: CopilotClient | undefined;
|
||
- private _clientStarting: Promise<CopilotClient> | undefined;
|
||
- private _githubToken: string | undefined;
|
||
- private readonly _sessions = this._register(new DisposableMap<string, CopilotAgentSession>());
|
||
- private readonly _sessionSequencer = new SequencerByKey<string>();
|
||
- private readonly _plugins: PluginController;
|
||
-
|
||
- constructor(
|
||
- @ILogService private readonly _logService: ILogService,
|
||
- @IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||
- @IFileService private readonly _fileService: IFileService,
|
||
- @ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||
- @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
|
||
- ) {
|
||
- super();
|
||
- this._plugins = this._instantiationService.createInstance(PluginController);
|
||
- }
|
||
-
|
||
- // ---- auth ---------------------------------------------------------------
|
||
-
|
||
- getDescriptor(): IAgentDescriptor {
|
||
- return {
|
||
- provider: 'copilot',
|
||
- displayName: 'Copilot',
|
||
- description: 'Copilot SDK agent running in a dedicated process',
|
||
- };
|
||
- }
|
||
-
|
||
- getProtectedResources(): IProtectedResourceMetadata[] {
|
||
- return [{
|
||
- resource: 'https://api.github.com',
|
||
- resource_name: 'GitHub Copilot',
|
||
- authorization_servers: ['https://github.com/login/oauth'],
|
||
- scopes_supported: ['read:user', 'user:email'],
|
||
- required: true,
|
||
- }];
|
||
- }
|
||
-
|
||
- async authenticate(resource: string, token: string): Promise<boolean> {
|
||
- if (resource !== 'https://api.github.com') {
|
||
- return false;
|
||
- }
|
||
- const tokenChanged = this._githubToken !== token;
|
||
- this._githubToken = token;
|
||
- this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`);
|
||
- if (tokenChanged && this._client && this._sessions.size === 0) {
|
||
- this._logService.info('[Copilot] Restarting CopilotClient with new token');
|
||
- const client = this._client;
|
||
- this._client = undefined;
|
||
- this._clientStarting = undefined;
|
||
- await client.stop();
|
||
- }
|
||
- return true;
|
||
- }
|
||
-
|
||
- // ---- client lifecycle ---------------------------------------------------
|
||
-
|
||
- private async _ensureClient(): Promise<CopilotClient> {
|
||
- if (this._client) {
|
||
- return this._client;
|
||
- }
|
||
- if (this._clientStarting) {
|
||
- return this._clientStarting;
|
||
- }
|
||
- this._clientStarting = (async () => {
|
||
- this._logService.info(`[Copilot] Starting CopilotClient... ${this._githubToken ? '(with token)' : '(no token)'}`);
|
||
-
|
||
- // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars
|
||
- // that can interfere with the Node.js process the SDK spawns.
|
||
- const env: Record<string, string | undefined> = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' });
|
||
- delete env['NODE_OPTIONS'];
|
||
- delete env['VSCODE_INSPECTOR_OPTIONS'];
|
||
- delete env['VSCODE_ESM_ENTRYPOINT'];
|
||
- delete env['VSCODE_HANDLES_UNCAUGHT_ERRORS'];
|
||
- for (const key of Object.keys(env)) {
|
||
- if (key === 'ELECTRON_RUN_AS_NODE') {
|
||
- continue;
|
||
- }
|
||
- if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) {
|
||
- delete env[key];
|
||
- }
|
||
- }
|
||
- env['COPILOT_CLI_RUN_AS_NODE'] = '1';
|
||
- env['USE_BUILTIN_RIPGREP'] = 'false';
|
||
-
|
||
- // Resolve the CLI entry point from node_modules. We can't use require.resolve()
|
||
- // because @github/copilot's exports map blocks direct subpath access.
|
||
- // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up.
|
||
- const cliPath = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@github', 'copilot', 'index.js').fsPath;
|
||
-
|
||
- // Add VS Code's built-in ripgrep to PATH so the CLI subprocess can find it.
|
||
- // If @vscode/ripgrep is in an .asar file, the binary is unpacked.
|
||
- const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked');
|
||
- const rgDir = dirname(rgDiskPath);
|
||
- // On Windows the env key is typically "Path" (not "PATH"). Since we copied
|
||
- // process.env into a plain (case-sensitive) object, we must find the actual key.
|
||
- const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH';
|
||
- const currentPath = env[pathKey];
|
||
- env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir;
|
||
- this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`);
|
||
-
|
||
- const client = new CopilotClient({
|
||
- githubToken: this._githubToken,
|
||
- useLoggedInUser: !this._githubToken,
|
||
- useStdio: true,
|
||
- autoStart: true,
|
||
- env,
|
||
- cliPath,
|
||
- });
|
||
- await client.start();
|
||
- this._logService.info('[Copilot] CopilotClient started successfully');
|
||
- this._client = client;
|
||
- this._clientStarting = undefined;
|
||
- return client;
|
||
- })();
|
||
- return this._clientStarting;
|
||
- }
|
||
-
|
||
- // ---- session management -------------------------------------------------
|
||
-
|
||
- async listSessions(): Promise<IAgentSessionMetadata[]> {
|
||
- this._logService.info('[Copilot] Listing sessions...');
|
||
- const client = await this._ensureClient();
|
||
- const sessions = await client.listSessions();
|
||
- const projectLimiter = new Limiter<IAgentSessionProjectInfo | undefined>(4);
|
||
- const projectByContext = new Map<string, Promise<IAgentSessionProjectInfo | undefined>>();
|
||
- const result: IAgentSessionMetadata[] = await Promise.all(sessions.map(async s => {
|
||
- const session = AgentSession.uri(this.id, s.sessionId);
|
||
- let { project, resolved } = await this._readSessionProject(session);
|
||
- if (!resolved) {
|
||
- project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext);
|
||
- this._storeSessionProjectResolution(session, project);
|
||
- }
|
||
- return {
|
||
- session,
|
||
- startTime: s.startTime.getTime(),
|
||
- modifiedTime: s.modifiedTime.getTime(),
|
||
- ...(project ? { project } : {}),
|
||
- summary: s.summary,
|
||
- workingDirectory: typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined,
|
||
- };
|
||
- }));
|
||
- this._logService.info(`[Copilot] Found ${result.length} sessions`);
|
||
- return result;
|
||
- }
|
||
-
|
||
- async listModels(): Promise<IAgentModelInfo[]> {
|
||
- this._logService.info('[Copilot] Listing models...');
|
||
- const client = await this._ensureClient();
|
||
- const models = await client.listModels();
|
||
- const result = models.map(m => ({
|
||
- provider: this.id,
|
||
- id: m.id,
|
||
- name: m.name,
|
||
- maxContextWindow: m.capabilities.limits.max_context_window_tokens,
|
||
- supportsVision: m.capabilities.supports.vision,
|
||
- supportsReasoningEffort: m.capabilities.supports.reasoningEffort,
|
||
- supportedReasoningEfforts: m.supportedReasoningEfforts,
|
||
- defaultReasoningEffort: m.defaultReasoningEffort,
|
||
- policyState: m.policy?.state as PolicyState | undefined,
|
||
- billingMultiplier: m.billing?.multiplier,
|
||
- }));
|
||
- this._logService.info(`[Copilot] Found ${result.length} models`);
|
||
- return result;
|
||
- }
|
||
-
|
||
- async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
|
||
- this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`);
|
||
- const client = await this._ensureClient();
|
||
- const parsedPlugins = await this._plugins.getAppliedPlugins();
|
||
-
|
||
- // When forking, we manipulate the CLI's on-disk data and then resume
|
||
- // instead of creating a fresh session via the SDK.
|
||
- if (config?.fork) {
|
||
- const sourceSessionId = AgentSession.id(config.fork.session);
|
||
- const newSessionId = config.session ? AgentSession.id(config.session) : generateUuid();
|
||
-
|
||
- // Serialize against the source session to prevent concurrent
|
||
- // modifications while we read its on-disk data.
|
||
- return this._sessionSequencer.queue(sourceSessionId, async () => {
|
||
- this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${config.fork!.turnIndex} → ${newSessionId}`);
|
||
-
|
||
- // Ensure the source session is loaded so on-disk data is available
|
||
- if (!this._sessions.has(sourceSessionId)) {
|
||
- await this._resumeSession(sourceSessionId);
|
||
- }
|
||
-
|
||
- const copilotDataDir = getCopilotDataDir();
|
||
- await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, config.fork!.turnIndex);
|
||
-
|
||
- // Resume the forked session so the SDK loads the forked history
|
||
- const agentSession = await this._resumeSession(newSessionId);
|
||
- const session = agentSession.sessionUri;
|
||
- this._logService.info(`[Copilot] Forked session created: ${session.toString()}`);
|
||
- const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath });
|
||
- this._storeSessionMetadata(session, undefined, config.workingDirectory, project, true);
|
||
- return { session, ...(project ? { project } : {}) };
|
||
- });
|
||
- }
|
||
-
|
||
- const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid();
|
||
- const sessionUri = AgentSession.uri(this.id, sessionId);
|
||
- const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri);
|
||
- const sessionConfig = this._buildSessionConfig(parsedPlugins, shellManager);
|
||
-
|
||
- const factory: SessionWrapperFactory = async callbacks => {
|
||
- const raw = await client.createSession({
|
||
- model: config?.model,
|
||
- sessionId,
|
||
- streaming: true,
|
||
- workingDirectory: config?.workingDirectory?.fsPath,
|
||
- ...await sessionConfig(callbacks),
|
||
- });
|
||
- return new CopilotSessionWrapper(raw);
|
||
- };
|
||
-
|
||
- const agentSession = this._createAgentSession(factory, config?.workingDirectory, sessionId, shellManager);
|
||
- this._plugins.setAppliedPlugins(agentSession, parsedPlugins);
|
||
- await agentSession.initializeSession();
|
||
-
|
||
- const session = agentSession.sessionUri;
|
||
- this._logService.info(`[Copilot] Session created: ${session.toString()}`);
|
||
- const project = await projectFromCopilotContext({ cwd: config?.workingDirectory?.fsPath });
|
||
- // Persist model, working directory, and project so we can recreate the
|
||
- // session if the SDK loses it and avoid rediscovering git metadata.
|
||
- this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory, project, true);
|
||
- return { session, ...(project ? { project } : {}) };
|
||
- }
|
||
-
|
||
- async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {
|
||
- return this._plugins.sync(clientId, customizations, progress);
|
||
- }
|
||
-
|
||
- setCustomizationEnabled(uri: string, enabled: boolean): void {
|
||
- this._plugins.setEnabled(uri, enabled);
|
||
- }
|
||
-
|
||
- async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- await this._sessionSequencer.queue(sessionId, async () => {
|
||
-
|
||
- // If plugin config changed, dispose this session so it gets resumed
|
||
- // with the updated plugin primitives.
|
||
- let entry = this._sessions.get(sessionId);
|
||
- if (entry && await this._plugins.needsSessionRefresh(entry)) {
|
||
- this._logService.info(`[Copilot:${sessionId}] Plugin config changed, refreshing session`);
|
||
- this._sessions.deleteAndDispose(sessionId);
|
||
- entry = undefined;
|
||
- }
|
||
-
|
||
- entry ??= await this._resumeSession(sessionId);
|
||
- await entry.send(prompt, attachments, turnId);
|
||
- });
|
||
- }
|
||
-
|
||
- setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void {
|
||
- const sessionId = AgentSession.id(session);
|
||
- const entry = this._sessions.get(sessionId);
|
||
- if (!entry) {
|
||
- this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`);
|
||
- return;
|
||
- }
|
||
-
|
||
- // Steering: send with mode 'immediate' so the SDK injects it mid-turn
|
||
- if (steeringMessage) {
|
||
- entry.sendSteering(steeringMessage);
|
||
- }
|
||
-
|
||
- // Queued messages are consumed by the server (AgentSideEffects)
|
||
- // which dispatches SessionTurnStarted and calls sendMessage directly.
|
||
- // No SDK-level enqueue is needed.
|
||
- }
|
||
-
|
||
- async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined);
|
||
- if (!entry) {
|
||
- return [];
|
||
- }
|
||
- return entry.getMessages();
|
||
- }
|
||
-
|
||
- async disposeSession(session: URI): Promise<void> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- await this._sessionSequencer.queue(sessionId, async () => {
|
||
- this._sessions.deleteAndDispose(sessionId);
|
||
- });
|
||
- }
|
||
-
|
||
- async abortSession(session: URI): Promise<void> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- await this._sessionSequencer.queue(sessionId, async () => {
|
||
- const entry = this._sessions.get(sessionId);
|
||
- if (entry) {
|
||
- await entry.abort();
|
||
- }
|
||
- });
|
||
- }
|
||
-
|
||
- async truncateSession(session: URI, turnIndex?: number): Promise<void> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- await this._sessionSequencer.queue(sessionId, async () => {
|
||
- this._logService.info(`[Copilot:${sessionId}] Truncating session${turnIndex !== undefined ? ` at index ${turnIndex}` : ' (all turns)'}`);
|
||
-
|
||
- const keepUpToTurnIndex = turnIndex ?? -1;
|
||
-
|
||
- // Destroy the SDK session first and wait for cleanup to complete,
|
||
- // ensuring on-disk data (events.jsonl, locks) is released before
|
||
- // we modify it. Then dispose the wrapper.
|
||
- const entry = this._sessions.get(sessionId);
|
||
- if (entry) {
|
||
- await entry.destroySession();
|
||
- }
|
||
- this._sessions.deleteAndDispose(sessionId);
|
||
-
|
||
- const copilotDataDir = getCopilotDataDir();
|
||
- await truncateCopilotSessionOnDisk(copilotDataDir, sessionId, keepUpToTurnIndex);
|
||
-
|
||
- // Resume the session from the modified on-disk data
|
||
- await this._resumeSession(sessionId);
|
||
- this._logService.info(`[Copilot:${sessionId}] Session truncated and resumed`);
|
||
- });
|
||
- }
|
||
-
|
||
- async forkSession(sourceSession: URI, newSessionId: string, turnIndex: number): Promise<void> {
|
||
- const sourceSessionId = AgentSession.id(sourceSession);
|
||
- await this._sessionSequencer.queue(sourceSessionId, async () => {
|
||
- this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${turnIndex} → ${newSessionId}`);
|
||
-
|
||
- const copilotDataDir = getCopilotDataDir();
|
||
- await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, turnIndex);
|
||
- this._logService.info(`[Copilot] Forked session ${newSessionId} created on disk`);
|
||
- });
|
||
- }
|
||
-
|
||
- async changeModel(session: URI, model: string): Promise<void> {
|
||
- const sessionId = AgentSession.id(session);
|
||
- const entry = this._sessions.get(sessionId);
|
||
- if (entry) {
|
||
- await entry.setModel(model);
|
||
- }
|
||
- this._storeSessionMetadata(session, model, undefined, undefined);
|
||
- }
|
||
-
|
||
- async shutdown(): Promise<void> {
|
||
- this._logService.info('[Copilot] Shutting down...');
|
||
- this._sessions.clearAndDisposeAll();
|
||
- await this._client?.stop();
|
||
- this._client = undefined;
|
||
- }
|
||
-
|
||
- respondToPermissionRequest(requestId: string, approved: boolean): void {
|
||
- for (const [, session] of this._sessions) {
|
||
- if (session.respondToPermissionRequest(requestId, approved)) {
|
||
- return;
|
||
- }
|
||
- }
|
||
- }
|
||
-
|
||
- respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record<string, ISessionInputAnswer>): void {
|
||
- for (const [, session] of this._sessions) {
|
||
- if (session.respondToUserInputRequest(requestId, response, answers)) {
|
||
- return;
|
||
- }
|
||
- }
|
||
- }
|
||
-
|
||
- /**
|
||
- * Returns true if this provider owns the given session ID.
|
||
- */
|
||
- hasSession(session: URI): boolean {
|
||
- return this._sessions.has(AgentSession.id(session));
|
||
- }
|
||
-
|
||
- // ---- helpers ------------------------------------------------------------
|
||
-
|
||
- /**
|
||
- * Creates a {@link CopilotAgentSession}, registers it in the sessions map,
|
||
- * and returns it. The caller must call {@link CopilotAgentSession.initializeSession}
|
||
- * to wire up the SDK session.
|
||
- */
|
||
- private _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: URI | undefined, sessionId: string, shellManager: ShellManager): CopilotAgentSession {
|
||
- const sessionUri = AgentSession.uri(this.id, sessionId);
|
||
-
|
||
- const agentSession = this._instantiationService.createInstance(
|
||
- CopilotAgentSession,
|
||
- sessionUri,
|
||
- sessionId,
|
||
- workingDirectory,
|
||
- this._onDidSessionProgress,
|
||
- wrapperFactory,
|
||
- shellManager,
|
||
- );
|
||
-
|
||
- this._sessions.set(sessionId, agentSession);
|
||
- return agentSession;
|
||
- }
|
||
-
|
||
- /**
|
||
- * Builds the common session configuration (plugins + shell tools) shared
|
||
- * by both {@link createSession} and {@link _resumeSession}.
|
||
- *
|
||
- * Returns an async function that resolves the final config given the
|
||
- * session's permission/hook callbacks, so it can be called lazily
|
||
- * inside the {@link SessionWrapperFactory}.
|
||
- */
|
||
- private _buildSessionConfig(parsedPlugins: readonly IParsedPlugin[], shellManager: ShellManager) {
|
||
- const shellTools = createShellTools(shellManager, this._terminalManager, this._logService);
|
||
-
|
||
- return async (callbacks: Parameters<SessionWrapperFactory>[0]) => {
|
||
- const customAgents = await toSdkCustomAgents(parsedPlugins.flatMap(p => p.agents), this._fileService);
|
||
- return {
|
||
- onPermissionRequest: callbacks.onPermissionRequest,
|
||
- onUserInputRequest: callbacks.onUserInputRequest,
|
||
- hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks),
|
||
- mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)),
|
||
- customAgents,
|
||
- skillDirectories: toSdkSkillDirectories(parsedPlugins.flatMap(p => p.skills)),
|
||
- tools: shellTools,
|
||
- };
|
||
- };
|
||
- }
|
||
-
|
||
- private async _resumeSession(sessionId: string): Promise<CopilotAgentSession> {
|
||
- this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`);
|
||
- const client = await this._ensureClient();
|
||
- const parsedPlugins = await this._plugins.getAppliedPlugins();
|
||
-
|
||
- const sessionUri = AgentSession.uri(this.id, sessionId);
|
||
- const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => {
|
||
- this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err);
|
||
- return undefined;
|
||
- });
|
||
- const workingDirectory = typeof sessionMetadata?.context?.cwd === 'string' ? URI.file(sessionMetadata.context.cwd) : undefined;
|
||
- const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri);
|
||
- const sessionConfig = this._buildSessionConfig(parsedPlugins, shellManager);
|
||
-
|
||
- const factory: SessionWrapperFactory = async callbacks => {
|
||
- const config = await sessionConfig(callbacks);
|
||
- try {
|
||
- const raw = await client.resumeSession(sessionId, {
|
||
- ...config,
|
||
- workingDirectory: workingDirectory?.fsPath,
|
||
- });
|
||
- return new CopilotSessionWrapper(raw);
|
||
- } catch (err) {
|
||
- // The SDK fails to resume sessions that have no messages.
|
||
- // Fall back to creating a new session with the same ID,
|
||
- // seeding model & working directory from stored metadata.
|
||
- if (!err || (err as { code?: number }).code !== -32603) {
|
||
- throw err;
|
||
- }
|
||
-
|
||
- this._logService.warn(`[Copilot:${sessionId}] Resume failed (session not found in SDK), recreating`);
|
||
- const metadata = await this._readSessionMetadata(sessionUri);
|
||
- const raw = await client.createSession({
|
||
- ...config,
|
||
- sessionId,
|
||
- streaming: true,
|
||
- model: metadata.model,
|
||
- workingDirectory: metadata.workingDirectory?.fsPath,
|
||
- });
|
||
-
|
||
- return new CopilotSessionWrapper(raw);
|
||
- }
|
||
- };
|
||
-
|
||
- const agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager);
|
||
- this._plugins.setAppliedPlugins(agentSession, parsedPlugins);
|
||
- await agentSession.initializeSession();
|
||
-
|
||
- return agentSession;
|
||
- }
|
||
-
|
||
- // ---- session metadata persistence --------------------------------------
|
||
-
|
||
- private static readonly _META_MODEL = 'copilot.model';
|
||
- private static readonly _META_CWD = 'copilot.workingDirectory';
|
||
- private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved';
|
||
- private static readonly _META_PROJECT_URI = 'copilot.project.uri';
|
||
- private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName';
|
||
-
|
||
- private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): void {
|
||
- const dbRef = this._sessionDataService.tryOpenDatabase(session);
|
||
- dbRef?.then(ref => {
|
||
- if (!ref) {
|
||
- return;
|
||
- }
|
||
- const db = ref.object;
|
||
- const work: Promise<void>[] = [];
|
||
- if (model) {
|
||
- work.push(db.setMetadata(CopilotAgent._META_MODEL, model));
|
||
- }
|
||
- if (workingDirectory) {
|
||
- work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString()));
|
||
- }
|
||
- if (projectResolved) {
|
||
- work.push(db.setMetadata(CopilotAgent._META_PROJECT_RESOLVED, 'true'));
|
||
- }
|
||
- if (project) {
|
||
- work.push(db.setMetadata(CopilotAgent._META_PROJECT_URI, project.uri.toString()));
|
||
- work.push(db.setMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME, project.displayName));
|
||
- }
|
||
- Promise.all(work).finally(() => ref.dispose());
|
||
- });
|
||
- }
|
||
-
|
||
- private async _readSessionMetadata(session: URI): Promise<{ model?: string; workingDirectory?: URI }> {
|
||
- const ref = await this._sessionDataService.tryOpenDatabase(session);
|
||
- if (!ref) {
|
||
- return {};
|
||
- }
|
||
- try {
|
||
- const [model, cwd] = await Promise.all([
|
||
- ref.object.getMetadata(CopilotAgent._META_MODEL),
|
||
- ref.object.getMetadata(CopilotAgent._META_CWD),
|
||
- ]);
|
||
- return {
|
||
- model,
|
||
- workingDirectory: cwd ? URI.parse(cwd) : undefined,
|
||
- };
|
||
- } finally {
|
||
- ref.dispose();
|
||
- }
|
||
- }
|
||
-
|
||
- private async _readSessionProject(session: URI): Promise<{ project?: IAgentSessionProjectInfo; resolved: boolean }> {
|
||
- const ref = await this._sessionDataService.tryOpenDatabase(session);
|
||
- if (!ref) {
|
||
- return { resolved: false };
|
||
- }
|
||
- try {
|
||
- const [resolved, uri, displayName] = await Promise.all([
|
||
- ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED),
|
||
- ref.object.getMetadata(CopilotAgent._META_PROJECT_URI),
|
||
- ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME),
|
||
- ]);
|
||
- const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined;
|
||
- return { project, resolved: resolved === 'true' || project !== undefined };
|
||
- } finally {
|
||
- ref.dispose();
|
||
- }
|
||
- }
|
||
-
|
||
- private _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): void {
|
||
- this._storeSessionMetadata(session, undefined, undefined, project, true);
|
||
- }
|
||
-
|
||
- private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter<IAgentSessionProjectInfo | undefined>, projectByContext: Map<string, Promise<IAgentSessionProjectInfo | undefined>>): Promise<IAgentSessionProjectInfo | undefined> {
|
||
- const key = this._projectContextKey(context);
|
||
- if (!key) {
|
||
- return Promise.resolve(undefined);
|
||
- }
|
||
-
|
||
- let project = projectByContext.get(key);
|
||
- if (!project) {
|
||
- project = limiter.queue(() => projectFromCopilotContext(context));
|
||
- projectByContext.set(key, project);
|
||
- }
|
||
- return project;
|
||
- }
|
||
-
|
||
- private _projectContextKey(context: ICopilotSessionContext | undefined): string | undefined {
|
||
- if (context?.cwd) {
|
||
- return `cwd:${context.cwd}`;
|
||
- }
|
||
- if (context?.gitRoot) {
|
||
- return `gitRoot:${context.gitRoot}`;
|
||
- }
|
||
- if (context?.repository) {
|
||
- return `repository:${context.repository}`;
|
||
- }
|
||
- return undefined;
|
||
- }
|
||
-
|
||
- override dispose(): void {
|
||
- this._client?.stop().catch(() => { /* best-effort */ });
|
||
- super.dispose();
|
||
- }
|
||
-}
|
||
-
|
||
-class PluginController {
|
||
- private readonly _enablement = new Map<string, boolean>();
|
||
- private _lastSynced: Promise<{ synced: ISyncedCustomization[]; parsed: IParsedPlugin[] }> = Promise.resolve({ synced: [], parsed: [] });
|
||
-
|
||
- /** Parsed plugin contents from the most recently applied sync. */
|
||
- private _appliedParsed = new WeakMap<CopilotAgentSession, readonly IParsedPlugin[]>();
|
||
-
|
||
- constructor(
|
||
- @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager,
|
||
- @ILogService private readonly _logService: ILogService,
|
||
- @IFileService private readonly _fileService: IFileService,
|
||
- ) { }
|
||
-
|
||
- /**
|
||
- * Returns true if the plugin configuration has changed since the last
|
||
- * time sessions were created/resumed. Used by {@link CopilotAgent.sendMessage}
|
||
- * to decide whether a session needs to be refreshed.
|
||
- */
|
||
- public async needsSessionRefresh(session: CopilotAgentSession): Promise<boolean> {
|
||
- const { parsed } = await this._lastSynced;
|
||
- return !parsedPluginsEqual(this._appliedParsed.get(session) || [], parsed);
|
||
- }
|
||
-
|
||
- /**
|
||
- * Returns the current parsed plugins filtered by enablement,
|
||
- * then marks them as applied so {@link needsSessionRefresh} returns
|
||
- * false until the next change.
|
||
- */
|
||
- public async getAppliedPlugins(): Promise<readonly IParsedPlugin[]> {
|
||
- const { parsed } = await this._lastSynced;
|
||
- return parsed;
|
||
- }
|
||
-
|
||
- public setAppliedPlugins(session: CopilotAgentSession, plugins: readonly IParsedPlugin[]) {
|
||
- this._appliedParsed.set(session, plugins);
|
||
- }
|
||
-
|
||
- public setEnabled(pluginProtocolUri: string, enabled: boolean) {
|
||
- this._enablement.set(pluginProtocolUri, enabled);
|
||
- }
|
||
-
|
||
- public sync(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) {
|
||
- const prev = this._lastSynced;
|
||
- const promise = this._lastSynced = prev.catch(() => []).then(async () => {
|
||
- const result = await this._pluginManager.syncCustomizations(clientId, customizations, status => {
|
||
- progress?.(status.map(c => ({ customization: c })));
|
||
- });
|
||
-
|
||
-
|
||
- const parsed: IParsedPlugin[] = [];
|
||
- const synced: ISyncedCustomization[] = [];
|
||
- for (const dir of result) {
|
||
- if (dir.pluginDir) {
|
||
- try {
|
||
- parsed.push(await parsePlugin(dir.pluginDir, this._fileService, undefined, this._getUserHome()));
|
||
- synced.push(dir);
|
||
- } catch (e) {
|
||
- this._logService.warn(`[Copilot:PluginController] Error parsing plugin: ${e}`);
|
||
- synced.push({ customization: { ...dir.customization, status: CustomizationStatus.Error, statusMessage: `Error parsing plugin: ${e}` } });
|
||
- }
|
||
- } else {
|
||
- synced.push(dir);
|
||
- }
|
||
- }
|
||
-
|
||
- return { synced, parsed };
|
||
- });
|
||
-
|
||
- return promise.then(p => p.synced);
|
||
- }
|
||
-
|
||
- private _getUserHome(): string {
|
||
- return process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
|
||
- }
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts
|
||
deleted file mode 100644
|
||
index 0631ba51..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts
|
||
+++ /dev/null
|
||
@@ -1,599 +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 * as fs from 'fs';
|
||
-import * as os from 'os';
|
||
-import type { Database } from '@vscode/sqlite3';
|
||
-import { generateUuid } from '../../../../base/common/uuid.js';
|
||
-import * as path from '../../../../base/common/path.js';
|
||
-
|
||
-// ---- Types ------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * A single event entry from a Copilot CLI `events.jsonl` file.
|
||
- * The Copilot CLI stores session history as a newline-delimited JSON log
|
||
- * where events form a linked list via `parentId`.
|
||
- */
|
||
-export interface ICopilotEventLogEntry {
|
||
- readonly type: string;
|
||
- readonly data: Record<string, unknown>;
|
||
- readonly id: string;
|
||
- readonly timestamp: string;
|
||
- readonly parentId: string | null;
|
||
-}
|
||
-
|
||
-// ---- Promise wrappers around callback-based @vscode/sqlite3 API -----------
|
||
-
|
||
-function dbExec(db: Database, sql: string): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.exec(sql, err => err ? reject(err) : resolve());
|
||
- });
|
||
-}
|
||
-
|
||
-function dbRun(db: Database, sql: string, params: unknown[]): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.run(sql, params, function (err: Error | null) {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve();
|
||
- });
|
||
- });
|
||
-}
|
||
-
|
||
-function dbAll(db: Database, sql: string, params: unknown[]): Promise<Record<string, unknown>[]> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.all(sql, params, (err: Error | null, rows: Record<string, unknown>[]) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(rows);
|
||
- });
|
||
- });
|
||
-}
|
||
-
|
||
-function dbClose(db: Database): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.close(err => err ? reject(err) : resolve());
|
||
- });
|
||
-}
|
||
-
|
||
-function dbOpen(dbPath: string): Promise<Database> {
|
||
- return new Promise((resolve, reject) => {
|
||
- import('@vscode/sqlite3').then(sqlite3 => {
|
||
- const db = new sqlite3.default.Database(dbPath, (err: Error | null) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(db);
|
||
- });
|
||
- }, reject);
|
||
- });
|
||
-}
|
||
-
|
||
-// ---- Pure functions (testable, no I/O) ------------------------------------
|
||
-
|
||
-/**
|
||
- * Parses a JSONL string into an array of event log entries.
|
||
- */
|
||
-export function parseEventLog(content: string): ICopilotEventLogEntry[] {
|
||
- const entries: ICopilotEventLogEntry[] = [];
|
||
- for (const line of content.split('\n')) {
|
||
- const trimmed = line.trim();
|
||
- if (trimmed.length === 0) {
|
||
- continue;
|
||
- }
|
||
- entries.push(JSON.parse(trimmed));
|
||
- }
|
||
- return entries;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Serializes an array of event log entries back into a JSONL string.
|
||
- */
|
||
-export function serializeEventLog(entries: readonly ICopilotEventLogEntry[]): string {
|
||
- return entries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
||
-}
|
||
-
|
||
-/**
|
||
- * Finds the index of the last event that belongs to the given turn (0-based).
|
||
- *
|
||
- * A "turn" corresponds to one `user.message` event and all subsequent events
|
||
- * up to (and including) the `assistant.turn_end` that closes that interaction,
|
||
- * or the `session.shutdown` that ends the session.
|
||
- *
|
||
- * @returns The inclusive index of the last event in the specified turn,
|
||
- * or `-1` if the turn is not found.
|
||
- */
|
||
-export function findTurnBoundaryInEventLog(entries: readonly ICopilotEventLogEntry[], turnIndex: number): number {
|
||
- let userMessageCount = -1;
|
||
- let lastEventForTurn = -1;
|
||
-
|
||
- for (let i = 0; i < entries.length; i++) {
|
||
- const entry = entries[i];
|
||
-
|
||
- if (entry.type === 'user.message') {
|
||
- userMessageCount++;
|
||
- if (userMessageCount > turnIndex) {
|
||
- // We've entered the next turn — stop
|
||
- return lastEventForTurn;
|
||
- }
|
||
- }
|
||
-
|
||
- if (userMessageCount === turnIndex) {
|
||
- lastEventForTurn = i;
|
||
- }
|
||
- }
|
||
-
|
||
- // If we scanned everything and the target turn was found, return its last event
|
||
- return lastEventForTurn;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Builds a forked event log from the source session's events.
|
||
- *
|
||
- * - Keeps events up to and including the specified fork turn (0-based).
|
||
- * - Rewrites `session.start` with the new session ID.
|
||
- * - Generates fresh UUIDs for all events.
|
||
- * - Re-chains `parentId` links via an old→new ID map.
|
||
- * - Strips `session.shutdown` and `session.resume` lifecycle events.
|
||
- */
|
||
-export function buildForkedEventLog(
|
||
- entries: readonly ICopilotEventLogEntry[],
|
||
- forkTurnIndex: number,
|
||
- newSessionId: string,
|
||
-): ICopilotEventLogEntry[] {
|
||
- const boundary = findTurnBoundaryInEventLog(entries, forkTurnIndex);
|
||
- if (boundary < 0) {
|
||
- throw new Error(`Fork turn index ${forkTurnIndex} not found in event log`);
|
||
- }
|
||
-
|
||
- // Keep events up to boundary, filtering out lifecycle events
|
||
- const kept = entries
|
||
- .slice(0, boundary + 1)
|
||
- .filter(e => e.type !== 'session.shutdown' && e.type !== 'session.resume');
|
||
-
|
||
- // Build UUID remap and re-chain
|
||
- const idMap = new Map<string, string>();
|
||
- const result: ICopilotEventLogEntry[] = [];
|
||
-
|
||
- for (const entry of kept) {
|
||
- const newId = generateUuid();
|
||
- idMap.set(entry.id, newId);
|
||
-
|
||
- let data = entry.data;
|
||
- if (entry.type === 'session.start') {
|
||
- data = { ...data, sessionId: newSessionId };
|
||
- }
|
||
-
|
||
- const newParentId = entry.parentId !== null
|
||
- ? (idMap.get(entry.parentId) ?? result[result.length - 1]?.id ?? null)
|
||
- : null;
|
||
-
|
||
- result.push({
|
||
- type: entry.type,
|
||
- data,
|
||
- id: newId,
|
||
- timestamp: entry.timestamp,
|
||
- parentId: newParentId,
|
||
- });
|
||
- }
|
||
-
|
||
- return result;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Builds a truncated event log from the source session's events.
|
||
- *
|
||
- * - Keeps events up to and including the specified turn (0-based).
|
||
- * - Prepends a new `session.start` event using the original start data.
|
||
- * - Re-chains `parentId` links for remaining events.
|
||
- */
|
||
-export function buildTruncatedEventLog(
|
||
- entries: readonly ICopilotEventLogEntry[],
|
||
- keepUpToTurnIndex: number,
|
||
-): ICopilotEventLogEntry[] {
|
||
- const boundary = findTurnBoundaryInEventLog(entries, keepUpToTurnIndex);
|
||
- if (boundary < 0) {
|
||
- throw new Error(`Turn index ${keepUpToTurnIndex} not found in event log`);
|
||
- }
|
||
-
|
||
- // Find the original session.start for its metadata
|
||
- const originalStart = entries.find(e => e.type === 'session.start');
|
||
- if (!originalStart) {
|
||
- throw new Error('No session.start event found in event log');
|
||
- }
|
||
-
|
||
- // Keep events from after session start up to boundary, stripping lifecycle events
|
||
- const kept = entries
|
||
- .slice(0, boundary + 1)
|
||
- .filter(e => e.type !== 'session.start' && e.type !== 'session.shutdown' && e.type !== 'session.resume');
|
||
-
|
||
- // Build new start event
|
||
- const newStartId = generateUuid();
|
||
- const newStart: ICopilotEventLogEntry = {
|
||
- type: 'session.start',
|
||
- data: { ...originalStart.data, startTime: new Date().toISOString() },
|
||
- id: newStartId,
|
||
- timestamp: new Date().toISOString(),
|
||
- parentId: null,
|
||
- };
|
||
-
|
||
- // Re-chain: first remaining event points to the new start
|
||
- const idMap = new Map<string, string>();
|
||
- idMap.set(originalStart.id, newStartId);
|
||
-
|
||
- const result: ICopilotEventLogEntry[] = [newStart];
|
||
- let lastId = newStartId;
|
||
-
|
||
- for (const entry of kept) {
|
||
- const newId = generateUuid();
|
||
- idMap.set(entry.id, newId);
|
||
-
|
||
- const newParentId = entry.parentId !== null
|
||
- ? (idMap.get(entry.parentId) ?? lastId)
|
||
- : lastId;
|
||
-
|
||
- result.push({
|
||
- type: entry.type,
|
||
- data: entry.data,
|
||
- id: newId,
|
||
- timestamp: entry.timestamp,
|
||
- parentId: newParentId,
|
||
- });
|
||
- lastId = newId;
|
||
- }
|
||
-
|
||
- return result;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Generates a `workspace.yaml` file content for a Copilot CLI session.
|
||
- */
|
||
-export function buildWorkspaceYaml(sessionId: string, cwd: string, summary: string): string {
|
||
- const now = new Date().toISOString();
|
||
- return [
|
||
- `id: ${sessionId}`,
|
||
- `cwd: ${cwd}`,
|
||
- `summary_count: 0`,
|
||
- `created_at: ${now}`,
|
||
- `updated_at: ${now}`,
|
||
- `summary: ${summary}`,
|
||
- '',
|
||
- ].join('\n');
|
||
-}
|
||
-
|
||
-// ---- SQLite operations (Copilot CLI session-store.db) ---------------------
|
||
-
|
||
-/**
|
||
- * Forks a session record in the Copilot CLI's `session-store.db`.
|
||
- *
|
||
- * Copies the source session's metadata, turns (up to `forkTurnIndex`),
|
||
- * session files, search index entries, and checkpoints into a new session.
|
||
- */
|
||
-export async function forkSessionInDb(
|
||
- db: Database,
|
||
- sourceSessionId: string,
|
||
- newSessionId: string,
|
||
- forkTurnIndex: number,
|
||
-): Promise<void> {
|
||
- await dbExec(db, 'PRAGMA foreign_keys = ON');
|
||
- await dbExec(db, 'BEGIN TRANSACTION');
|
||
- try {
|
||
- const now = new Date().toISOString();
|
||
-
|
||
- // Copy session row
|
||
- await dbRun(db,
|
||
- `INSERT INTO sessions (id, cwd, repository, branch, summary, created_at, updated_at, host_type)
|
||
- SELECT ?, cwd, repository, branch, summary, ?, ?, host_type
|
||
- FROM sessions WHERE id = ?`,
|
||
- [newSessionId, now, now, sourceSessionId],
|
||
- );
|
||
-
|
||
- // Copy turns up to fork point (turn_index is 0-based)
|
||
- await dbRun(db,
|
||
- `INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp)
|
||
- SELECT ?, turn_index, user_message, assistant_response, timestamp
|
||
- FROM turns
|
||
- WHERE session_id = ? AND turn_index <= ?`,
|
||
- [newSessionId, sourceSessionId, forkTurnIndex],
|
||
- );
|
||
-
|
||
- // Copy session files that were first seen at or before the fork point
|
||
- await dbRun(db,
|
||
- `INSERT INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at)
|
||
- SELECT ?, file_path, tool_name, turn_index, first_seen_at
|
||
- FROM session_files
|
||
- WHERE session_id = ? AND turn_index <= ?`,
|
||
- [newSessionId, sourceSessionId, forkTurnIndex],
|
||
- );
|
||
-
|
||
- // Copy search index entries for kept turns only.
|
||
- // source_id format is "<session_id>:turn:<turn_index>"; filter by
|
||
- // parsing the turn index so we don't leak content from later turns.
|
||
- await dbAll(db,
|
||
- `SELECT content, source_type, source_id
|
||
- FROM search_index
|
||
- WHERE session_id = ? AND source_type = 'turn'`,
|
||
- [sourceSessionId],
|
||
- ).then(async rows => {
|
||
- const prefix = `${sourceSessionId}:turn:`;
|
||
- for (const row of rows) {
|
||
- const sourceId = row.source_id as string;
|
||
- if (sourceId.startsWith(prefix)) {
|
||
- const turnIdx = parseInt(sourceId.substring(prefix.length), 10);
|
||
- if (!isNaN(turnIdx) && turnIdx <= forkTurnIndex) {
|
||
- const newSourceId = sourceId.replace(sourceSessionId, newSessionId);
|
||
- await dbRun(db,
|
||
- `INSERT INTO search_index (content, session_id, source_type, source_id)
|
||
- VALUES (?, ?, ?, ?)`,
|
||
- [row.content, newSessionId, row.source_type, newSourceId],
|
||
- );
|
||
- }
|
||
- }
|
||
- }
|
||
- });
|
||
-
|
||
- // Copy checkpoints at or before the fork point.
|
||
- // checkpoint_number is 1-based and correlates to turns, so we keep
|
||
- // only those where checkpoint_number <= forkTurnIndex + 1.
|
||
- await dbRun(db,
|
||
- `INSERT INTO checkpoints (session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at)
|
||
- SELECT ?, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at
|
||
- FROM checkpoints
|
||
- WHERE session_id = ? AND checkpoint_number <= ?`,
|
||
- [newSessionId, sourceSessionId, forkTurnIndex + 1],
|
||
- );
|
||
-
|
||
- await dbExec(db, 'COMMIT');
|
||
- } catch (err) {
|
||
- await dbExec(db, 'ROLLBACK');
|
||
- throw err;
|
||
- }
|
||
-}
|
||
-
|
||
-/**
|
||
- * Truncates a session in the Copilot CLI's `session-store.db`.
|
||
- *
|
||
- * Removes all turns after `keepUpToTurnIndex` and updates session metadata.
|
||
- */
|
||
-export async function truncateSessionInDb(
|
||
- db: Database,
|
||
- sessionId: string,
|
||
- keepUpToTurnIndex: number,
|
||
-): Promise<void> {
|
||
- await dbExec(db, 'PRAGMA foreign_keys = ON');
|
||
- await dbExec(db, 'BEGIN TRANSACTION');
|
||
- try {
|
||
- const now = new Date().toISOString();
|
||
-
|
||
- // Delete turns after the truncation point
|
||
- await dbRun(db,
|
||
- `DELETE FROM turns WHERE session_id = ? AND turn_index > ?`,
|
||
- [sessionId, keepUpToTurnIndex],
|
||
- );
|
||
-
|
||
- // Update session timestamp
|
||
- await dbRun(db,
|
||
- `UPDATE sessions SET updated_at = ? WHERE id = ?`,
|
||
- [now, sessionId],
|
||
- );
|
||
-
|
||
- // Remove search index entries for removed turns
|
||
- // source_id format is "<session_id>:turn:<turn_index>"
|
||
- await dbAll(db,
|
||
- `SELECT source_id FROM search_index
|
||
- WHERE session_id = ? AND source_type = 'turn'`,
|
||
- [sessionId],
|
||
- ).then(async rows => {
|
||
- const prefix = `${sessionId}:turn:`;
|
||
- for (const row of rows) {
|
||
- const sourceId = row.source_id as string;
|
||
- if (sourceId.startsWith(prefix)) {
|
||
- const turnIdx = parseInt(sourceId.substring(prefix.length), 10);
|
||
- if (!isNaN(turnIdx) && turnIdx > keepUpToTurnIndex) {
|
||
- await dbRun(db,
|
||
- `DELETE FROM search_index WHERE source_id = ? AND session_id = ?`,
|
||
- [sourceId, sessionId],
|
||
- );
|
||
- }
|
||
- }
|
||
- }
|
||
- });
|
||
-
|
||
- await dbExec(db, 'COMMIT');
|
||
- } catch (err) {
|
||
- await dbExec(db, 'ROLLBACK');
|
||
- throw err;
|
||
- }
|
||
-}
|
||
-
|
||
-// ---- File system operations -----------------------------------------------
|
||
-
|
||
-/**
|
||
- * Resolves the Copilot CLI data directory.
|
||
- * The Copilot CLI stores its data in `~/.copilot/` by default, or in the
|
||
- * directory specified by `COPILOT_CONFIG_DIR`.
|
||
- */
|
||
-export function getCopilotDataDir(): string {
|
||
- return process.env['COPILOT_CONFIG_DIR'] ?? path.join(os.homedir(), '.copilot');
|
||
-}
|
||
-
|
||
-/**
|
||
- * Forks a Copilot CLI session on disk.
|
||
- *
|
||
- * 1. Reads the source session's `events.jsonl`
|
||
- * 2. Builds a forked event log
|
||
- * 3. Creates the new session folder with all required files/directories
|
||
- * 4. Updates the `session-store.db`
|
||
- *
|
||
- * @param copilotDataDir Path to the `.copilot` directory
|
||
- * @param sourceSessionId UUID of the source session to fork from
|
||
- * @param newSessionId UUID for the new forked session
|
||
- * @param forkTurnIndex 0-based turn index to fork at (inclusive)
|
||
- */
|
||
-export async function forkCopilotSessionOnDisk(
|
||
- copilotDataDir: string,
|
||
- sourceSessionId: string,
|
||
- newSessionId: string,
|
||
- forkTurnIndex: number,
|
||
-): Promise<void> {
|
||
- const sessionStateDir = path.join(copilotDataDir, 'session-state');
|
||
-
|
||
- // Read source events
|
||
- const sourceEventsPath = path.join(sessionStateDir, sourceSessionId, 'events.jsonl');
|
||
- const sourceContent = await fs.promises.readFile(sourceEventsPath, 'utf-8');
|
||
- const sourceEntries = parseEventLog(sourceContent);
|
||
-
|
||
- // Build forked event log
|
||
- const forkedEntries = buildForkedEventLog(sourceEntries, forkTurnIndex, newSessionId);
|
||
-
|
||
- // Read source workspace.yaml for cwd/summary
|
||
- let cwd = '';
|
||
- let summary = '';
|
||
- try {
|
||
- const workspaceYamlPath = path.join(sessionStateDir, sourceSessionId, 'workspace.yaml');
|
||
- const yamlContent = await fs.promises.readFile(workspaceYamlPath, 'utf-8');
|
||
- const cwdMatch = yamlContent.match(/^cwd:\s*(.+)$/m);
|
||
- const summaryMatch = yamlContent.match(/^summary:\s*(.+)$/m);
|
||
- if (cwdMatch) {
|
||
- cwd = cwdMatch[1].trim();
|
||
- }
|
||
- if (summaryMatch) {
|
||
- summary = summaryMatch[1].trim();
|
||
- }
|
||
- } catch {
|
||
- // Fall back to session.start data
|
||
- const startEvent = sourceEntries.find(e => e.type === 'session.start');
|
||
- if (startEvent) {
|
||
- const ctx = startEvent.data.context as Record<string, string> | undefined;
|
||
- cwd = ctx?.cwd ?? '';
|
||
- }
|
||
- }
|
||
-
|
||
- // Create new session folder structure
|
||
- const newSessionDir = path.join(sessionStateDir, newSessionId);
|
||
- await fs.promises.mkdir(path.join(newSessionDir, 'checkpoints'), { recursive: true });
|
||
- await fs.promises.mkdir(path.join(newSessionDir, 'files'), { recursive: true });
|
||
- await fs.promises.mkdir(path.join(newSessionDir, 'research'), { recursive: true });
|
||
-
|
||
- // Write events.jsonl
|
||
- await fs.promises.writeFile(
|
||
- path.join(newSessionDir, 'events.jsonl'),
|
||
- serializeEventLog(forkedEntries),
|
||
- 'utf-8',
|
||
- );
|
||
-
|
||
- // Write workspace.yaml
|
||
- await fs.promises.writeFile(
|
||
- path.join(newSessionDir, 'workspace.yaml'),
|
||
- buildWorkspaceYaml(newSessionId, cwd, summary),
|
||
- 'utf-8',
|
||
- );
|
||
-
|
||
- // Write empty vscode.metadata.json
|
||
- await fs.promises.writeFile(
|
||
- path.join(newSessionDir, 'vscode.metadata.json'),
|
||
- '{}',
|
||
- 'utf-8',
|
||
- );
|
||
-
|
||
- // Write empty checkpoints index
|
||
- await fs.promises.writeFile(
|
||
- path.join(newSessionDir, 'checkpoints', 'index.md'),
|
||
- '',
|
||
- 'utf-8',
|
||
- );
|
||
-
|
||
- // Update session-store.db
|
||
- const dbPath = path.join(copilotDataDir, 'session-store.db');
|
||
- const db = await dbOpen(dbPath);
|
||
- try {
|
||
- await forkSessionInDb(db, sourceSessionId, newSessionId, forkTurnIndex);
|
||
- } finally {
|
||
- await dbClose(db);
|
||
- }
|
||
-}
|
||
-
|
||
-/**
|
||
- * Truncates a Copilot CLI session on disk.
|
||
- *
|
||
- * 1. Reads the session's `events.jsonl`
|
||
- * 2. Builds a truncated event log
|
||
- * 3. Overwrites `events.jsonl` and updates `workspace.yaml`
|
||
- * 4. Updates the `session-store.db`
|
||
- *
|
||
- * @param copilotDataDir Path to the `.copilot` directory
|
||
- * @param sessionId UUID of the session to truncate
|
||
- * @param keepUpToTurnIndex 0-based turn index to keep up to (inclusive)
|
||
- */
|
||
-export async function truncateCopilotSessionOnDisk(
|
||
- copilotDataDir: string,
|
||
- sessionId: string,
|
||
- keepUpToTurnIndex: number,
|
||
-): Promise<void> {
|
||
- const sessionStateDir = path.join(copilotDataDir, 'session-state');
|
||
- const sessionDir = path.join(sessionStateDir, sessionId);
|
||
-
|
||
- // Read and truncate events
|
||
- const eventsPath = path.join(sessionDir, 'events.jsonl');
|
||
- const content = await fs.promises.readFile(eventsPath, 'utf-8');
|
||
- const entries = parseEventLog(content);
|
||
-
|
||
- let truncatedEntries: ICopilotEventLogEntry[];
|
||
- if (keepUpToTurnIndex < 0) {
|
||
- // Truncate all turns: keep only a fresh session.start event
|
||
- const originalStart = entries.find(e => e.type === 'session.start');
|
||
- if (!originalStart) {
|
||
- throw new Error('No session.start event found in event log');
|
||
- }
|
||
- truncatedEntries = [{
|
||
- type: 'session.start',
|
||
- data: { ...originalStart.data, startTime: new Date().toISOString() },
|
||
- id: generateUuid(),
|
||
- timestamp: new Date().toISOString(),
|
||
- parentId: null,
|
||
- }];
|
||
- } else {
|
||
- truncatedEntries = buildTruncatedEventLog(entries, keepUpToTurnIndex);
|
||
- }
|
||
-
|
||
- // Overwrite events.jsonl
|
||
- await fs.promises.writeFile(eventsPath, serializeEventLog(truncatedEntries), 'utf-8');
|
||
-
|
||
- // Update workspace.yaml timestamp
|
||
- try {
|
||
- const yamlPath = path.join(sessionDir, 'workspace.yaml');
|
||
- let yaml = await fs.promises.readFile(yamlPath, 'utf-8');
|
||
- yaml = yaml.replace(/^updated_at:\s*.+$/m, `updated_at: ${new Date().toISOString()}`);
|
||
- await fs.promises.writeFile(yamlPath, yaml, 'utf-8');
|
||
- } catch {
|
||
- // workspace.yaml may not exist (old format)
|
||
- }
|
||
-
|
||
- // Update session-store.db
|
||
- const dbPath = path.join(copilotDataDir, 'session-store.db');
|
||
- const db = await dbOpen(dbPath);
|
||
- try {
|
||
- await truncateSessionInDb(db, sessionId, keepUpToTurnIndex);
|
||
- } finally {
|
||
- await dbClose(db);
|
||
- }
|
||
-}
|
||
-
|
||
-/**
|
||
- * Maps a protocol turn ID to a 0-based turn index by finding the turn's
|
||
- * position within the session's event log.
|
||
- *
|
||
- * The protocol state assigns arbitrary string IDs to turns, but the Copilot
|
||
- * CLI's `events.jsonl` uses sequential `user.message` events. To bridge the
|
||
- * two, we match turns by their position in the sequence.
|
||
- *
|
||
- * @returns The 0-based turn index, or `-1` if the turn ID is not found in the
|
||
- * `turnIds` array.
|
||
- */
|
||
-export function turnIdToIndex(turnIds: readonly string[], turnId: string): number {
|
||
- return turnIds.indexOf(turnId);
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
|
||
deleted file mode 100644
|
||
index e4e4203d..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
|
||
+++ /dev/null
|
||
@@ -1,754 +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 { PermissionRequest, PermissionRequestResult } from '@github/copilot-sdk';
|
||
-import { DeferredPromise } from '../../../../base/common/async.js';
|
||
-import { Emitter } from '../../../../base/common/event.js';
|
||
-import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js';
|
||
-import { generateUuid } from '../../../../base/common/uuid.js';
|
||
-import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||
-import { ILogService } from '../../../log/common/log.js';
|
||
-import { localize } from '../../../../nls.js';
|
||
-import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||
-import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||
-import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js';
|
||
-import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||
-import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool } from './copilotToolDisplay.js';
|
||
-import { FileEditTracker } from './fileEditTracker.js';
|
||
-import { mapSessionEvents } from './mapSessionEvents.js';
|
||
-import type { ShellManager } from './copilotShellTools.js';
|
||
-
|
||
-/**
|
||
- * Factory function that produces a {@link CopilotSessionWrapper}.
|
||
- * Called by {@link CopilotAgentSession.initializeSession} with the
|
||
- * session's permission handler and edit-tracking hooks so the factory
|
||
- * can wire them into the SDK session it creates.
|
||
- *
|
||
- * In production, the factory calls `CopilotClient.createSession()` or
|
||
- * `resumeSession()`. In tests, it returns a mock wrapper directly.
|
||
- */
|
||
-export type SessionWrapperFactory = (callbacks: {
|
||
- readonly onPermissionRequest: (request: PermissionRequest) => Promise<PermissionRequestResult>;
|
||
- readonly onUserInputRequest: (request: IUserInputRequest, invocation: { sessionId: string }) => Promise<IUserInputResponse>;
|
||
- readonly hooks: {
|
||
- readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||
- readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||
- };
|
||
-}) => Promise<CopilotSessionWrapper>;
|
||
-
|
||
-/** Matches the SDK's `UserInputRequest` which is not re-exported from the package entry point. */
|
||
-interface IUserInputRequest {
|
||
- question: string;
|
||
- choices?: string[];
|
||
- allowFreeform?: boolean;
|
||
-}
|
||
-
|
||
-/** Matches the SDK's `UserInputResponse` which is not re-exported from the package entry point. */
|
||
-interface IUserInputResponse {
|
||
- answer: string;
|
||
- wasFreeform: boolean;
|
||
-}
|
||
-
|
||
-function tryStringify(value: unknown): string | undefined {
|
||
- try {
|
||
- return JSON.stringify(value);
|
||
- } catch {
|
||
- return undefined;
|
||
- }
|
||
-}
|
||
-
|
||
-/**
|
||
- * Derives display fields from a permission request for the tool confirmation UI.
|
||
- */
|
||
-function getPermissionDisplay(request: { kind: string;[key: string]: unknown }): {
|
||
- confirmationTitle: string;
|
||
- invocationMessage: string;
|
||
- toolInput?: string;
|
||
- /** Normalized permission kind for auto-approval routing. */
|
||
- permissionKind: string;
|
||
-} {
|
||
- const path = typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined);
|
||
- const fullCommandText = typeof request.fullCommandText === 'string' ? request.fullCommandText : undefined;
|
||
- const intention = typeof request.intention === 'string' ? request.intention : undefined;
|
||
- const serverName = typeof request.serverName === 'string' ? request.serverName : undefined;
|
||
- const toolName = typeof request.toolName === 'string' ? request.toolName : undefined;
|
||
-
|
||
- switch (request.kind) {
|
||
- case 'shell':
|
||
- return {
|
||
- confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"),
|
||
- invocationMessage: intention ?? localize('copilot.permission.shell.message', "Run command"),
|
||
- toolInput: fullCommandText,
|
||
- permissionKind: 'shell',
|
||
- };
|
||
- case 'custom-tool': {
|
||
- // Custom tool overrides (e.g. our shell tool). Extract the actual
|
||
- // tool args from the SDK's wrapper envelope.
|
||
- const args = typeof request.args === 'object' && request.args !== null ? request.args as Record<string, unknown> : undefined;
|
||
- const command = typeof args?.command === 'string' ? args.command : undefined;
|
||
- const sdkToolName = typeof request.toolName === 'string' ? request.toolName : undefined;
|
||
- if (command && sdkToolName && isShellTool(sdkToolName)) {
|
||
- return {
|
||
- confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"),
|
||
- invocationMessage: localize('copilot.permission.shell.message', "Run command"),
|
||
- toolInput: command,
|
||
- permissionKind: 'shell',
|
||
- };
|
||
- }
|
||
- return {
|
||
- confirmationTitle: toolName ?? localize('copilot.permission.default.title', "Permission request"),
|
||
- invocationMessage: localize('copilot.permission.default.message', "Permission request"),
|
||
- toolInput: args ? tryStringify(args) : tryStringify(request),
|
||
- permissionKind: request.kind,
|
||
- };
|
||
- }
|
||
- case 'write':
|
||
- return {
|
||
- confirmationTitle: localize('copilot.permission.write.title', "Write file"),
|
||
- invocationMessage: path ? localize('copilot.permission.write.message', "Edit {0}", path) : localize('copilot.permission.write.messageGeneric', "Edit file"),
|
||
- toolInput: tryStringify(path ? { path } : request) ?? undefined,
|
||
- permissionKind: 'write',
|
||
- };
|
||
- case 'mcp': {
|
||
- const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool");
|
||
- return {
|
||
- confirmationTitle: serverName ? `${serverName}: ${title}` : title,
|
||
- invocationMessage: serverName ? `${serverName}: ${title}` : title,
|
||
- toolInput: tryStringify({ serverName, toolName }) ?? undefined,
|
||
- permissionKind: 'mcp',
|
||
- };
|
||
- }
|
||
- case 'read':
|
||
- return {
|
||
- confirmationTitle: localize('copilot.permission.read.title', "Read file"),
|
||
- invocationMessage: intention ?? localize('copilot.permission.read.message', "Read file"),
|
||
- toolInput: tryStringify(path ? { path, intention } : request) ?? undefined,
|
||
- permissionKind: 'read',
|
||
- };
|
||
- default:
|
||
- return {
|
||
- confirmationTitle: localize('copilot.permission.default.title', "Permission request"),
|
||
- invocationMessage: localize('copilot.permission.default.message', "Permission request"),
|
||
- toolInput: tryStringify(request) ?? undefined,
|
||
- permissionKind: request.kind,
|
||
- };
|
||
- }
|
||
-}
|
||
-
|
||
-/**
|
||
- * Encapsulates a single Copilot SDK session and all its associated bookkeeping.
|
||
- *
|
||
- * Created by {@link CopilotAgent}, one instance per active session. Disposing
|
||
- * this class tears down all per-session resources (SDK wrapper, edit tracker,
|
||
- * database reference, pending permissions).
|
||
- */
|
||
-export class CopilotAgentSession extends Disposable {
|
||
- readonly sessionId: string;
|
||
- readonly sessionUri: URI;
|
||
-
|
||
- /** Tracks active tool invocations so we can produce past-tense messages on completion. */
|
||
- private readonly _activeToolCalls = new Map<string, { toolName: string; displayName: string; parameters: Record<string, unknown> | undefined }>();
|
||
- /** Pending permission requests awaiting a renderer-side decision. */
|
||
- private readonly _pendingPermissions = new Map<string, DeferredPromise<boolean>>();
|
||
- /** Pending user input requests awaiting a renderer-side answer. */
|
||
- private readonly _pendingUserInputs = new Map<string, { deferred: DeferredPromise<{ response: SessionInputResponseKind; answers?: Record<string, ISessionInputAnswer> }>; questionId: string }>();
|
||
- /** File edit tracker for this session. */
|
||
- private readonly _editTracker: FileEditTracker;
|
||
- /** Session database reference. */
|
||
- private readonly _databaseRef: IReference<ISessionDatabase>;
|
||
- /** Protocol turn ID set by {@link send}, used for file edit tracking. */
|
||
- private _turnId = '';
|
||
- /** SDK session wrapper, set by {@link initializeSession}. */
|
||
- private _wrapper!: CopilotSessionWrapper;
|
||
-
|
||
- private readonly _workingDirectory: URI | undefined;
|
||
-
|
||
- constructor(
|
||
- sessionUri: URI,
|
||
- rawSessionId: string,
|
||
- workingDirectory: URI | undefined,
|
||
- private readonly _onDidSessionProgress: Emitter<IAgentProgressEvent>,
|
||
- private readonly _wrapperFactory: SessionWrapperFactory,
|
||
- private readonly _shellManager: ShellManager | undefined,
|
||
- @IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||
- @ILogService private readonly _logService: ILogService,
|
||
- @ISessionDataService sessionDataService: ISessionDataService,
|
||
- ) {
|
||
- super();
|
||
- this.sessionId = rawSessionId;
|
||
- this.sessionUri = sessionUri;
|
||
- this._workingDirectory = workingDirectory;
|
||
-
|
||
- this._databaseRef = sessionDataService.openDatabase(sessionUri);
|
||
- this._register(toDisposable(() => this._databaseRef.dispose()));
|
||
-
|
||
- this._editTracker = this._instantiationService.createInstance(FileEditTracker, sessionUri.toString(), this._databaseRef.object);
|
||
-
|
||
- this._register(toDisposable(() => this._denyPendingPermissions()));
|
||
- this._register(toDisposable(() => this._shellManager?.dispose()));
|
||
- this._register(toDisposable(() => this._cancelPendingUserInputs()));
|
||
- }
|
||
-
|
||
- /**
|
||
- * Creates (or resumes) the SDK session via the injected factory and
|
||
- * wires up all event listeners. Must be called exactly once after
|
||
- * construction before using the session.
|
||
- */
|
||
- async initializeSession(): Promise<void> {
|
||
- this._wrapper = this._register(await this._wrapperFactory({
|
||
- onPermissionRequest: request => this.handlePermissionRequest(request),
|
||
- onUserInputRequest: (request, invocation) => this.handleUserInputRequest(request, invocation),
|
||
- hooks: {
|
||
- onPreToolUse: async input => {
|
||
- if (isEditTool(input.toolName)) {
|
||
- const filePath = getEditFilePath(input.toolArgs);
|
||
- if (filePath) {
|
||
- await this._editTracker.trackEditStart(filePath);
|
||
- }
|
||
- }
|
||
- },
|
||
- onPostToolUse: async input => {
|
||
- if (isEditTool(input.toolName)) {
|
||
- const filePath = getEditFilePath(input.toolArgs);
|
||
- if (filePath) {
|
||
- await this._editTracker.completeEdit(filePath);
|
||
- }
|
||
- }
|
||
- },
|
||
- },
|
||
- }));
|
||
- this._subscribeToEvents();
|
||
- this._subscribeForLogging();
|
||
- }
|
||
-
|
||
- // ---- session operations -------------------------------------------------
|
||
-
|
||
- async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void> {
|
||
- if (turnId) {
|
||
- this._turnId = turnId;
|
||
- }
|
||
- this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`);
|
||
-
|
||
- const sdkAttachments = attachments?.map(a => {
|
||
- if (a.type === 'selection') {
|
||
- return { type: 'selection' as const, filePath: a.path, displayName: a.displayName ?? a.path, text: a.text, selection: a.selection };
|
||
- }
|
||
- return { type: a.type, path: a.path, displayName: a.displayName };
|
||
- });
|
||
- if (sdkAttachments?.length) {
|
||
- this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`);
|
||
- }
|
||
-
|
||
- await this._wrapper.session.send({ prompt, attachments: sdkAttachments });
|
||
- this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`);
|
||
- }
|
||
-
|
||
- async sendSteering(steeringMessage: IPendingMessage): Promise<void> {
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`);
|
||
- try {
|
||
- await this._wrapper.session.send({
|
||
- prompt: steeringMessage.userMessage.text,
|
||
- mode: 'immediate',
|
||
- });
|
||
- this._onDidSessionProgress.fire({
|
||
- session: this.sessionUri,
|
||
- type: 'steering_consumed',
|
||
- id: steeringMessage.id,
|
||
- });
|
||
- } catch (err) {
|
||
- this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err);
|
||
- }
|
||
- }
|
||
-
|
||
- async getMessages(): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> {
|
||
- const events = await this._wrapper.session.getMessages();
|
||
- let db: ISessionDatabase | undefined;
|
||
- try {
|
||
- db = this._databaseRef.object;
|
||
- } catch {
|
||
- // Database may not exist yet — that's fine
|
||
- }
|
||
- return mapSessionEvents(this.sessionUri, db, events);
|
||
- }
|
||
-
|
||
- async abort(): Promise<void> {
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`);
|
||
- this._denyPendingPermissions();
|
||
- await this._wrapper.session.abort();
|
||
- }
|
||
-
|
||
- /**
|
||
- * Explicitly destroys the underlying SDK session and waits for cleanup
|
||
- * to complete. Call this before {@link dispose} when you need to ensure
|
||
- * the session's on-disk data is no longer locked (e.g. before
|
||
- * truncation or fork operations that modify the session files).
|
||
- */
|
||
- async destroySession(): Promise<void> {
|
||
- await this._wrapper.session.destroy();
|
||
- }
|
||
-
|
||
- async setModel(model: string): Promise<void> {
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`);
|
||
- await this._wrapper.session.setModel(model);
|
||
- }
|
||
-
|
||
- // ---- permission handling ------------------------------------------------
|
||
-
|
||
- /**
|
||
- * Handles a permission request from the SDK by firing a `tool_ready` event
|
||
- * (which transitions the tool to PendingConfirmation) and waiting for the
|
||
- * side-effects layer to respond via {@link respondToPermissionRequest}.
|
||
- */
|
||
- async handlePermissionRequest(
|
||
- request: PermissionRequest,
|
||
- ): Promise<PermissionRequestResult> {
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Permission request: kind=${request.kind}`);
|
||
-
|
||
- // Auto-approve reads inside the working directory
|
||
- if (request.kind === 'read') {
|
||
- const requestPath = typeof request.path === 'string' ? request.path : undefined;
|
||
- if (requestPath && this._workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(requestPath)), this._workingDirectory)) {
|
||
- this._logService.trace(`[Copilot:${this.sessionId}] Auto-approving read inside working directory: ${requestPath}`);
|
||
- return { kind: 'approved' };
|
||
- }
|
||
- }
|
||
-
|
||
- const toolCallId = request.toolCallId;
|
||
- if (!toolCallId) {
|
||
- // TODO: handle permission requests without a toolCallId by creating a synthetic tool call
|
||
- this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`);
|
||
- return { kind: 'denied-interactively-by-user' };
|
||
- }
|
||
-
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`);
|
||
-
|
||
- const deferred = new DeferredPromise<boolean>();
|
||
- this._pendingPermissions.set(toolCallId, deferred);
|
||
-
|
||
- // Derive display information from the permission request kind
|
||
- const { confirmationTitle, invocationMessage, toolInput, permissionKind } = getPermissionDisplay(request);
|
||
-
|
||
- // Fire a tool_ready event to transition the tool to PendingConfirmation
|
||
- this._onDidSessionProgress.fire({
|
||
- session: this.sessionUri,
|
||
- type: 'tool_ready',
|
||
- toolCallId,
|
||
- invocationMessage,
|
||
- toolInput,
|
||
- confirmationTitle,
|
||
- permissionKind,
|
||
- permissionPath: typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined),
|
||
- });
|
||
-
|
||
- const approved = await deferred.p;
|
||
- this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`);
|
||
- return { kind: approved ? 'approved' : 'denied-interactively-by-user' };
|
||
- }
|
||
-
|
||
- respondToPermissionRequest(requestId: string, approved: boolean): boolean {
|
||
- const deferred = this._pendingPermissions.get(requestId);
|
||
- if (deferred) {
|
||
- this._pendingPermissions.delete(requestId);
|
||
- deferred.complete(approved);
|
||
- return true;
|
||
- }
|
||
- return false;
|
||
- }
|
||
-
|
||
- // ---- user input handling ------------------------------------------------
|
||
-
|
||
- /**
|
||
- * Handles a user input request from the SDK (ask_user tool) by firing a
|
||
- * `user_input_request` progress event and waiting for the renderer to
|
||
- * respond via {@link respondToUserInputRequest}.
|
||
- */
|
||
- async handleUserInputRequest(
|
||
- request: IUserInputRequest,
|
||
- _invocation: { sessionId: string },
|
||
- ): Promise<IUserInputResponse> {
|
||
- const requestId = generateUuid();
|
||
- const questionId = generateUuid();
|
||
- this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${request.question.substring(0, 100)}"`);
|
||
-
|
||
- const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record<string, ISessionInputAnswer> }>();
|
||
- this._pendingUserInputs.set(requestId, { deferred, questionId });
|
||
-
|
||
- // Build the protocol ISessionInputRequest from the SDK's simple format
|
||
- const inputRequest: ISessionInputRequest = {
|
||
- id: requestId,
|
||
- message: request.question,
|
||
- questions: [request.choices && request.choices.length > 0
|
||
- ? {
|
||
- kind: SessionInputQuestionKind.SingleSelect,
|
||
- id: questionId,
|
||
- message: request.question,
|
||
- required: true,
|
||
- options: request.choices.map(c => ({ id: c, label: c })),
|
||
- allowFreeformInput: request.allowFreeform ?? true,
|
||
- }
|
||
- : {
|
||
- kind: SessionInputQuestionKind.Text,
|
||
- id: questionId,
|
||
- message: request.question,
|
||
- required: true,
|
||
- },
|
||
- ],
|
||
- };
|
||
-
|
||
- this._onDidSessionProgress.fire({
|
||
- session: this.sessionUri,
|
||
- type: 'user_input_request',
|
||
- request: inputRequest,
|
||
- });
|
||
-
|
||
- const result = await deferred.p;
|
||
- this._logService.info(`[Copilot:${this.sessionId}] User input response: requestId=${requestId}, response=${result.response}`);
|
||
-
|
||
- if (result.response !== SessionInputResponseKind.Accept || !result.answers) {
|
||
- return { answer: '', wasFreeform: true };
|
||
- }
|
||
-
|
||
- // Extract the answer for our single question
|
||
- const answer = result.answers[questionId];
|
||
- if (!answer || answer.state === SessionInputAnswerState.Skipped) {
|
||
- return { answer: '', wasFreeform: true };
|
||
- }
|
||
-
|
||
- const { value: val } = answer;
|
||
- if (val.kind === SessionInputAnswerValueKind.Text) {
|
||
- return { answer: val.value, wasFreeform: true };
|
||
- } else if (val.kind === SessionInputAnswerValueKind.Selected) {
|
||
- const wasFreeform = !request.choices?.includes(val.value);
|
||
- return { answer: val.value, wasFreeform };
|
||
- }
|
||
-
|
||
- return { answer: '', wasFreeform: true };
|
||
- }
|
||
-
|
||
- respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record<string, ISessionInputAnswer>): boolean {
|
||
- const pending = this._pendingUserInputs.get(requestId);
|
||
- if (pending) {
|
||
- this._pendingUserInputs.delete(requestId);
|
||
- pending.deferred.complete({ response, answers });
|
||
- return true;
|
||
- }
|
||
- return false;
|
||
- }
|
||
-
|
||
- // ---- event wiring -------------------------------------------------------
|
||
-
|
||
- private _subscribeToEvents(): void {
|
||
- const wrapper = this._wrapper;
|
||
- const sessionId = this.sessionId;
|
||
- const session = this.sessionUri;
|
||
-
|
||
- this._register(wrapper.onMessageDelta(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'delta',
|
||
- messageId: e.data.messageId,
|
||
- content: e.data.deltaContent,
|
||
- parentToolCallId: e.data.parentToolCallId,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onMessage(e => {
|
||
- this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'message',
|
||
- role: 'assistant',
|
||
- messageId: e.data.messageId,
|
||
- content: e.data.content,
|
||
- toolRequests: e.data.toolRequests?.map(tr => ({
|
||
- toolCallId: tr.toolCallId,
|
||
- name: tr.name,
|
||
- arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined,
|
||
- type: tr.type,
|
||
- })),
|
||
- reasoningOpaque: e.data.reasoningOpaque,
|
||
- reasoningText: e.data.reasoningText,
|
||
- encryptedContent: e.data.encryptedContent,
|
||
- parentToolCallId: e.data.parentToolCallId,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onToolStart(e => {
|
||
- if (isHiddenTool(e.data.toolName)) {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`);
|
||
- return;
|
||
- }
|
||
- this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`);
|
||
- const toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined;
|
||
- let parameters: Record<string, unknown> | undefined;
|
||
- if (toolArgs) {
|
||
- try { parameters = JSON.parse(toolArgs) as Record<string, unknown>; } catch { /* ignore */ }
|
||
- }
|
||
- const displayName = getToolDisplayName(e.data.toolName);
|
||
- this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters });
|
||
- const toolKind = getToolKind(e.data.toolName);
|
||
-
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'tool_start',
|
||
- toolCallId: e.data.toolCallId,
|
||
- toolName: e.data.toolName,
|
||
- displayName,
|
||
- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters),
|
||
- toolInput: getToolInputString(e.data.toolName, parameters, toolArgs),
|
||
- toolKind,
|
||
- language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined,
|
||
- toolArguments: toolArgs,
|
||
- mcpServerName: e.data.mcpServerName,
|
||
- mcpToolName: e.data.mcpToolName,
|
||
- parentToolCallId: e.data.parentToolCallId,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onToolComplete(async e => {
|
||
- const tracked = this._activeToolCalls.get(e.data.toolCallId);
|
||
- if (!tracked) {
|
||
- return;
|
||
- }
|
||
- this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`);
|
||
- this._activeToolCalls.delete(e.data.toolCallId);
|
||
- const displayName = tracked.displayName;
|
||
- const toolOutput = e.data.error?.message ?? e.data.result?.content;
|
||
-
|
||
- const content: IToolResultContent[] = [];
|
||
- if (toolOutput !== undefined) {
|
||
- content.push({ type: ToolResultContentType.Text, text: toolOutput });
|
||
- }
|
||
-
|
||
- const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined;
|
||
- if (filePath) {
|
||
- try {
|
||
- const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath);
|
||
- if (fileEdit) {
|
||
- content.push(fileEdit);
|
||
- }
|
||
- } catch (err) {
|
||
- this._logService.warn(`[Copilot:${sessionId}] Failed to take completed edit`, err);
|
||
- }
|
||
- }
|
||
-
|
||
- // Add terminal content reference for shell tools
|
||
- if (isShellTool(tracked.toolName) && this._shellManager) {
|
||
- const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId);
|
||
- if (terminalUri) {
|
||
- content.push({
|
||
- type: ToolResultContentType.Terminal,
|
||
- resource: terminalUri,
|
||
- title: tracked.displayName,
|
||
- });
|
||
- }
|
||
- }
|
||
-
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'tool_complete',
|
||
- toolCallId: e.data.toolCallId,
|
||
- result: {
|
||
- success: e.data.success,
|
||
- pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success),
|
||
- content: content.length > 0 ? content : undefined,
|
||
- error: e.data.error,
|
||
- },
|
||
- isUserRequested: e.data.isUserRequested,
|
||
- toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined,
|
||
- parentToolCallId: e.data.parentToolCallId,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onIdle(() => {
|
||
- this._logService.info(`[Copilot:${sessionId}] Session idle`);
|
||
- this._onDidSessionProgress.fire({ session, type: 'idle' });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSubagentStarted(e => {
|
||
- this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'subagent_started',
|
||
- toolCallId: e.data.toolCallId,
|
||
- agentName: e.data.agentName,
|
||
- agentDisplayName: e.data.agentDisplayName,
|
||
- agentDescription: e.data.agentDescription,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionError(e => {
|
||
- this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'error',
|
||
- errorType: e.data.errorType,
|
||
- message: e.data.message,
|
||
- stack: e.data.stack,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onUsage(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'usage',
|
||
- inputTokens: e.data.inputTokens,
|
||
- outputTokens: e.data.outputTokens,
|
||
- model: e.data.model,
|
||
- cacheReadTokens: e.data.cacheReadTokens,
|
||
- });
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onReasoningDelta(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`);
|
||
- this._onDidSessionProgress.fire({
|
||
- session,
|
||
- type: 'reasoning',
|
||
- content: e.data.deltaContent,
|
||
- });
|
||
- }));
|
||
- }
|
||
-
|
||
- private _subscribeForLogging(): void {
|
||
- const wrapper = this._wrapper;
|
||
- const sessionId = this.sessionId;
|
||
-
|
||
- this._register(wrapper.onSessionStart(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionResume(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionInfo(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionModelChange(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionHandoff(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionTruncation(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionSnapshotRewind(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionShutdown(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionUsageInfo(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionCompactionStart(() => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Compaction started`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSessionCompactionComplete(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onUserMessage(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onPendingMessagesModified(() => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onTurnStart(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onIntent(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onReasoning(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onTurnEnd(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onAbort(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onToolUserRequested(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onToolPartialResult(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onToolProgress(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSkillInvoked(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSubagentStarted(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSubagentCompleted(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSubagentFailed(e => {
|
||
- this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSubagentSelected(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onHookStart(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onHookEnd(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`);
|
||
- }));
|
||
-
|
||
- this._register(wrapper.onSystemMessage(e => {
|
||
- this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`);
|
||
- }));
|
||
- }
|
||
-
|
||
- // ---- cleanup ------------------------------------------------------------
|
||
-
|
||
- private _denyPendingPermissions(): void {
|
||
- for (const [, deferred] of this._pendingPermissions) {
|
||
- deferred.complete(false);
|
||
- }
|
||
- this._pendingPermissions.clear();
|
||
- }
|
||
-
|
||
- private _cancelPendingUserInputs(): void {
|
||
- for (const [, pending] of this._pendingUserInputs) {
|
||
- pending.deferred.complete({ response: SessionInputResponseKind.Cancel });
|
||
- }
|
||
- this._pendingUserInputs.clear();
|
||
- }
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts
|
||
deleted file mode 100644
|
||
index c1e2d22f..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts
|
||
+++ /dev/null
|
||
@@ -1,87 +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 * as cp from 'child_process';
|
||
-import { Schemas } from '../../../../base/common/network.js';
|
||
-import { basename } from '../../../../base/common/path.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import type { IAgentSessionProjectInfo } from '../../common/agentService.js';
|
||
-
|
||
-export interface ICopilotSessionContext {
|
||
- readonly cwd?: string;
|
||
- readonly gitRoot?: string;
|
||
- readonly repository?: string;
|
||
-}
|
||
-
|
||
-function execGit(cwd: string, args: string[]): Promise<string> {
|
||
- return new Promise((resolve, reject) => {
|
||
- cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout) => {
|
||
- if (error) {
|
||
- reject(error);
|
||
- return;
|
||
- }
|
||
- resolve(stdout.trim());
|
||
- });
|
||
- });
|
||
-}
|
||
-
|
||
-export async function resolveGitProject(workingDirectory: URI | undefined): Promise<IAgentSessionProjectInfo | undefined> {
|
||
- if (!workingDirectory || workingDirectory.scheme !== Schemas.file) {
|
||
- return undefined;
|
||
- }
|
||
-
|
||
- const cwd = workingDirectory.fsPath;
|
||
- try {
|
||
- if ((await execGit(cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true') {
|
||
- return undefined;
|
||
- }
|
||
- } catch {
|
||
- return undefined;
|
||
- }
|
||
-
|
||
- let projectPath: string | undefined;
|
||
- try {
|
||
- const worktreeList = await execGit(cwd, ['worktree', 'list', '--porcelain']);
|
||
- projectPath = worktreeList.split(/\r?\n/).find(line => line.startsWith('worktree '))?.substring('worktree '.length);
|
||
- } catch {
|
||
- // Fall back to the current worktree root below.
|
||
- }
|
||
-
|
||
- if (!projectPath) {
|
||
- try {
|
||
- projectPath = await execGit(cwd, ['rev-parse', '--show-toplevel']);
|
||
- } catch {
|
||
- return undefined;
|
||
- }
|
||
- }
|
||
-
|
||
- const uri = URI.file(projectPath);
|
||
- return { uri, displayName: basename(uri.fsPath) || uri.toString() };
|
||
-}
|
||
-
|
||
-export function projectFromRepository(repository: string): IAgentSessionProjectInfo | undefined {
|
||
- const uri = repository.includes('://') ? URI.parse(repository) : URI.parse(`https://github.com/${repository}`);
|
||
- const rawDisplayName = basename(uri.path) || repository.split('/').filter(Boolean).pop() || repository;
|
||
- const displayName = rawDisplayName.endsWith('.git') ? rawDisplayName.slice(0, -'.git'.length) : rawDisplayName;
|
||
- return { uri, displayName };
|
||
-}
|
||
-
|
||
-export async function projectFromCopilotContext(context: ICopilotSessionContext | undefined): Promise<IAgentSessionProjectInfo | undefined> {
|
||
- const workingDirectory = typeof context?.cwd === 'string'
|
||
- ? URI.file(context.cwd)
|
||
- : typeof context?.gitRoot === 'string'
|
||
- ? URI.file(context.gitRoot)
|
||
- : undefined;
|
||
- const gitProject = await resolveGitProject(workingDirectory);
|
||
- if (gitProject) {
|
||
- return gitProject;
|
||
- }
|
||
-
|
||
- if (context?.repository) {
|
||
- return projectFromRepository(context.repository);
|
||
- }
|
||
-
|
||
- return undefined;
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
|
||
deleted file mode 100644
|
||
index c08d7e5f..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts
|
||
+++ /dev/null
|
||
@@ -1,341 +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 { spawn } from 'child_process';
|
||
-import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk';
|
||
-import { OperatingSystem, OS } from '../../../../base/common/platform.js';
|
||
-import { IFileService } from '../../../files/common/files.js';
|
||
-import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';
|
||
-import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
|
||
-import { dirname } from '../../../../base/common/path.js';
|
||
-
|
||
-type SessionHooks = NonNullable<SessionConfig['hooks']>;
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// MCP servers
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * Converts parsed MCP server definitions into the SDK's `mcpServers` config.
|
||
- */
|
||
-export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record<string, MCPServerConfig> {
|
||
- const result: Record<string, MCPServerConfig> = {};
|
||
- for (const def of defs) {
|
||
- const config = def.configuration;
|
||
- if (config.type === McpServerType.LOCAL) {
|
||
- result[def.name] = {
|
||
- type: 'local',
|
||
- command: config.command,
|
||
- args: config.args ? [...config.args] : [],
|
||
- tools: ['*'],
|
||
- ...(config.env && { env: toStringEnv(config.env) }),
|
||
- ...(config.cwd && { cwd: config.cwd }),
|
||
- };
|
||
- } else {
|
||
- result[def.name] = {
|
||
- type: 'http',
|
||
- url: config.url,
|
||
- tools: ['*'],
|
||
- ...(config.headers && { headers: { ...config.headers } }),
|
||
- };
|
||
- }
|
||
- }
|
||
- return result;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Ensures all env values are strings (the SDK requires `Record<string, string>`).
|
||
- */
|
||
-function toStringEnv(env: Record<string, string | number | null>): Record<string, string> {
|
||
- const result: Record<string, string> = {};
|
||
- for (const [key, value] of Object.entries(env)) {
|
||
- if (value !== null) {
|
||
- result[key] = String(value);
|
||
- }
|
||
- }
|
||
- return result;
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Custom agents
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * Converts parsed plugin agents into the SDK's `customAgents` config.
|
||
- * Reads each agent's `.md` file to use as the prompt.
|
||
- */
|
||
-export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise<CustomAgentConfig[]> {
|
||
- const configs: CustomAgentConfig[] = [];
|
||
- for (const agent of agents) {
|
||
- try {
|
||
- const content = await fileService.readFile(agent.uri);
|
||
- configs.push({
|
||
- name: agent.name,
|
||
- prompt: content.value.toString(),
|
||
- });
|
||
- } catch {
|
||
- // Skip agents whose file cannot be read
|
||
- }
|
||
- }
|
||
- return configs;
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Skill directories
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * Converts parsed plugin skills into the SDK's `skillDirectories` config.
|
||
- * The SDK expects directory paths; we extract the parent directory of each SKILL.md.
|
||
- */
|
||
-export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] {
|
||
- const seen = new Set<string>();
|
||
- const result: string[] = [];
|
||
- for (const skill of skills) {
|
||
- // SKILL.md parent directory is the skill directory
|
||
- const dir = dirname(skill.uri.fsPath);
|
||
- if (!seen.has(dir)) {
|
||
- seen.add(dir);
|
||
- result.push(dir);
|
||
- }
|
||
- }
|
||
- return result;
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Hooks
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * Resolves the effective command for the current platform from a parsed hook command.
|
||
- */
|
||
-function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined {
|
||
- if (os === OperatingSystem.Windows && hook.windows) {
|
||
- return hook.windows;
|
||
- } else if (os === OperatingSystem.Macintosh && hook.osx) {
|
||
- return hook.osx;
|
||
- } else if (os === OperatingSystem.Linux && hook.linux) {
|
||
- return hook.linux;
|
||
- }
|
||
- return hook.command;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Executes a hook command as a shell process. Returns the stdout on success,
|
||
- * or throws on non-zero exit code or timeout.
|
||
- */
|
||
-function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise<string> {
|
||
- const command = resolveEffectiveCommand(hook, OS);
|
||
- if (!command) {
|
||
- return Promise.resolve('');
|
||
- }
|
||
-
|
||
- const timeout = (hook.timeout ?? 30) * 1000;
|
||
- const cwd = hook.cwd?.fsPath;
|
||
-
|
||
- return new Promise<string>((resolve, reject) => {
|
||
- const isWindows = OS === OperatingSystem.Windows;
|
||
- const shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
||
- const shellArgs = isWindows ? ['/c', command] : ['-c', command];
|
||
-
|
||
- const child = spawn(shell, shellArgs, {
|
||
- cwd,
|
||
- env: { ...process.env, ...hook.env },
|
||
- stdio: ['pipe', 'pipe', 'pipe'],
|
||
- timeout,
|
||
- });
|
||
-
|
||
- let stdout = '';
|
||
- let stderr = '';
|
||
-
|
||
- child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||
- child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||
-
|
||
- if (stdin) {
|
||
- child.stdin.write(stdin);
|
||
- child.stdin.end();
|
||
- } else {
|
||
- child.stdin.end();
|
||
- }
|
||
-
|
||
- child.on('error', reject);
|
||
- child.on('close', (code) => {
|
||
- if (code === 0) {
|
||
- resolve(stdout);
|
||
- } else {
|
||
- reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`));
|
||
- }
|
||
- });
|
||
- });
|
||
-}
|
||
-
|
||
-/**
|
||
- * Mapping from canonical hook type identifiers to SDK SessionHooks handler keys.
|
||
- */
|
||
-const HOOK_TYPE_TO_SDK_KEY: Record<string, keyof SessionHooks> = {
|
||
- 'PreToolUse': 'onPreToolUse',
|
||
- 'PostToolUse': 'onPostToolUse',
|
||
- 'UserPromptSubmit': 'onUserPromptSubmitted',
|
||
- 'SessionStart': 'onSessionStart',
|
||
- 'SessionEnd': 'onSessionEnd',
|
||
- 'ErrorOccurred': 'onErrorOccurred',
|
||
-};
|
||
-
|
||
-/**
|
||
- * Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions.
|
||
- *
|
||
- * Each handler executes the hook's shell commands sequentially when invoked.
|
||
- * Hook types that don't map to SDK handler keys are silently ignored.
|
||
- *
|
||
- * The optional `editTrackingHooks` parameter provides internal edit-tracking
|
||
- * callbacks from {@link CopilotAgentSession} that are merged with plugin hooks.
|
||
- */
|
||
-export function toSdkHooks(
|
||
- hookGroups: readonly IParsedHookGroup[],
|
||
- editTrackingHooks?: {
|
||
- readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||
- readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||
- },
|
||
-): SessionHooks {
|
||
- // Group all commands by SDK handler key
|
||
- const commandsByKey = new Map<keyof SessionHooks, IParsedHookCommand[]>();
|
||
- for (const group of hookGroups) {
|
||
- const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type];
|
||
- if (!sdkKey) {
|
||
- continue;
|
||
- }
|
||
- const existing = commandsByKey.get(sdkKey) ?? [];
|
||
- existing.push(...group.commands);
|
||
- commandsByKey.set(sdkKey, existing);
|
||
- }
|
||
-
|
||
- const hooks: SessionHooks = {};
|
||
-
|
||
- // Pre-tool-use handler
|
||
- const preToolCommands = commandsByKey.get('onPreToolUse');
|
||
- if (preToolCommands?.length || editTrackingHooks) {
|
||
- hooks.onPreToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
|
||
- await editTrackingHooks?.onPreToolUse(input);
|
||
- if (preToolCommands) {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of preToolCommands) {
|
||
- try {
|
||
- const output = await executeHookCommand(cmd, stdin);
|
||
- if (output.trim()) {
|
||
- try {
|
||
- const parsed = JSON.parse(output);
|
||
- if (parsed && typeof parsed === 'object') {
|
||
- return parsed;
|
||
- }
|
||
- } catch {
|
||
- // Non-JSON output is fine — no modification
|
||
- }
|
||
- }
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- // Post-tool-use handler
|
||
- const postToolCommands = commandsByKey.get('onPostToolUse');
|
||
- if (postToolCommands?.length || editTrackingHooks) {
|
||
- hooks.onPostToolUse = async (input: { toolName: string; toolArgs: unknown }) => {
|
||
- await editTrackingHooks?.onPostToolUse(input);
|
||
- if (postToolCommands) {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of postToolCommands) {
|
||
- try {
|
||
- await executeHookCommand(cmd, stdin);
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- // User-prompt-submitted handler
|
||
- const promptCommands = commandsByKey.get('onUserPromptSubmitted');
|
||
- if (promptCommands?.length) {
|
||
- hooks.onUserPromptSubmitted = async (input: { prompt: string }) => {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of promptCommands) {
|
||
- try {
|
||
- await executeHookCommand(cmd, stdin);
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- // Session-start handler
|
||
- const startCommands = commandsByKey.get('onSessionStart');
|
||
- if (startCommands?.length) {
|
||
- hooks.onSessionStart = async (input: { source: string }) => {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of startCommands) {
|
||
- try {
|
||
- await executeHookCommand(cmd, stdin);
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- // Session-end handler
|
||
- const endCommands = commandsByKey.get('onSessionEnd');
|
||
- if (endCommands?.length) {
|
||
- hooks.onSessionEnd = async (input: { reason: string }) => {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of endCommands) {
|
||
- try {
|
||
- await executeHookCommand(cmd, stdin);
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- // Error-occurred handler
|
||
- const errorCommands = commandsByKey.get('onErrorOccurred');
|
||
- if (errorCommands?.length) {
|
||
- hooks.onErrorOccurred = async (input: { error: string }) => {
|
||
- const stdin = JSON.stringify(input);
|
||
- for (const cmd of errorCommands) {
|
||
- try {
|
||
- await executeHookCommand(cmd, stdin);
|
||
- } catch {
|
||
- // Hook failures are non-fatal
|
||
- }
|
||
- }
|
||
- };
|
||
- }
|
||
-
|
||
- return hooks;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Checks whether two sets of parsed plugins produce equivalent SDK config.
|
||
- * Used to determine if a session needs to be refreshed.
|
||
- */
|
||
-export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean {
|
||
- // Simple structural comparison via JSON serialization.
|
||
- // We serialize only the essential fields, replacing URIs with strings.
|
||
- const serialize = (plugins: readonly IParsedPlugin[]) => {
|
||
- return JSON.stringify(plugins.map(p => ({
|
||
- hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })),
|
||
- mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })),
|
||
- skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })),
|
||
- agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })),
|
||
- })));
|
||
- };
|
||
- return serialize(a) === serialize(b);
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts
|
||
deleted file mode 100644
|
||
index 36ad526d..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts
|
||
+++ /dev/null
|
||
@@ -1,217 +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 { CopilotSession, SessionEventPayload, SessionEventType } from '@github/copilot-sdk';
|
||
-import { Emitter, Event } from '../../../../base/common/event.js';
|
||
-import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||
-
|
||
-/**
|
||
- * Thin wrapper around {@link CopilotSession} that exposes each SDK event as a
|
||
- * proper VS Code `Event<T>`. All subscriptions and the underlying SDK session
|
||
- * are cleaned up on dispose.
|
||
- */
|
||
-export class CopilotSessionWrapper extends Disposable {
|
||
-
|
||
- constructor(readonly session: CopilotSession) {
|
||
- super();
|
||
- this._register(toDisposable(() => {
|
||
- session.destroy().catch(() => { /* best-effort */ });
|
||
- }));
|
||
- }
|
||
-
|
||
- get sessionId(): string { return this.session.sessionId; }
|
||
-
|
||
- private _onMessageDelta: Event<SessionEventPayload<'assistant.message_delta'>> | undefined;
|
||
- get onMessageDelta(): Event<SessionEventPayload<'assistant.message_delta'>> {
|
||
- return this._onMessageDelta ??= this._sdkEvent('assistant.message_delta');
|
||
- }
|
||
-
|
||
- private _onMessage: Event<SessionEventPayload<'assistant.message'>> | undefined;
|
||
- get onMessage(): Event<SessionEventPayload<'assistant.message'>> {
|
||
- return this._onMessage ??= this._sdkEvent('assistant.message');
|
||
- }
|
||
-
|
||
- private _onToolStart: Event<SessionEventPayload<'tool.execution_start'>> | undefined;
|
||
- get onToolStart(): Event<SessionEventPayload<'tool.execution_start'>> {
|
||
- return this._onToolStart ??= this._sdkEvent('tool.execution_start');
|
||
- }
|
||
-
|
||
- private _onToolComplete: Event<SessionEventPayload<'tool.execution_complete'>> | undefined;
|
||
- get onToolComplete(): Event<SessionEventPayload<'tool.execution_complete'>> {
|
||
- return this._onToolComplete ??= this._sdkEvent('tool.execution_complete');
|
||
- }
|
||
-
|
||
- private _onIdle: Event<SessionEventPayload<'session.idle'>> | undefined;
|
||
- get onIdle(): Event<SessionEventPayload<'session.idle'>> {
|
||
- return this._onIdle ??= this._sdkEvent('session.idle');
|
||
- }
|
||
-
|
||
- private _onSessionStart: Event<SessionEventPayload<'session.start'>> | undefined;
|
||
- get onSessionStart(): Event<SessionEventPayload<'session.start'>> {
|
||
- return this._onSessionStart ??= this._sdkEvent('session.start');
|
||
- }
|
||
-
|
||
- private _onSessionResume: Event<SessionEventPayload<'session.resume'>> | undefined;
|
||
- get onSessionResume(): Event<SessionEventPayload<'session.resume'>> {
|
||
- return this._onSessionResume ??= this._sdkEvent('session.resume');
|
||
- }
|
||
-
|
||
- private _onSessionError: Event<SessionEventPayload<'session.error'>> | undefined;
|
||
- get onSessionError(): Event<SessionEventPayload<'session.error'>> {
|
||
- return this._onSessionError ??= this._sdkEvent('session.error');
|
||
- }
|
||
-
|
||
- private _onSessionInfo: Event<SessionEventPayload<'session.info'>> | undefined;
|
||
- get onSessionInfo(): Event<SessionEventPayload<'session.info'>> {
|
||
- return this._onSessionInfo ??= this._sdkEvent('session.info');
|
||
- }
|
||
-
|
||
- private _onSessionModelChange: Event<SessionEventPayload<'session.model_change'>> | undefined;
|
||
- get onSessionModelChange(): Event<SessionEventPayload<'session.model_change'>> {
|
||
- return this._onSessionModelChange ??= this._sdkEvent('session.model_change');
|
||
- }
|
||
-
|
||
- private _onSessionHandoff: Event<SessionEventPayload<'session.handoff'>> | undefined;
|
||
- get onSessionHandoff(): Event<SessionEventPayload<'session.handoff'>> {
|
||
- return this._onSessionHandoff ??= this._sdkEvent('session.handoff');
|
||
- }
|
||
-
|
||
- private _onSessionTruncation: Event<SessionEventPayload<'session.truncation'>> | undefined;
|
||
- get onSessionTruncation(): Event<SessionEventPayload<'session.truncation'>> {
|
||
- return this._onSessionTruncation ??= this._sdkEvent('session.truncation');
|
||
- }
|
||
-
|
||
- private _onSessionSnapshotRewind: Event<SessionEventPayload<'session.snapshot_rewind'>> | undefined;
|
||
- get onSessionSnapshotRewind(): Event<SessionEventPayload<'session.snapshot_rewind'>> {
|
||
- return this._onSessionSnapshotRewind ??= this._sdkEvent('session.snapshot_rewind');
|
||
- }
|
||
-
|
||
- private _onSessionShutdown: Event<SessionEventPayload<'session.shutdown'>> | undefined;
|
||
- get onSessionShutdown(): Event<SessionEventPayload<'session.shutdown'>> {
|
||
- return this._onSessionShutdown ??= this._sdkEvent('session.shutdown');
|
||
- }
|
||
-
|
||
- private _onSessionUsageInfo: Event<SessionEventPayload<'session.usage_info'>> | undefined;
|
||
- get onSessionUsageInfo(): Event<SessionEventPayload<'session.usage_info'>> {
|
||
- return this._onSessionUsageInfo ??= this._sdkEvent('session.usage_info');
|
||
- }
|
||
-
|
||
- private _onSessionCompactionStart: Event<SessionEventPayload<'session.compaction_start'>> | undefined;
|
||
- get onSessionCompactionStart(): Event<SessionEventPayload<'session.compaction_start'>> {
|
||
- return this._onSessionCompactionStart ??= this._sdkEvent('session.compaction_start');
|
||
- }
|
||
-
|
||
- private _onSessionCompactionComplete: Event<SessionEventPayload<'session.compaction_complete'>> | undefined;
|
||
- get onSessionCompactionComplete(): Event<SessionEventPayload<'session.compaction_complete'>> {
|
||
- return this._onSessionCompactionComplete ??= this._sdkEvent('session.compaction_complete');
|
||
- }
|
||
-
|
||
- private _onUserMessage: Event<SessionEventPayload<'user.message'>> | undefined;
|
||
- get onUserMessage(): Event<SessionEventPayload<'user.message'>> {
|
||
- return this._onUserMessage ??= this._sdkEvent('user.message');
|
||
- }
|
||
-
|
||
- private _onPendingMessagesModified: Event<SessionEventPayload<'pending_messages.modified'>> | undefined;
|
||
- get onPendingMessagesModified(): Event<SessionEventPayload<'pending_messages.modified'>> {
|
||
- return this._onPendingMessagesModified ??= this._sdkEvent('pending_messages.modified');
|
||
- }
|
||
-
|
||
- private _onTurnStart: Event<SessionEventPayload<'assistant.turn_start'>> | undefined;
|
||
- get onTurnStart(): Event<SessionEventPayload<'assistant.turn_start'>> {
|
||
- return this._onTurnStart ??= this._sdkEvent('assistant.turn_start');
|
||
- }
|
||
-
|
||
- private _onIntent: Event<SessionEventPayload<'assistant.intent'>> | undefined;
|
||
- get onIntent(): Event<SessionEventPayload<'assistant.intent'>> {
|
||
- return this._onIntent ??= this._sdkEvent('assistant.intent');
|
||
- }
|
||
-
|
||
- private _onReasoning: Event<SessionEventPayload<'assistant.reasoning'>> | undefined;
|
||
- get onReasoning(): Event<SessionEventPayload<'assistant.reasoning'>> {
|
||
- return this._onReasoning ??= this._sdkEvent('assistant.reasoning');
|
||
- }
|
||
-
|
||
- private _onReasoningDelta: Event<SessionEventPayload<'assistant.reasoning_delta'>> | undefined;
|
||
- get onReasoningDelta(): Event<SessionEventPayload<'assistant.reasoning_delta'>> {
|
||
- return this._onReasoningDelta ??= this._sdkEvent('assistant.reasoning_delta');
|
||
- }
|
||
-
|
||
- private _onTurnEnd: Event<SessionEventPayload<'assistant.turn_end'>> | undefined;
|
||
- get onTurnEnd(): Event<SessionEventPayload<'assistant.turn_end'>> {
|
||
- return this._onTurnEnd ??= this._sdkEvent('assistant.turn_end');
|
||
- }
|
||
-
|
||
- private _onUsage: Event<SessionEventPayload<'assistant.usage'>> | undefined;
|
||
- get onUsage(): Event<SessionEventPayload<'assistant.usage'>> {
|
||
- return this._onUsage ??= this._sdkEvent('assistant.usage');
|
||
- }
|
||
-
|
||
- private _onAbort: Event<SessionEventPayload<'abort'>> | undefined;
|
||
- get onAbort(): Event<SessionEventPayload<'abort'>> {
|
||
- return this._onAbort ??= this._sdkEvent('abort');
|
||
- }
|
||
-
|
||
- private _onToolUserRequested: Event<SessionEventPayload<'tool.user_requested'>> | undefined;
|
||
- get onToolUserRequested(): Event<SessionEventPayload<'tool.user_requested'>> {
|
||
- return this._onToolUserRequested ??= this._sdkEvent('tool.user_requested');
|
||
- }
|
||
-
|
||
- private _onToolPartialResult: Event<SessionEventPayload<'tool.execution_partial_result'>> | undefined;
|
||
- get onToolPartialResult(): Event<SessionEventPayload<'tool.execution_partial_result'>> {
|
||
- return this._onToolPartialResult ??= this._sdkEvent('tool.execution_partial_result');
|
||
- }
|
||
-
|
||
- private _onToolProgress: Event<SessionEventPayload<'tool.execution_progress'>> | undefined;
|
||
- get onToolProgress(): Event<SessionEventPayload<'tool.execution_progress'>> {
|
||
- return this._onToolProgress ??= this._sdkEvent('tool.execution_progress');
|
||
- }
|
||
-
|
||
- private _onSkillInvoked: Event<SessionEventPayload<'skill.invoked'>> | undefined;
|
||
- get onSkillInvoked(): Event<SessionEventPayload<'skill.invoked'>> {
|
||
- return this._onSkillInvoked ??= this._sdkEvent('skill.invoked');
|
||
- }
|
||
-
|
||
- private _onSubagentStarted: Event<SessionEventPayload<'subagent.started'>> | undefined;
|
||
- get onSubagentStarted(): Event<SessionEventPayload<'subagent.started'>> {
|
||
- return this._onSubagentStarted ??= this._sdkEvent('subagent.started');
|
||
- }
|
||
-
|
||
- private _onSubagentCompleted: Event<SessionEventPayload<'subagent.completed'>> | undefined;
|
||
- get onSubagentCompleted(): Event<SessionEventPayload<'subagent.completed'>> {
|
||
- return this._onSubagentCompleted ??= this._sdkEvent('subagent.completed');
|
||
- }
|
||
-
|
||
- private _onSubagentFailed: Event<SessionEventPayload<'subagent.failed'>> | undefined;
|
||
- get onSubagentFailed(): Event<SessionEventPayload<'subagent.failed'>> {
|
||
- return this._onSubagentFailed ??= this._sdkEvent('subagent.failed');
|
||
- }
|
||
-
|
||
- private _onSubagentSelected: Event<SessionEventPayload<'subagent.selected'>> | undefined;
|
||
- get onSubagentSelected(): Event<SessionEventPayload<'subagent.selected'>> {
|
||
- return this._onSubagentSelected ??= this._sdkEvent('subagent.selected');
|
||
- }
|
||
-
|
||
- private _onHookStart: Event<SessionEventPayload<'hook.start'>> | undefined;
|
||
- get onHookStart(): Event<SessionEventPayload<'hook.start'>> {
|
||
- return this._onHookStart ??= this._sdkEvent('hook.start');
|
||
- }
|
||
-
|
||
- private _onHookEnd: Event<SessionEventPayload<'hook.end'>> | undefined;
|
||
- get onHookEnd(): Event<SessionEventPayload<'hook.end'>> {
|
||
- return this._onHookEnd ??= this._sdkEvent('hook.end');
|
||
- }
|
||
-
|
||
- private _onSystemMessage: Event<SessionEventPayload<'system.message'>> | undefined;
|
||
- get onSystemMessage(): Event<SessionEventPayload<'system.message'>> {
|
||
- return this._onSystemMessage ??= this._sdkEvent('system.message');
|
||
- }
|
||
-
|
||
- private _sdkEvent<K extends SessionEventType>(eventType: K): Event<SessionEventPayload<K>> {
|
||
- const emitter = this._register(new Emitter<SessionEventPayload<K>>());
|
||
- const unsubscribe = this.session.on(eventType, (data: SessionEventPayload<K>) => emitter.fire(data));
|
||
- this._register(toDisposable(unsubscribe));
|
||
- return emitter.event;
|
||
- }
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts
|
||
deleted file mode 100644
|
||
index b40abea2..00000000
|
||
--- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts
|
||
+++ /dev/null
|
||
@@ -1,455 +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 { Tool, ToolResultObject } from '@github/copilot-sdk';
|
||
-import { generateUuid } from '../../../../base/common/uuid.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
|
||
-import * as platform from '../../../../base/common/platform.js';
|
||
-import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
|
||
-import { ILogService } from '../../../log/common/log.js';
|
||
-import { TerminalClaimKind, type ITerminalSessionClaim } from '../../common/state/protocol/state.js';
|
||
-import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
|
||
-
|
||
-/**
|
||
- * Maximum scrollback content (in bytes) returned to the model in tool results.
|
||
- */
|
||
-const MAX_OUTPUT_BYTES = 80_000;
|
||
-
|
||
-/**
|
||
- * Default command timeout in milliseconds (120 seconds).
|
||
- */
|
||
-const DEFAULT_TIMEOUT_MS = 120_000;
|
||
-
|
||
-/**
|
||
- * The sentinel prefix used to detect command completion in terminal output.
|
||
- * The full sentinel format is: `<<<COPILOT_SENTINEL_<uuid>_EXIT_<code>>>`.
|
||
- */
|
||
-const SENTINEL_PREFIX = '<<<COPILOT_SENTINEL_';
|
||
-
|
||
-/**
|
||
- * Tracks a single persistent shell instance backed by a managed PTY terminal.
|
||
- */
|
||
-interface IManagedShell {
|
||
- readonly id: string;
|
||
- readonly terminalUri: string;
|
||
- readonly shellType: ShellType;
|
||
-}
|
||
-
|
||
-type ShellType = 'bash' | 'powershell';
|
||
-
|
||
-function getShellExecutable(shellType: ShellType): string {
|
||
- if (shellType === 'powershell') {
|
||
- return 'powershell.exe';
|
||
- }
|
||
- return process.env['SHELL'] || '/bin/bash';
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// ShellManager
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-/**
|
||
- * Per-session manager for persistent shell instances. Each shell is backed by
|
||
- * a {@link IAgentHostTerminalManager} terminal and participates in AHP terminal
|
||
- * claim semantics.
|
||
- *
|
||
- * Created via {@link IInstantiationService} once per session and disposed when
|
||
- * the session ends.
|
||
- */
|
||
-export class ShellManager {
|
||
-
|
||
- private readonly _shells = new Map<string, IManagedShell>();
|
||
- private readonly _toolCallShells = new Map<string, string>();
|
||
-
|
||
- constructor(
|
||
- private readonly _sessionUri: URI,
|
||
- @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
|
||
- @ILogService private readonly _logService: ILogService,
|
||
- ) { }
|
||
-
|
||
- async getOrCreateShell(
|
||
- shellType: ShellType,
|
||
- turnId: string,
|
||
- toolCallId: string,
|
||
- cwd?: string,
|
||
- ): Promise<IManagedShell> {
|
||
- for (const shell of this._shells.values()) {
|
||
- if (shell.shellType === shellType && this._terminalManager.hasTerminal(shell.terminalUri)) {
|
||
- const exitCode = this._terminalManager.getExitCode(shell.terminalUri);
|
||
- if (exitCode === undefined) {
|
||
- this._trackToolCall(toolCallId, shell.id);
|
||
- return shell;
|
||
- }
|
||
- this._shells.delete(shell.id);
|
||
- }
|
||
- }
|
||
-
|
||
- const id = generateUuid();
|
||
- const terminalUri = `agenthost-terminal://shell/${id}`;
|
||
-
|
||
- const claim: ITerminalSessionClaim = {
|
||
- kind: TerminalClaimKind.Session,
|
||
- session: this._sessionUri.toString(),
|
||
- turnId,
|
||
- toolCallId,
|
||
- };
|
||
-
|
||
- const shellDisplayName = shellType === 'bash' ? 'Bash' : 'PowerShell';
|
||
-
|
||
- await this._terminalManager.createTerminal({
|
||
- terminal: terminalUri,
|
||
- claim,
|
||
- name: shellDisplayName,
|
||
- cwd,
|
||
- }, { shell: getShellExecutable(shellType) });
|
||
-
|
||
- const shell: IManagedShell = { id, terminalUri, shellType };
|
||
- this._shells.set(id, shell);
|
||
- this._trackToolCall(toolCallId, id);
|
||
- this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri})`);
|
||
- return shell;
|
||
- }
|
||
-
|
||
- private _trackToolCall(toolCallId: string, shellId: string): void {
|
||
- this._toolCallShells.set(toolCallId, shellId);
|
||
- }
|
||
-
|
||
- getTerminalUriForToolCall(toolCallId: string): string | undefined {
|
||
- const shellId = this._toolCallShells.get(toolCallId);
|
||
- if (!shellId) {
|
||
- return undefined;
|
||
- }
|
||
- return this._shells.get(shellId)?.terminalUri;
|
||
- }
|
||
-
|
||
- getShell(id: string): IManagedShell | undefined {
|
||
- return this._shells.get(id);
|
||
- }
|
||
-
|
||
- listShells(): IManagedShell[] {
|
||
- const result: IManagedShell[] = [];
|
||
- for (const shell of this._shells.values()) {
|
||
- if (this._terminalManager.hasTerminal(shell.terminalUri)) {
|
||
- result.push(shell);
|
||
- }
|
||
- }
|
||
- return result;
|
||
- }
|
||
-
|
||
- shutdownShell(id: string): boolean {
|
||
- const shell = this._shells.get(id);
|
||
- if (!shell) {
|
||
- return false;
|
||
- }
|
||
- this._terminalManager.disposeTerminal(shell.terminalUri);
|
||
- this._shells.delete(id);
|
||
- this._logService.info(`[ShellManager] Shut down shell ${id}`);
|
||
- return true;
|
||
- }
|
||
-
|
||
- dispose(): void {
|
||
- for (const shell of this._shells.values()) {
|
||
- if (this._terminalManager.hasTerminal(shell.terminalUri)) {
|
||
- this._terminalManager.disposeTerminal(shell.terminalUri);
|
||
- }
|
||
- }
|
||
- this._shells.clear();
|
||
- this._toolCallShells.clear();
|
||
- }
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Sentinel helpers
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-function makeSentinelId(): string {
|
||
- return generateUuid().replace(/-/g, '');
|
||
-}
|
||
-
|
||
-function buildSentinelCommand(sentinelId: string, shellType: ShellType): string {
|
||
- if (shellType === 'powershell') {
|
||
- return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`;
|
||
- }
|
||
- return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`;
|
||
-}
|
||
-
|
||
-function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } {
|
||
- const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`;
|
||
- const idx = content.indexOf(marker);
|
||
- if (idx === -1) {
|
||
- return { found: false, exitCode: -1, outputBeforeSentinel: content };
|
||
- }
|
||
-
|
||
- const outputBeforeSentinel = content.substring(0, idx);
|
||
- const afterMarker = content.substring(idx + marker.length);
|
||
- const endIdx = afterMarker.indexOf('>>>');
|
||
- const exitCodeStr = endIdx >= 0 ? afterMarker.substring(0, endIdx) : afterMarker.trim();
|
||
- const exitCode = parseInt(exitCodeStr, 10);
|
||
- return {
|
||
- found: true,
|
||
- exitCode: isNaN(exitCode) ? -1 : exitCode,
|
||
- outputBeforeSentinel,
|
||
- };
|
||
-}
|
||
-
|
||
-function prepareOutputForModel(rawOutput: string): string {
|
||
- let text = removeAnsiEscapeCodes(rawOutput).trim();
|
||
- if (text.length > MAX_OUTPUT_BYTES) {
|
||
- text = text.substring(text.length - MAX_OUTPUT_BYTES);
|
||
- }
|
||
- return text;
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Tool implementations
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-function makeSuccessResult(text: string): ToolResultObject {
|
||
- return { textResultForLlm: text, resultType: 'success' };
|
||
-}
|
||
-
|
||
-function makeFailureResult(text: string, error?: string): ToolResultObject {
|
||
- return { textResultForLlm: text, resultType: 'failure', error };
|
||
-}
|
||
-
|
||
-async function executeCommandInShell(
|
||
- shell: IManagedShell,
|
||
- command: string,
|
||
- timeoutMs: number,
|
||
- terminalManager: IAgentHostTerminalManager,
|
||
- logService: ILogService,
|
||
-): Promise<ToolResultObject> {
|
||
- const sentinelId = makeSentinelId();
|
||
- const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType);
|
||
- const disposables = new DisposableStore();
|
||
-
|
||
- const contentBefore = terminalManager.getContent(shell.terminalUri) ?? '';
|
||
- const offsetBefore = contentBefore.length;
|
||
-
|
||
- // PTY input uses \r for line endings — the PTY translates to \r\n
|
||
- const input = `${command}\r${sentinelCmd}\r`;
|
||
- terminalManager.writeInput(shell.terminalUri, input);
|
||
-
|
||
- return new Promise<ToolResultObject>(resolve => {
|
||
- let resolved = false;
|
||
- const finish = (result: ToolResultObject) => {
|
||
- if (resolved) {
|
||
- return;
|
||
- }
|
||
- resolved = true;
|
||
- disposables.dispose();
|
||
- resolve(result);
|
||
- };
|
||
-
|
||
- const checkForSentinel = () => {
|
||
- const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||
- // Clamp offset: the terminal manager trims content when it exceeds
|
||
- // 100k chars (slices to last 80k). If trimming happened after we
|
||
- // captured offsetBefore, scan from the start of the current buffer.
|
||
- const clampedOffset = Math.min(offsetBefore, fullContent.length);
|
||
- const newContent = fullContent.substring(clampedOffset);
|
||
- const parsed = parseSentinel(newContent, sentinelId);
|
||
- if (parsed.found) {
|
||
- const output = prepareOutputForModel(parsed.outputBeforeSentinel);
|
||
- logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`);
|
||
- if (parsed.exitCode === 0) {
|
||
- finish(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`));
|
||
- } else {
|
||
- finish(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`));
|
||
- }
|
||
- }
|
||
- };
|
||
-
|
||
- disposables.add(terminalManager.onData(shell.terminalUri, () => {
|
||
- checkForSentinel();
|
||
- }));
|
||
-
|
||
- disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => {
|
||
- logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`);
|
||
- const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||
- const newContent = fullContent.substring(offsetBefore);
|
||
- const output = prepareOutputForModel(newContent);
|
||
- finish(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`));
|
||
- }));
|
||
-
|
||
- disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => {
|
||
- if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
|
||
- logService.info(`[ShellTool] Continuing in background (claim narrowed)`);
|
||
- finish(makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'));
|
||
- }
|
||
- }));
|
||
-
|
||
- const timer = setTimeout(() => {
|
||
- logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`);
|
||
- const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||
- const newContent = fullContent.substring(offsetBefore);
|
||
- const output = prepareOutputForModel(newContent);
|
||
- finish(makeFailureResult(
|
||
- `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
||
- 'timeout',
|
||
- ));
|
||
- }, timeoutMs);
|
||
- disposables.add(toDisposable(() => clearTimeout(timer)));
|
||
-
|
||
- checkForSentinel();
|
||
- });
|
||
-}
|
||
-
|
||
-// ---------------------------------------------------------------------------
|
||
-// Public factory
|
||
-// ---------------------------------------------------------------------------
|
||
-
|
||
-interface IShellToolArgs {
|
||
- command: string;
|
||
- timeout?: number;
|
||
-}
|
||
-
|
||
-interface IWriteShellArgs {
|
||
- command: string;
|
||
-}
|
||
-
|
||
-interface IReadShellArgs {
|
||
- shell_id?: string;
|
||
-}
|
||
-
|
||
-interface IShutdownShellArgs {
|
||
- shell_id?: string;
|
||
-}
|
||
-
|
||
-/**
|
||
- * Creates the set of SDK {@link Tool} definitions that override the built-in
|
||
- * Copilot CLI shell tools with PTY-backed implementations.
|
||
- *
|
||
- * Returns tools for the platform-appropriate shell (bash or powershell),
|
||
- * including companion tools (read, write, shutdown, list).
|
||
- */
|
||
-export function createShellTools(
|
||
- shellManager: ShellManager,
|
||
- terminalManager: IAgentHostTerminalManager,
|
||
- logService: ILogService,
|
||
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
-): Tool<any>[] {
|
||
- const shellType: ShellType = platform.isWindows ? 'powershell' : 'bash';
|
||
-
|
||
- const primaryTool: Tool<IShellToolArgs> = {
|
||
- name: shellType,
|
||
- description: `Execute a command in a persistent ${shellType} shell. The shell is reused across calls.`,
|
||
- parameters: {
|
||
- type: 'object',
|
||
- properties: {
|
||
- command: { type: 'string', description: 'The command to execute' },
|
||
- timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
|
||
- },
|
||
- required: ['command'],
|
||
- },
|
||
- overridesBuiltInTool: true,
|
||
- handler: async (args, invocation) => {
|
||
- const shell = await shellManager.getOrCreateShell(
|
||
- shellType,
|
||
- invocation.toolCallId,
|
||
- invocation.toolCallId,
|
||
- );
|
||
- const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS;
|
||
- return executeCommandInShell(shell, args.command, timeoutMs, terminalManager, logService);
|
||
- },
|
||
- };
|
||
-
|
||
- const readTool: Tool<IReadShellArgs> = {
|
||
- name: `read_${shellType}`,
|
||
- description: `Read the latest output from a running ${shellType} shell.`,
|
||
- parameters: {
|
||
- type: 'object',
|
||
- properties: {
|
||
- shell_id: { type: 'string', description: 'Shell ID to read from (optional; uses latest shell if omitted)' },
|
||
- },
|
||
- },
|
||
- overridesBuiltInTool: true,
|
||
- handler: (args) => {
|
||
- const shells = shellManager.listShells();
|
||
- const shell = args.shell_id
|
||
- ? shellManager.getShell(args.shell_id)
|
||
- : shells[shells.length - 1];
|
||
- if (!shell) {
|
||
- return makeFailureResult('No active shell found.', 'no_shell');
|
||
- }
|
||
- const content = terminalManager.getContent(shell.terminalUri);
|
||
- if (!content) {
|
||
- return makeSuccessResult('(no output)');
|
||
- }
|
||
- return makeSuccessResult(prepareOutputForModel(content));
|
||
- },
|
||
- };
|
||
-
|
||
- const writeTool: Tool<IWriteShellArgs> = {
|
||
- name: `write_${shellType}`,
|
||
- description: `Send input to a running ${shellType} shell (e.g. answering a prompt, sending Ctrl+C).`,
|
||
- parameters: {
|
||
- type: 'object',
|
||
- properties: {
|
||
- command: { type: 'string', description: 'Text to write to the shell stdin' },
|
||
- },
|
||
- required: ['command'],
|
||
- },
|
||
- overridesBuiltInTool: true,
|
||
- handler: (args) => {
|
||
- const shells = shellManager.listShells();
|
||
- const shell = shells[shells.length - 1];
|
||
- if (!shell) {
|
||
- return makeFailureResult('No active shell found.', 'no_shell');
|
||
- }
|
||
- terminalManager.writeInput(shell.terminalUri, args.command);
|
||
- return makeSuccessResult('Input sent to shell.');
|
||
- },
|
||
- };
|
||
-
|
||
- const shutdownTool: Tool<IShutdownShellArgs> = {
|
||
- name: shellType === 'bash' ? 'bash_shutdown' : `${shellType}_shutdown`,
|
||
- description: `Stop a ${shellType} shell.`,
|
||
- parameters: {
|
||
- type: 'object',
|
||
- properties: {
|
||
- shell_id: { type: 'string', description: 'Shell ID to stop (optional; stops latest shell if omitted)' },
|
||
- },
|
||
- },
|
||
- overridesBuiltInTool: true,
|
||
- handler: (args) => {
|
||
- if (args.shell_id) {
|
||
- const success = shellManager.shutdownShell(args.shell_id);
|
||
- return success
|
||
- ? makeSuccessResult('Shell stopped.')
|
||
- : makeFailureResult('Shell not found.', 'not_found');
|
||
- }
|
||
- const shells = shellManager.listShells();
|
||
- const shell = shells[shells.length - 1];
|
||
- if (!shell) {
|
||
- return makeFailureResult('No active shell to stop.', 'no_shell');
|
||
- }
|
||
- shellManager.shutdownShell(shell.id);
|
||
- return makeSuccessResult('Shell stopped.');
|
||
- },
|
||
- };
|
||
-
|
||
- const listTool: Tool<Record<string, never>> = {
|
||
- name: `list_${shellType}`,
|
||
- description: `List active ${shellType} shell instances.`,
|
||
- parameters: { type: 'object', properties: {} },
|
||
- overridesBuiltInTool: true,
|
||
- handler: () => {
|
||
- const shells = shellManager.listShells();
|
||
- if (shells.length === 0) {
|
||
- return makeSuccessResult('No active shells.');
|
||
- }
|
||
- const descriptions = shells.map(s => {
|
||
- const exitCode = terminalManager.getExitCode(s.terminalUri);
|
||
- const status = exitCode !== undefined ? `exited (${exitCode})` : 'running';
|
||
- return `- ${s.id}: ${s.shellType} [${status}]`;
|
||
- });
|
||
- return makeSuccessResult(descriptions.join('\n'));
|
||
- },
|
||
- };
|
||
-
|
||
- return [primaryTool, readTool, writeTool, shutdownTool, listTool];
|
||
-}
|
||
diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
|
||
index 903158d8..31c74bc2 100644
|
||
--- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
|
||
+++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts
|
||
@@ -40,4 +40,4 @@ export interface ISessionEventToolComplete {
|
||
success: boolean;
|
||
- result?: { content?: string };
|
||
- error?: { message: string; code?: string };
|
||
+ result?: { content?: string; };
|
||
+ error?: { message: string; code?: string; };
|
||
isUserRequested?: boolean;
|
||
@@ -54,3 +54,3 @@ export interface ISessionEventMessage {
|
||
content?: string;
|
||
- toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[];
|
||
+ toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom'; }[];
|
||
reasoningOpaque?: string;
|
||
@@ -63,3 +63,3 @@ export interface ISessionEventMessage {
|
||
/** Minimal event shape for session history mapping. */
|
||
-export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | ISessionEventSubagentStarted | { type: string; data?: unknown };
|
||
+export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | ISessionEventSubagentStarted | { type: string; data?: unknown; };
|
||
|
||
@@ -88,3 +88,3 @@ export async function mapSessionEvents(
|
||
const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[] = [];
|
||
- const toolInfoByCallId = new Map<string, { toolName: string; parameters: Record<string, unknown> | undefined }>();
|
||
+ const toolInfoByCallId = new Map<string, { toolName: string; parameters: Record<string, unknown> | undefined; }>();
|
||
|
||
diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts
|
||
index 8533a728..ca1195b3 100644
|
||
--- a/src/vs/platform/agentHost/test/node/agentService.test.ts
|
||
+++ b/src/vs/platform/agentHost/test/node/agentService.test.ts
|
||
@@ -202,3 +202,3 @@ suite('AgentService (node dispatcher)', () => {
|
||
// Manually add the session to the mock
|
||
- (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
|
||
+ (agent as unknown as { _sessions: Map<string, URI>; })._sessions.set(sessionId, sessionUri);
|
||
|
||
diff --git a/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts
|
||
deleted file mode 100644
|
||
index d5577754..00000000
|
||
--- a/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts
|
||
+++ /dev/null
|
||
@@ -1,705 +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 assert from 'assert';
|
||
-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||
-import {
|
||
- parseEventLog,
|
||
- serializeEventLog,
|
||
- findTurnBoundaryInEventLog,
|
||
- buildForkedEventLog,
|
||
- buildTruncatedEventLog,
|
||
- buildWorkspaceYaml,
|
||
- forkSessionInDb,
|
||
- truncateSessionInDb,
|
||
- type ICopilotEventLogEntry,
|
||
-} from '../../node/copilot/copilotAgentForking.js';
|
||
-
|
||
-// ---- Test helpers -----------------------------------------------------------
|
||
-
|
||
-function makeEntry(type: string, overrides?: Partial<ICopilotEventLogEntry>): ICopilotEventLogEntry {
|
||
- return {
|
||
- type,
|
||
- data: {},
|
||
- id: `id-${Math.random().toString(36).slice(2, 8)}`,
|
||
- timestamp: new Date().toISOString(),
|
||
- parentId: null,
|
||
- ...overrides,
|
||
- };
|
||
-}
|
||
-
|
||
-/**
|
||
- * Builds a minimal event log representing a multi-turn session.
|
||
- * Each turn = user.message → assistant.turn_start → assistant.message → assistant.turn_end.
|
||
- */
|
||
-function buildTestEventLog(turnCount: number): ICopilotEventLogEntry[] {
|
||
- const entries: ICopilotEventLogEntry[] = [];
|
||
- let lastId: string | null = null;
|
||
-
|
||
- const sessionStart = makeEntry('session.start', {
|
||
- id: 'session-start-id',
|
||
- data: { sessionId: 'source-session', context: { cwd: '/test' } },
|
||
- parentId: null,
|
||
- });
|
||
- entries.push(sessionStart);
|
||
- lastId = sessionStart.id;
|
||
-
|
||
- for (let turn = 0; turn < turnCount; turn++) {
|
||
- const userMsg = makeEntry('user.message', {
|
||
- id: `user-msg-${turn}`,
|
||
- data: { content: `Turn ${turn} message` },
|
||
- parentId: lastId,
|
||
- });
|
||
- entries.push(userMsg);
|
||
- lastId = userMsg.id;
|
||
-
|
||
- const turnStart = makeEntry('assistant.turn_start', {
|
||
- id: `turn-start-${turn}`,
|
||
- data: { turnId: String(turn) },
|
||
- parentId: lastId,
|
||
- });
|
||
- entries.push(turnStart);
|
||
- lastId = turnStart.id;
|
||
-
|
||
- const assistantMsg = makeEntry('assistant.message', {
|
||
- id: `assistant-msg-${turn}`,
|
||
- data: { content: `Response ${turn}` },
|
||
- parentId: lastId,
|
||
- });
|
||
- entries.push(assistantMsg);
|
||
- lastId = assistantMsg.id;
|
||
-
|
||
- const turnEnd = makeEntry('assistant.turn_end', {
|
||
- id: `turn-end-${turn}`,
|
||
- parentId: lastId,
|
||
- });
|
||
- entries.push(turnEnd);
|
||
- lastId = turnEnd.id;
|
||
- }
|
||
-
|
||
- return entries;
|
||
-}
|
||
-
|
||
-suite('CopilotAgentForking', () => {
|
||
-
|
||
- ensureNoDisposablesAreLeakedInTestSuite();
|
||
-
|
||
- // ---- parseEventLog / serializeEventLog ------------------------------
|
||
-
|
||
- suite('parseEventLog', () => {
|
||
-
|
||
- test('parses a single-line JSONL', () => {
|
||
- const entry = makeEntry('session.start');
|
||
- const jsonl = JSON.stringify(entry);
|
||
- const result = parseEventLog(jsonl);
|
||
- assert.strictEqual(result.length, 1);
|
||
- assert.strictEqual(result[0].type, 'session.start');
|
||
- });
|
||
-
|
||
- test('parses multi-line JSONL', () => {
|
||
- const entries = [makeEntry('session.start'), makeEntry('user.message')];
|
||
- const jsonl = entries.map(e => JSON.stringify(e)).join('\n');
|
||
- const result = parseEventLog(jsonl);
|
||
- assert.strictEqual(result.length, 2);
|
||
- });
|
||
-
|
||
- test('ignores empty lines', () => {
|
||
- const entry = makeEntry('session.start');
|
||
- const jsonl = '\n' + JSON.stringify(entry) + '\n\n';
|
||
- const result = parseEventLog(jsonl);
|
||
- assert.strictEqual(result.length, 1);
|
||
- });
|
||
-
|
||
- test('empty input returns empty array', () => {
|
||
- assert.deepStrictEqual(parseEventLog(''), []);
|
||
- assert.deepStrictEqual(parseEventLog('\n\n'), []);
|
||
- });
|
||
- });
|
||
-
|
||
- suite('serializeEventLog', () => {
|
||
-
|
||
- test('round-trips correctly', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- const serialized = serializeEventLog(entries);
|
||
- const parsed = parseEventLog(serialized);
|
||
- assert.strictEqual(parsed.length, entries.length);
|
||
- for (let i = 0; i < entries.length; i++) {
|
||
- assert.strictEqual(parsed[i].id, entries[i].id);
|
||
- assert.strictEqual(parsed[i].type, entries[i].type);
|
||
- }
|
||
- });
|
||
-
|
||
- test('ends with a newline', () => {
|
||
- const entries = [makeEntry('session.start')];
|
||
- const serialized = serializeEventLog(entries);
|
||
- assert.ok(serialized.endsWith('\n'));
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- findTurnBoundaryInEventLog -------------------------------------
|
||
-
|
||
- suite('findTurnBoundaryInEventLog', () => {
|
||
-
|
||
- test('finds first turn boundary', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const boundary = findTurnBoundaryInEventLog(entries, 0);
|
||
- // Turn 0: user.message(1) + turn_start(2) + assistant.message(3) + turn_end(4)
|
||
- // Index 4 = turn_end of turn 0
|
||
- assert.strictEqual(boundary, 4);
|
||
- assert.strictEqual(entries[boundary].type, 'assistant.turn_end');
|
||
- assert.strictEqual(entries[boundary].id, 'turn-end-0');
|
||
- });
|
||
-
|
||
- test('finds middle turn boundary', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const boundary = findTurnBoundaryInEventLog(entries, 1);
|
||
- // Turn 1 ends at index 8
|
||
- assert.strictEqual(boundary, 8);
|
||
- assert.strictEqual(entries[boundary].type, 'assistant.turn_end');
|
||
- assert.strictEqual(entries[boundary].id, 'turn-end-1');
|
||
- });
|
||
-
|
||
- test('finds last turn boundary', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const boundary = findTurnBoundaryInEventLog(entries, 2);
|
||
- assert.strictEqual(boundary, entries.length - 1);
|
||
- assert.strictEqual(entries[boundary].type, 'assistant.turn_end');
|
||
- assert.strictEqual(entries[boundary].id, 'turn-end-2');
|
||
- });
|
||
-
|
||
- test('returns -1 for non-existent turn', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- assert.strictEqual(findTurnBoundaryInEventLog(entries, 5), -1);
|
||
- });
|
||
-
|
||
- test('returns -1 for empty log', () => {
|
||
- assert.strictEqual(findTurnBoundaryInEventLog([], 0), -1);
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- buildForkedEventLog --------------------------------------------
|
||
-
|
||
- suite('buildForkedEventLog', () => {
|
||
-
|
||
- test('forks at turn 0', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const forked = buildForkedEventLog(entries, 0, 'new-session-id');
|
||
-
|
||
- // Should have session.start + turn 0 events (user.message, turn_start, assistant.message, turn_end)
|
||
- assert.strictEqual(forked.length, 5);
|
||
- assert.strictEqual(forked[0].type, 'session.start');
|
||
- assert.strictEqual((forked[0].data as Record<string, unknown>).sessionId, 'new-session-id');
|
||
- });
|
||
-
|
||
- test('forks at turn 1', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const forked = buildForkedEventLog(entries, 1, 'new-session-id');
|
||
-
|
||
- // session.start + 2 turns × 4 events = 9 events
|
||
- assert.strictEqual(forked.length, 9);
|
||
- });
|
||
-
|
||
- test('generates unique UUIDs', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- const forked = buildForkedEventLog(entries, 0, 'new-session-id');
|
||
-
|
||
- const ids = new Set(forked.map(e => e.id));
|
||
- assert.strictEqual(ids.size, forked.length, 'All IDs should be unique');
|
||
-
|
||
- // None should match the original
|
||
- for (const entry of forked) {
|
||
- assert.ok(!entries.some(e => e.id === entry.id), 'Should not reuse original IDs');
|
||
- }
|
||
- });
|
||
-
|
||
- test('re-chains parentId links', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- const forked = buildForkedEventLog(entries, 0, 'new-session-id');
|
||
-
|
||
- // First event has no parent
|
||
- assert.strictEqual(forked[0].parentId, null);
|
||
-
|
||
- // Each subsequent event's parentId should be a valid ID in the forked log
|
||
- const idSet = new Set(forked.map(e => e.id));
|
||
- for (let i = 1; i < forked.length; i++) {
|
||
- assert.ok(
|
||
- forked[i].parentId !== null && idSet.has(forked[i].parentId!),
|
||
- `Event ${i} (${forked[i].type}) should have a valid parentId`,
|
||
- );
|
||
- }
|
||
- });
|
||
-
|
||
- test('strips session.shutdown and session.resume events', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- // Insert lifecycle events
|
||
- entries.splice(5, 0, makeEntry('session.shutdown', { parentId: entries[4].id }));
|
||
- entries.splice(6, 0, makeEntry('session.resume', { parentId: entries[5].id }));
|
||
-
|
||
- const forked = buildForkedEventLog(entries, 1, 'new-session-id');
|
||
- assert.ok(!forked.some(e => e.type === 'session.shutdown'));
|
||
- assert.ok(!forked.some(e => e.type === 'session.resume'));
|
||
- });
|
||
-
|
||
- test('throws for invalid turn index', () => {
|
||
- const entries = buildTestEventLog(1);
|
||
- assert.throws(() => buildForkedEventLog(entries, 5, 'new-session-id'));
|
||
- });
|
||
-
|
||
- test('falls back to last kept event when lifecycle event parent is stripped', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- // Insert shutdown event between turns, then make the next turn's
|
||
- // user.message point to the shutdown event (which will be stripped)
|
||
- const shutdownEntry = makeEntry('session.shutdown', {
|
||
- id: 'shutdown-1',
|
||
- parentId: entries[4].id, // turn-end-0
|
||
- });
|
||
- entries.splice(5, 0, shutdownEntry);
|
||
- // Next entry (user-msg-1) now points to the shutdown event
|
||
- entries[6] = { ...entries[6], parentId: 'shutdown-1' };
|
||
-
|
||
- const forked = buildForkedEventLog(entries, 1, 'new-session-id');
|
||
-
|
||
- // All parentIds should be valid
|
||
- const idSet = new Set(forked.map(e => e.id));
|
||
- for (let i = 1; i < forked.length; i++) {
|
||
- assert.ok(
|
||
- forked[i].parentId !== null && idSet.has(forked[i].parentId!),
|
||
- `Event ${i} (${forked[i].type}) should have a valid parentId, got ${forked[i].parentId}`,
|
||
- );
|
||
- }
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- buildTruncatedEventLog -----------------------------------------
|
||
-
|
||
- suite('buildTruncatedEventLog', () => {
|
||
-
|
||
- test('truncates to turn 0', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const truncated = buildTruncatedEventLog(entries, 0);
|
||
-
|
||
- // New session.start + turn 0 events (user.message, turn_start, assistant.message, turn_end)
|
||
- assert.strictEqual(truncated.length, 5);
|
||
- assert.strictEqual(truncated[0].type, 'session.start');
|
||
- });
|
||
-
|
||
- test('truncates to turn 1', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- const truncated = buildTruncatedEventLog(entries, 1);
|
||
-
|
||
- // New session.start + 2 turns × 4 events = 9 events
|
||
- assert.strictEqual(truncated.length, 9);
|
||
- });
|
||
-
|
||
- test('prepends fresh session.start', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- const truncated = buildTruncatedEventLog(entries, 0);
|
||
-
|
||
- assert.strictEqual(truncated[0].type, 'session.start');
|
||
- assert.strictEqual(truncated[0].parentId, null);
|
||
- // Should not reuse original session.start ID
|
||
- assert.notStrictEqual(truncated[0].id, entries[0].id);
|
||
- });
|
||
-
|
||
- test('re-chains parentId links', () => {
|
||
- const entries = buildTestEventLog(2);
|
||
- const truncated = buildTruncatedEventLog(entries, 0);
|
||
-
|
||
- const idSet = new Set(truncated.map(e => e.id));
|
||
- for (let i = 1; i < truncated.length; i++) {
|
||
- assert.ok(
|
||
- truncated[i].parentId !== null && idSet.has(truncated[i].parentId!),
|
||
- `Event ${i} (${truncated[i].type}) should have a valid parentId`,
|
||
- );
|
||
- }
|
||
- });
|
||
-
|
||
- test('strips lifecycle events', () => {
|
||
- const entries = buildTestEventLog(3);
|
||
- // Add lifecycle events between turns
|
||
- entries.splice(5, 0, makeEntry('session.shutdown'));
|
||
- entries.splice(6, 0, makeEntry('session.resume'));
|
||
-
|
||
- const truncated = buildTruncatedEventLog(entries, 2);
|
||
- const lifecycleEvents = truncated.filter(
|
||
- e => e.type === 'session.shutdown' || e.type === 'session.resume',
|
||
- );
|
||
- assert.strictEqual(lifecycleEvents.length, 0);
|
||
- });
|
||
-
|
||
- test('throws for invalid turn index', () => {
|
||
- const entries = buildTestEventLog(1);
|
||
- assert.throws(() => buildTruncatedEventLog(entries, 5));
|
||
- });
|
||
-
|
||
- test('throws when no session.start exists', () => {
|
||
- const entries = [makeEntry('user.message')];
|
||
- assert.throws(() => buildTruncatedEventLog(entries, 0));
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- buildWorkspaceYaml ---------------------------------------------
|
||
-
|
||
- suite('buildWorkspaceYaml', () => {
|
||
-
|
||
- test('contains required fields', () => {
|
||
- const yaml = buildWorkspaceYaml('test-id', '/home/user/project', 'Test summary');
|
||
- assert.ok(yaml.includes('id: test-id'));
|
||
- assert.ok(yaml.includes('cwd: /home/user/project'));
|
||
- assert.ok(yaml.includes('summary: Test summary'));
|
||
- assert.ok(yaml.includes('summary_count: 0'));
|
||
- assert.ok(yaml.includes('created_at:'));
|
||
- assert.ok(yaml.includes('updated_at:'));
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- SQLite operations (in-memory) ----------------------------------
|
||
-
|
||
- suite('forkSessionInDb', () => {
|
||
-
|
||
- async function openTestDb(): Promise<import('@vscode/sqlite3').Database> {
|
||
- const sqlite3 = await import('@vscode/sqlite3');
|
||
- return new Promise((resolve, reject) => {
|
||
- const db = new sqlite3.default.Database(':memory:', (err: Error | null) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(db);
|
||
- });
|
||
- });
|
||
- }
|
||
-
|
||
- function exec(db: import('@vscode/sqlite3').Database, sql: string): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.exec(sql, err => err ? reject(err) : resolve());
|
||
- });
|
||
- }
|
||
-
|
||
- function all(db: import('@vscode/sqlite3').Database, sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.all(sql, params, (err: Error | null, rows: Record<string, unknown>[]) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(rows);
|
||
- });
|
||
- });
|
||
- }
|
||
-
|
||
- function close(db: import('@vscode/sqlite3').Database): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.close(err => err ? reject(err) : resolve());
|
||
- });
|
||
- }
|
||
-
|
||
- async function setupSchema(db: import('@vscode/sqlite3').Database): Promise<void> {
|
||
- await exec(db, `
|
||
- CREATE TABLE sessions (
|
||
- id TEXT PRIMARY KEY,
|
||
- cwd TEXT,
|
||
- repository TEXT,
|
||
- branch TEXT,
|
||
- summary TEXT,
|
||
- created_at TEXT,
|
||
- updated_at TEXT,
|
||
- host_type TEXT
|
||
- );
|
||
- CREATE TABLE turns (
|
||
- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
- session_id TEXT NOT NULL,
|
||
- turn_index INTEGER NOT NULL,
|
||
- user_message TEXT,
|
||
- assistant_response TEXT,
|
||
- timestamp TEXT,
|
||
- UNIQUE(session_id, turn_index)
|
||
- );
|
||
- CREATE TABLE session_files (
|
||
- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
- session_id TEXT NOT NULL,
|
||
- file_path TEXT,
|
||
- tool_name TEXT,
|
||
- turn_index INTEGER,
|
||
- first_seen_at TEXT
|
||
- );
|
||
- CREATE VIRTUAL TABLE search_index USING fts5(
|
||
- content,
|
||
- session_id,
|
||
- source_type,
|
||
- source_id
|
||
- );
|
||
- CREATE TABLE checkpoints (
|
||
- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
- session_id TEXT NOT NULL,
|
||
- checkpoint_number INTEGER,
|
||
- title TEXT,
|
||
- overview TEXT,
|
||
- history TEXT,
|
||
- work_done TEXT,
|
||
- technical_details TEXT,
|
||
- important_files TEXT,
|
||
- next_steps TEXT,
|
||
- created_at TEXT
|
||
- );
|
||
- `);
|
||
- }
|
||
-
|
||
- async function seedTestData(db: import('@vscode/sqlite3').Database, sessionId: string, turnCount: number): Promise<void> {
|
||
- await exec(db, `
|
||
- INSERT INTO sessions (id, cwd, repository, branch, summary, created_at, updated_at, host_type)
|
||
- VALUES ('${sessionId}', '/test', 'test-repo', 'main', 'Test session', '2026-01-01', '2026-01-01', 'github');
|
||
- `);
|
||
- for (let i = 0; i < turnCount; i++) {
|
||
- await exec(db, `
|
||
- INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp)
|
||
- VALUES ('${sessionId}', ${i}, 'msg ${i}', 'resp ${i}', '2026-01-01');
|
||
- `);
|
||
- await exec(db, `
|
||
- INSERT INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at)
|
||
- VALUES ('${sessionId}', 'file${i}.ts', 'edit', ${i}, '2026-01-01');
|
||
- `);
|
||
- }
|
||
- }
|
||
-
|
||
- test('copies session metadata', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await seedTestData(db, 'source', 3);
|
||
-
|
||
- await forkSessionInDb(db, 'source', 'forked', 1);
|
||
-
|
||
- const sessions = await all(db, 'SELECT * FROM sessions WHERE id = ?', ['forked']);
|
||
- assert.strictEqual(sessions.length, 1);
|
||
- assert.strictEqual(sessions[0].cwd, '/test');
|
||
- assert.strictEqual(sessions[0].repository, 'test-repo');
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('copies turns up to fork point', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await seedTestData(db, 'source', 3);
|
||
-
|
||
- await forkSessionInDb(db, 'source', 'forked', 1);
|
||
-
|
||
- const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index', ['forked']);
|
||
- assert.strictEqual(turns.length, 2); // turns 0 and 1
|
||
- assert.strictEqual(turns[0].turn_index, 0);
|
||
- assert.strictEqual(turns[1].turn_index, 1);
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('copies session files up to fork point', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await seedTestData(db, 'source', 3);
|
||
-
|
||
- await forkSessionInDb(db, 'source', 'forked', 1);
|
||
-
|
||
- const files = await all(db, 'SELECT * FROM session_files WHERE session_id = ?', ['forked']);
|
||
- assert.strictEqual(files.length, 2); // files from turns 0 and 1
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('does not affect source session', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await seedTestData(db, 'source', 3);
|
||
-
|
||
- await forkSessionInDb(db, 'source', 'forked', 1);
|
||
-
|
||
- const sourceTurns = await all(db, 'SELECT * FROM turns WHERE session_id = ?', ['source']);
|
||
- assert.strictEqual(sourceTurns.length, 3);
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
- });
|
||
-
|
||
- suite('truncateSessionInDb', () => {
|
||
-
|
||
- async function openTestDb(): Promise<import('@vscode/sqlite3').Database> {
|
||
- const sqlite3 = await import('@vscode/sqlite3');
|
||
- return new Promise((resolve, reject) => {
|
||
- const db = new sqlite3.default.Database(':memory:', (err: Error | null) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(db);
|
||
- });
|
||
- });
|
||
- }
|
||
-
|
||
- function exec(db: import('@vscode/sqlite3').Database, sql: string): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.exec(sql, err => err ? reject(err) : resolve());
|
||
- });
|
||
- }
|
||
-
|
||
- function all(db: import('@vscode/sqlite3').Database, sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.all(sql, params, (err: Error | null, rows: Record<string, unknown>[]) => {
|
||
- if (err) {
|
||
- return reject(err);
|
||
- }
|
||
- resolve(rows);
|
||
- });
|
||
- });
|
||
- }
|
||
-
|
||
- function close(db: import('@vscode/sqlite3').Database): Promise<void> {
|
||
- return new Promise((resolve, reject) => {
|
||
- db.close(err => err ? reject(err) : resolve());
|
||
- });
|
||
- }
|
||
-
|
||
- async function setupSchema(db: import('@vscode/sqlite3').Database): Promise<void> {
|
||
- await exec(db, `
|
||
- CREATE TABLE sessions (
|
||
- id TEXT PRIMARY KEY,
|
||
- cwd TEXT,
|
||
- repository TEXT,
|
||
- branch TEXT,
|
||
- summary TEXT,
|
||
- created_at TEXT,
|
||
- updated_at TEXT,
|
||
- host_type TEXT
|
||
- );
|
||
- CREATE TABLE turns (
|
||
- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
- session_id TEXT NOT NULL,
|
||
- turn_index INTEGER NOT NULL,
|
||
- user_message TEXT,
|
||
- assistant_response TEXT,
|
||
- timestamp TEXT,
|
||
- UNIQUE(session_id, turn_index)
|
||
- );
|
||
- CREATE VIRTUAL TABLE search_index USING fts5(
|
||
- content,
|
||
- session_id,
|
||
- source_type,
|
||
- source_id
|
||
- );
|
||
- `);
|
||
- }
|
||
-
|
||
- test('removes turns after truncation point', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await exec(db, `
|
||
- INSERT INTO sessions (id, cwd, summary, created_at, updated_at)
|
||
- VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01');
|
||
- `);
|
||
- for (let i = 0; i < 5; i++) {
|
||
- await exec(db, `
|
||
- INSERT INTO turns (session_id, turn_index, user_message, timestamp)
|
||
- VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01');
|
||
- `);
|
||
- }
|
||
-
|
||
- await truncateSessionInDb(db, 'sess', 2);
|
||
-
|
||
- const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index', ['sess']);
|
||
- assert.strictEqual(turns.length, 3); // turns 0, 1, 2
|
||
- assert.strictEqual(turns[0].turn_index, 0);
|
||
- assert.strictEqual(turns[2].turn_index, 2);
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('updates session timestamp', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await exec(db, `
|
||
- INSERT INTO sessions (id, cwd, summary, created_at, updated_at)
|
||
- VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01');
|
||
- `);
|
||
- await exec(db, `
|
||
- INSERT INTO turns (session_id, turn_index, user_message, timestamp)
|
||
- VALUES ('sess', 0, 'msg 0', '2026-01-01');
|
||
- `);
|
||
-
|
||
- await truncateSessionInDb(db, 'sess', 0);
|
||
-
|
||
- const sessions = await all(db, 'SELECT updated_at FROM sessions WHERE id = ?', ['sess']);
|
||
- assert.notStrictEqual(sessions[0].updated_at, '2026-01-01');
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('removes search index entries for truncated turns', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await exec(db, `
|
||
- INSERT INTO sessions (id, cwd, summary, created_at, updated_at)
|
||
- VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01');
|
||
- `);
|
||
- for (let i = 0; i < 3; i++) {
|
||
- await exec(db, `
|
||
- INSERT INTO turns (session_id, turn_index, user_message, timestamp)
|
||
- VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01');
|
||
- `);
|
||
- await exec(db, `
|
||
- INSERT INTO search_index (content, session_id, source_type, source_id)
|
||
- VALUES ('content ${i}', 'sess', 'turn', 'sess:turn:${i}');
|
||
- `);
|
||
- }
|
||
-
|
||
- await truncateSessionInDb(db, 'sess', 0);
|
||
-
|
||
- const searchEntries = await all(db, 'SELECT * FROM search_index WHERE session_id = ?', ['sess']);
|
||
- assert.strictEqual(searchEntries.length, 1);
|
||
- assert.strictEqual(searchEntries[0].source_id, 'sess:turn:0');
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
-
|
||
- test('removes all turns when keepUpToTurnIndex is -1', async () => {
|
||
- const db = await openTestDb();
|
||
- try {
|
||
- await setupSchema(db);
|
||
- await exec(db, `
|
||
- INSERT INTO sessions (id, cwd, summary, created_at, updated_at)
|
||
- VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01');
|
||
- `);
|
||
- for (let i = 0; i < 3; i++) {
|
||
- await exec(db, `
|
||
- INSERT INTO turns (session_id, turn_index, user_message, timestamp)
|
||
- VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01');
|
||
- `);
|
||
- await exec(db, `
|
||
- INSERT INTO search_index (content, session_id, source_type, source_id)
|
||
- VALUES ('content ${i}', 'sess', 'turn', 'sess:turn:${i}');
|
||
- `);
|
||
- }
|
||
-
|
||
- await truncateSessionInDb(db, 'sess', -1);
|
||
-
|
||
- const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ?', ['sess']);
|
||
- assert.strictEqual(turns.length, 0, 'all turns should be removed');
|
||
-
|
||
- const searchEntries = await all(db, 'SELECT * FROM search_index WHERE session_id = ?', ['sess']);
|
||
- assert.strictEqual(searchEntries.length, 0, 'all search entries should be removed');
|
||
- } finally {
|
||
- await close(db);
|
||
- }
|
||
- });
|
||
- });
|
||
-});
|
||
diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
|
||
deleted file mode 100644
|
||
index 3b02e51c..00000000
|
||
--- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
|
||
+++ /dev/null
|
||
@@ -1,456 +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 assert from 'assert';
|
||
-import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk';
|
||
-import { Emitter } from '../../../../base/common/event.js';
|
||
-import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||
-import { NullLogService, ILogService } from '../../../log/common/log.js';
|
||
-import { IFileService } from '../../../files/common/files.js';
|
||
-import { AgentSession, IAgentProgressEvent, IAgentUserInputRequestEvent } from '../../common/agentService.js';
|
||
-import { IDiffComputeService } from '../../common/diffComputeService.js';
|
||
-import { ISessionDataService } from '../../common/sessionDataService.js';
|
||
-import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind } from '../../common/state/sessionState.js';
|
||
-import { CopilotAgentSession, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js';
|
||
-import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js';
|
||
-import { InstantiationService } from '../../../instantiation/common/instantiationService.js';
|
||
-import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';
|
||
-import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js';
|
||
-
|
||
-// ---- Mock CopilotSession (SDK level) ----------------------------------------
|
||
-
|
||
-/**
|
||
- * Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to
|
||
- * store typed handlers, and exposes `fire()` so tests can push events
|
||
- * through the real {@link CopilotSessionWrapper} event pipeline.
|
||
- */
|
||
-class MockCopilotSession {
|
||
- readonly sessionId = 'test-session-1';
|
||
-
|
||
- private readonly _handlers = new Map<string, Set<(event: SessionEvent) => void>>();
|
||
-
|
||
- on<K extends SessionEventType>(eventType: K, handler: TypedSessionEventHandler<K>): () => void {
|
||
- let set = this._handlers.get(eventType);
|
||
- if (!set) {
|
||
- set = new Set();
|
||
- this._handlers.set(eventType, set);
|
||
- }
|
||
- set.add(handler as (event: SessionEvent) => void);
|
||
- return () => { set.delete(handler as (event: SessionEvent) => void); };
|
||
- }
|
||
-
|
||
- /** Push an event through to all registered handlers of the given type. */
|
||
- fire<K extends SessionEventType>(type: K, data: SessionEventPayload<K>['data']): void {
|
||
- const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload<K>;
|
||
- const set = this._handlers.get(type);
|
||
- if (set) {
|
||
- for (const handler of set) {
|
||
- handler(event);
|
||
- }
|
||
- }
|
||
- }
|
||
-
|
||
- // Stubs for methods the wrapper / session class calls
|
||
- async send() { return ''; }
|
||
- async abort() { }
|
||
- async setModel() { }
|
||
- async getMessages() { return []; }
|
||
- async destroy() { }
|
||
-}
|
||
-
|
||
-// ---- Helpers ----------------------------------------------------------------
|
||
-
|
||
-async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory?: URI }): Promise<{
|
||
- session: CopilotAgentSession;
|
||
- mockSession: MockCopilotSession;
|
||
- progressEvents: IAgentProgressEvent[];
|
||
-}> {
|
||
- const progressEmitter = disposables.add(new Emitter<IAgentProgressEvent>());
|
||
- const progressEvents: IAgentProgressEvent[] = [];
|
||
- disposables.add(progressEmitter.event(e => progressEvents.push(e)));
|
||
-
|
||
- const sessionUri = AgentSession.uri('copilot', 'test-session-1');
|
||
- const mockSession = new MockCopilotSession();
|
||
-
|
||
- const factory: SessionWrapperFactory = async () => new CopilotSessionWrapper(mockSession as unknown as CopilotSession);
|
||
-
|
||
- const services = new ServiceCollection();
|
||
- services.set(ILogService, new NullLogService());
|
||
- services.set(IFileService, { _serviceBrand: undefined } as IFileService);
|
||
- services.set(ISessionDataService, createSessionDataService());
|
||
- services.set(IDiffComputeService, createZeroDiffComputeService());
|
||
- const instantiationService = disposables.add(new InstantiationService(services));
|
||
-
|
||
- const session = disposables.add(instantiationService.createInstance(
|
||
- CopilotAgentSession,
|
||
- sessionUri,
|
||
- 'test-session-1',
|
||
- options?.workingDirectory,
|
||
- progressEmitter,
|
||
- factory,
|
||
- undefined, // shellManager
|
||
- ));
|
||
-
|
||
- await session.initializeSession();
|
||
-
|
||
- return { session, mockSession, progressEvents };
|
||
-}
|
||
-
|
||
-// ---- Tests ------------------------------------------------------------------
|
||
-
|
||
-suite('CopilotAgentSession', () => {
|
||
-
|
||
- const disposables = new DisposableStore();
|
||
-
|
||
- teardown(() => disposables.clear());
|
||
- ensureNoDisposablesAreLeakedInTestSuite();
|
||
-
|
||
- // ---- permission handling ----
|
||
-
|
||
- suite('permission handling', () => {
|
||
-
|
||
- test('auto-approves read inside working directory', async () => {
|
||
- const { session } = await createAgentSession(disposables, { workingDirectory: URI.file('/workspace') });
|
||
- const result = await session.handlePermissionRequest({
|
||
- kind: 'read',
|
||
- path: '/workspace/src/file.ts',
|
||
- toolCallId: 'tc-1',
|
||
- });
|
||
- assert.strictEqual(result.kind, 'approved');
|
||
- });
|
||
-
|
||
- test('does not auto-approve read outside working directory', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables, { workingDirectory: URI.file('/workspace') });
|
||
-
|
||
- // Kick off permission request but don't await — it will block
|
||
- const resultPromise = session.handlePermissionRequest({
|
||
- kind: 'read',
|
||
- path: '/other/file.ts',
|
||
- toolCallId: 'tc-2',
|
||
- });
|
||
-
|
||
- // Should have fired a tool_ready event
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'tool_ready');
|
||
-
|
||
- // Respond to it
|
||
- assert.ok(session.respondToPermissionRequest('tc-2', true));
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.kind, 'approved');
|
||
- });
|
||
-
|
||
- test('denies permission when no toolCallId', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
- const result = await session.handlePermissionRequest({ kind: 'write' });
|
||
- assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||
- });
|
||
-
|
||
- test('denied-interactively when user denies', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables);
|
||
- const resultPromise = session.handlePermissionRequest({
|
||
- kind: 'shell',
|
||
- toolCallId: 'tc-3',
|
||
- });
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- session.respondToPermissionRequest('tc-3', false);
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||
- });
|
||
-
|
||
- test('pending permissions are denied on dispose', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
- const resultPromise = session.handlePermissionRequest({
|
||
- kind: 'write',
|
||
- toolCallId: 'tc-4',
|
||
- });
|
||
-
|
||
- session.dispose();
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||
- });
|
||
-
|
||
- test('pending permissions are denied on abort', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
- const resultPromise = session.handlePermissionRequest({
|
||
- kind: 'write',
|
||
- toolCallId: 'tc-5',
|
||
- });
|
||
-
|
||
- await session.abort();
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||
- });
|
||
-
|
||
- test('respondToPermissionRequest returns false for unknown id', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
- assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false);
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- sendSteering ----
|
||
-
|
||
- suite('sendSteering', () => {
|
||
-
|
||
- test('fires steering_consumed after send resolves', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } });
|
||
-
|
||
- const consumed = progressEvents.find(e => e.type === 'steering_consumed');
|
||
- assert.ok(consumed, 'should fire steering_consumed event');
|
||
- assert.strictEqual((consumed as { id: string }).id, 'steer-1');
|
||
- });
|
||
-
|
||
- test('does not fire steering_consumed when send fails', async () => {
|
||
- const { session, mockSession, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- mockSession.send = async () => { throw new Error('send failed'); };
|
||
-
|
||
- await session.sendSteering({ id: 'steer-fail', userMessage: { text: 'will fail' } });
|
||
-
|
||
- const consumed = progressEvents.find(e => e.type === 'steering_consumed');
|
||
- assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure');
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- event mapping ----
|
||
-
|
||
- suite('event mapping', () => {
|
||
-
|
||
- test('tool_start event is mapped for non-hidden tools', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('tool.execution_start', {
|
||
- toolCallId: 'tc-10',
|
||
- toolName: 'bash',
|
||
- arguments: { command: 'echo hello' },
|
||
- } as SessionEventPayload<'tool.execution_start'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'tool_start');
|
||
- if (progressEvents[0].type === 'tool_start') {
|
||
- assert.strictEqual(progressEvents[0].toolCallId, 'tc-10');
|
||
- assert.strictEqual(progressEvents[0].toolName, 'bash');
|
||
- }
|
||
- });
|
||
-
|
||
- test('hidden tools are not emitted as tool_start', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('tool.execution_start', {
|
||
- toolCallId: 'tc-11',
|
||
- toolName: 'report_intent',
|
||
- } as SessionEventPayload<'tool.execution_start'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 0);
|
||
- });
|
||
-
|
||
- test('tool_complete event produces past-tense message', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- // First fire tool_start so it's tracked
|
||
- mockSession.fire('tool.execution_start', {
|
||
- toolCallId: 'tc-12',
|
||
- toolName: 'bash',
|
||
- arguments: { command: 'ls' },
|
||
- } as SessionEventPayload<'tool.execution_start'>['data']);
|
||
-
|
||
- // Then fire complete
|
||
- mockSession.fire('tool.execution_complete', {
|
||
- toolCallId: 'tc-12',
|
||
- success: true,
|
||
- result: { content: 'file1.ts\nfile2.ts' },
|
||
- } as SessionEventPayload<'tool.execution_complete'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 2);
|
||
- assert.strictEqual(progressEvents[1].type, 'tool_complete');
|
||
- if (progressEvents[1].type === 'tool_complete') {
|
||
- assert.strictEqual(progressEvents[1].toolCallId, 'tc-12');
|
||
- assert.ok(progressEvents[1].result.success);
|
||
- assert.ok(progressEvents[1].result.pastTenseMessage);
|
||
- }
|
||
- });
|
||
-
|
||
- test('tool_complete for untracked tool is ignored', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('tool.execution_complete', {
|
||
- toolCallId: 'tc-untracked',
|
||
- success: true,
|
||
- } as SessionEventPayload<'tool.execution_complete'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 0);
|
||
- });
|
||
-
|
||
- test('idle event is forwarded', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'idle');
|
||
- });
|
||
-
|
||
- test('error event is forwarded', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('session.error', {
|
||
- errorType: 'TestError',
|
||
- message: 'something went wrong',
|
||
- stack: 'Error: something went wrong',
|
||
- } as SessionEventPayload<'session.error'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'error');
|
||
- if (progressEvents[0].type === 'error') {
|
||
- assert.strictEqual(progressEvents[0].errorType, 'TestError');
|
||
- assert.strictEqual(progressEvents[0].message, 'something went wrong');
|
||
- }
|
||
- });
|
||
-
|
||
- test('message delta is forwarded', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('assistant.message_delta', {
|
||
- messageId: 'msg-1',
|
||
- deltaContent: 'Hello ',
|
||
- } as SessionEventPayload<'assistant.message_delta'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'delta');
|
||
- if (progressEvents[0].type === 'delta') {
|
||
- assert.strictEqual(progressEvents[0].content, 'Hello ');
|
||
- }
|
||
- });
|
||
-
|
||
- test('complete message with tool requests is forwarded', async () => {
|
||
- const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||
- mockSession.fire('assistant.message', {
|
||
- messageId: 'msg-2',
|
||
- content: 'Let me help you.',
|
||
- toolRequests: [{
|
||
- toolCallId: 'tc-20',
|
||
- name: 'bash',
|
||
- arguments: { command: 'ls' },
|
||
- type: 'function',
|
||
- }],
|
||
- } as SessionEventPayload<'assistant.message'>['data']);
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- assert.strictEqual(progressEvents[0].type, 'message');
|
||
- if (progressEvents[0].type === 'message') {
|
||
- assert.strictEqual(progressEvents[0].content, 'Let me help you.');
|
||
- assert.strictEqual(progressEvents[0].toolRequests?.length, 1);
|
||
- assert.strictEqual(progressEvents[0].toolRequests?.[0].toolCallId, 'tc-20');
|
||
- }
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- user input handling ----
|
||
-
|
||
- suite('user input handling', () => {
|
||
-
|
||
- function assertUserInputEvent(event: IAgentProgressEvent): asserts event is IAgentUserInputRequestEvent {
|
||
- assert.strictEqual(event.type, 'user_input_request');
|
||
- }
|
||
-
|
||
- test('handleUserInputRequest fires user_input_request progress event', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- // Start the request (don't await — it blocks waiting for response)
|
||
- const resultPromise = session.handleUserInputRequest(
|
||
- { question: 'What is your name?' },
|
||
- { sessionId: 'test-session-1' }
|
||
- );
|
||
-
|
||
- // Verify progress event was fired
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- const event = progressEvents[0];
|
||
- assertUserInputEvent(event);
|
||
- assert.strictEqual(event.request.message, 'What is your name?');
|
||
- const requestId = event.request.id;
|
||
- assert.ok(event.request.questions);
|
||
- const questionId = event.request.questions[0].id;
|
||
-
|
||
- // Respond to unblock the promise
|
||
- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||
- [questionId]: {
|
||
- state: SessionInputAnswerState.Submitted,
|
||
- value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' }
|
||
- }
|
||
- });
|
||
-
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.answer, 'Alice');
|
||
- assert.strictEqual(result.wasFreeform, true);
|
||
- });
|
||
-
|
||
- test('handleUserInputRequest with choices generates SingleSelect question', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- const resultPromise = session.handleUserInputRequest(
|
||
- { question: 'Pick a color', choices: ['red', 'blue', 'green'] },
|
||
- { sessionId: 'test-session-1' }
|
||
- );
|
||
-
|
||
- assert.strictEqual(progressEvents.length, 1);
|
||
- const event = progressEvents[0];
|
||
- assertUserInputEvent(event);
|
||
- assert.ok(event.request.questions);
|
||
- assert.strictEqual(event.request.questions.length, 1);
|
||
- assert.strictEqual(event.request.questions[0].kind, SessionInputQuestionKind.SingleSelect);
|
||
- if (event.request.questions[0].kind === SessionInputQuestionKind.SingleSelect) {
|
||
- assert.strictEqual(event.request.questions[0].options.length, 3);
|
||
- assert.strictEqual(event.request.questions[0].options[0].label, 'red');
|
||
- }
|
||
-
|
||
- // Respond with a selected choice
|
||
- const questions = event.request.questions;
|
||
- session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Accept, {
|
||
- [questions[0].id]: {
|
||
- state: SessionInputAnswerState.Submitted,
|
||
- value: { kind: SessionInputAnswerValueKind.Selected, value: 'blue' }
|
||
- }
|
||
- });
|
||
-
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.answer, 'blue');
|
||
- assert.strictEqual(result.wasFreeform, false);
|
||
- });
|
||
-
|
||
- test('handleUserInputRequest returns empty answer on cancel', async () => {
|
||
- const { session, progressEvents } = await createAgentSession(disposables);
|
||
-
|
||
- const resultPromise = session.handleUserInputRequest(
|
||
- { question: 'Cancel me' },
|
||
- { sessionId: 'test-session-1' }
|
||
- );
|
||
-
|
||
- const event = progressEvents[0];
|
||
- assertUserInputEvent(event);
|
||
- session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Cancel);
|
||
-
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.answer, '');
|
||
- assert.strictEqual(result.wasFreeform, true);
|
||
- });
|
||
-
|
||
- test('respondToUserInputRequest returns false for unknown id', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
- assert.strictEqual(session.respondToUserInputRequest('unknown-id', SessionInputResponseKind.Accept), false);
|
||
- });
|
||
-
|
||
- test('pending user inputs are cancelled on dispose', async () => {
|
||
- const { session } = await createAgentSession(disposables);
|
||
-
|
||
- const resultPromise = session.handleUserInputRequest(
|
||
- { question: 'Will be cancelled' },
|
||
- { sessionId: 'test-session-1' }
|
||
- );
|
||
-
|
||
- session.dispose();
|
||
- const result = await resultPromise;
|
||
- assert.strictEqual(result.answer, '');
|
||
- assert.strictEqual(result.wasFreeform, true);
|
||
- });
|
||
- });
|
||
-});
|
||
diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts
|
||
deleted file mode 100644
|
||
index 6ae1694c..00000000
|
||
--- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts
|
||
+++ /dev/null
|
||
@@ -1,117 +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 assert from 'assert';
|
||
-import * as cp from 'child_process';
|
||
-import * as fs from 'fs';
|
||
-import { tmpdir } from 'os';
|
||
-import { join } from '../../../../base/common/path.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { Promises } from '../../../../base/node/pfs.js';
|
||
-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||
-import { getRandomTestPath } from '../../../../base/test/node/testUtils.js';
|
||
-import { projectFromCopilotContext, projectFromRepository, resolveGitProject } from '../../node/copilot/copilotGitProject.js';
|
||
-
|
||
-function execGit(cwd: string, args: string[]): Promise<string> {
|
||
- return new Promise((resolve, reject) => {
|
||
- cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout, stderr) => {
|
||
- if (error) {
|
||
- reject(new Error(stderr || error.message));
|
||
- return;
|
||
- }
|
||
- resolve(stdout.trim());
|
||
- });
|
||
- });
|
||
-}
|
||
-
|
||
-suite('Copilot Git Project', () => {
|
||
- ensureNoDisposablesAreLeakedInTestSuite();
|
||
-
|
||
- let testDir: string;
|
||
-
|
||
- setup(async () => {
|
||
- testDir = getRandomTestPath(tmpdir(), 'vsctests', 'copilot-git-project');
|
||
- await fs.promises.mkdir(testDir, { recursive: true });
|
||
- });
|
||
-
|
||
- teardown(async () => {
|
||
- await Promises.rm(testDir);
|
||
- });
|
||
-
|
||
- async function createRepository(name: string): Promise<string> {
|
||
- const repositoryPath = join(testDir, name);
|
||
- await fs.promises.mkdir(repositoryPath, { recursive: true });
|
||
- await execGit(repositoryPath, ['init']);
|
||
- await execGit(repositoryPath, ['config', 'user.email', 'test@example.com']);
|
||
- await execGit(repositoryPath, ['config', 'user.name', 'Test User']);
|
||
- await fs.promises.writeFile(join(repositoryPath, 'README.md'), '# Test\n');
|
||
- await execGit(repositoryPath, ['add', 'README.md']);
|
||
- await execGit(repositoryPath, ['commit', '-m', 'initial']);
|
||
- return repositoryPath;
|
||
- }
|
||
-
|
||
- test('resolves a repository project from a worktree working directory', async () => {
|
||
- const repositoryPath = await createRepository('source-repo');
|
||
- const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath);
|
||
- const worktreePath = join(testDir, 'worktree-checkout');
|
||
- await execGit(repositoryPath, ['worktree', 'add', worktreePath]);
|
||
-
|
||
- const project = await resolveGitProject(URI.file(worktreePath));
|
||
-
|
||
- assert.deepStrictEqual({
|
||
- uri: project?.uri.toString(),
|
||
- displayName: project?.displayName,
|
||
- }, {
|
||
- uri: URI.file(canonicalRepositoryPath).toString(),
|
||
- displayName: 'source-repo',
|
||
- });
|
||
- });
|
||
-
|
||
- test('resolves the repository itself for a normal git working directory', async () => {
|
||
- const repositoryPath = await createRepository('normal-repo');
|
||
- const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath);
|
||
-
|
||
- const project = await resolveGitProject(URI.file(repositoryPath));
|
||
-
|
||
- assert.deepStrictEqual({
|
||
- uri: project?.uri.toString(),
|
||
- displayName: project?.displayName,
|
||
- }, {
|
||
- uri: URI.file(canonicalRepositoryPath).toString(),
|
||
- displayName: 'normal-repo',
|
||
- });
|
||
- });
|
||
-
|
||
- test('returns undefined outside a git working tree', async () => {
|
||
- const folder = join(testDir, 'plain-folder');
|
||
- await fs.promises.mkdir(folder);
|
||
-
|
||
- assert.strictEqual(await resolveGitProject(URI.file(folder)), undefined);
|
||
- });
|
||
-
|
||
- test('falls back to repository context when no git project is available', async () => {
|
||
- const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' });
|
||
-
|
||
- assert.deepStrictEqual({
|
||
- uri: project?.uri.toString(),
|
||
- displayName: project?.displayName,
|
||
- }, {
|
||
- uri: 'https://github.com/microsoft/vscode',
|
||
- displayName: 'vscode',
|
||
- });
|
||
- });
|
||
-
|
||
- test('parses repository URLs', () => {
|
||
- const project = projectFromRepository('https://github.com/microsoft/vscode.git');
|
||
-
|
||
- assert.deepStrictEqual({
|
||
- uri: project?.uri.toString(),
|
||
- displayName: project?.displayName,
|
||
- }, {
|
||
- uri: 'https://github.com/microsoft/vscode.git',
|
||
- displayName: 'vscode',
|
||
- });
|
||
- });
|
||
-});
|
||
diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts
|
||
deleted file mode 100644
|
||
index 4a18ade2..00000000
|
||
--- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts
|
||
+++ /dev/null
|
||
@@ -1,242 +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 assert from 'assert';
|
||
-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||
-import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||
-import { Schemas } from '../../../../base/common/network.js';
|
||
-import { URI } from '../../../../base/common/uri.js';
|
||
-import { VSBuffer } from '../../../../base/common/buffer.js';
|
||
-import { FileService } from '../../../files/common/fileService.js';
|
||
-import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
|
||
-import { NullLogService } from '../../../log/common/log.js';
|
||
-import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';
|
||
-import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual } from '../../node/copilot/copilotPluginConverters.js';
|
||
-import type { IMcpServerDefinition, INamedPluginResource, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
|
||
-
|
||
-suite('copilotPluginConverters', () => {
|
||
-
|
||
- const disposables = new DisposableStore();
|
||
- let fileService: FileService;
|
||
-
|
||
- setup(() => {
|
||
- fileService = disposables.add(new FileService(new NullLogService()));
|
||
- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
|
||
- });
|
||
-
|
||
- teardown(() => disposables.clear());
|
||
- ensureNoDisposablesAreLeakedInTestSuite();
|
||
-
|
||
- // ---- toSdkMcpServers ------------------------------------------------
|
||
-
|
||
- suite('toSdkMcpServers', () => {
|
||
-
|
||
- test('converts local server definitions', () => {
|
||
- const defs: IMcpServerDefinition[] = [{
|
||
- name: 'test-server',
|
||
- uri: URI.file('/plugin'),
|
||
- configuration: {
|
||
- type: McpServerType.LOCAL,
|
||
- command: 'node',
|
||
- args: ['server.js', '--port', '3000'],
|
||
- env: { NODE_ENV: 'production', PORT: 3000 as unknown as string },
|
||
- cwd: '/workspace',
|
||
- },
|
||
- }];
|
||
-
|
||
- const result = toSdkMcpServers(defs);
|
||
- assert.deepStrictEqual(result, {
|
||
- 'test-server': {
|
||
- type: 'local',
|
||
- command: 'node',
|
||
- args: ['server.js', '--port', '3000'],
|
||
- tools: ['*'],
|
||
- env: { NODE_ENV: 'production', PORT: '3000' },
|
||
- cwd: '/workspace',
|
||
- },
|
||
- });
|
||
- });
|
||
-
|
||
- test('converts remote/http server definitions', () => {
|
||
- const defs: IMcpServerDefinition[] = [{
|
||
- name: 'remote-server',
|
||
- uri: URI.file('/plugin'),
|
||
- configuration: {
|
||
- type: McpServerType.REMOTE,
|
||
- url: 'https://example.com/mcp',
|
||
- headers: { 'Authorization': 'Bearer token' },
|
||
- },
|
||
- }];
|
||
-
|
||
- const result = toSdkMcpServers(defs);
|
||
- assert.deepStrictEqual(result, {
|
||
- 'remote-server': {
|
||
- type: 'http',
|
||
- url: 'https://example.com/mcp',
|
||
- tools: ['*'],
|
||
- headers: { 'Authorization': 'Bearer token' },
|
||
- },
|
||
- });
|
||
- });
|
||
-
|
||
- test('handles empty definitions', () => {
|
||
- const result = toSdkMcpServers([]);
|
||
- assert.deepStrictEqual(result, {});
|
||
- });
|
||
-
|
||
- test('omits optional fields when undefined', () => {
|
||
- const defs: IMcpServerDefinition[] = [{
|
||
- name: 'minimal',
|
||
- uri: URI.file('/plugin'),
|
||
- configuration: {
|
||
- type: McpServerType.LOCAL,
|
||
- command: 'echo',
|
||
- },
|
||
- }];
|
||
-
|
||
- const result = toSdkMcpServers(defs);
|
||
- assert.strictEqual(result['minimal'].type, 'local');
|
||
- assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []);
|
||
- assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false);
|
||
- assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false);
|
||
- });
|
||
-
|
||
- test('filters null values from env', () => {
|
||
- const defs: IMcpServerDefinition[] = [{
|
||
- name: 'with-null-env',
|
||
- uri: URI.file('/plugin'),
|
||
- configuration: {
|
||
- type: McpServerType.LOCAL,
|
||
- command: 'test',
|
||
- env: { KEEP: 'value', DROP: null as unknown as string },
|
||
- },
|
||
- }];
|
||
-
|
||
- const result = toSdkMcpServers(defs);
|
||
- const env = (result['with-null-env'] as { env?: Record<string, string> }).env;
|
||
- assert.deepStrictEqual(env, { KEEP: 'value' });
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- toSdkCustomAgents ----------------------------------------------
|
||
-
|
||
- suite('toSdkCustomAgents', () => {
|
||
-
|
||
- test('reads agent files and creates configs', async () => {
|
||
- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' });
|
||
- await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant'));
|
||
-
|
||
- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }];
|
||
- const result = await toSdkCustomAgents(agents, fileService);
|
||
-
|
||
- assert.deepStrictEqual(result, [{
|
||
- name: 'helper',
|
||
- prompt: 'You are a helpful assistant',
|
||
- }]);
|
||
- });
|
||
-
|
||
- test('skips agents whose files cannot be read', async () => {
|
||
- const agents: INamedPluginResource[] = [
|
||
- { uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' },
|
||
- ];
|
||
- const result = await toSdkCustomAgents(agents, fileService);
|
||
- assert.deepStrictEqual(result, []);
|
||
- });
|
||
-
|
||
- test('processes multiple agents, skipping failures', async () => {
|
||
- const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' });
|
||
- await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent'));
|
||
-
|
||
- const agents: INamedPluginResource[] = [
|
||
- { uri: goodUri, name: 'good' },
|
||
- { uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' },
|
||
- ];
|
||
- const result = await toSdkCustomAgents(agents, fileService);
|
||
- assert.strictEqual(result.length, 1);
|
||
- assert.strictEqual(result[0].name, 'good');
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- toSdkSkillDirectories ------------------------------------------
|
||
-
|
||
- suite('toSdkSkillDirectories', () => {
|
||
-
|
||
- test('extracts parent directories of skill URIs', () => {
|
||
- const skills: INamedPluginResource[] = [
|
||
- { uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' },
|
||
- { uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' },
|
||
- ];
|
||
- const result = toSdkSkillDirectories(skills);
|
||
- assert.strictEqual(result.length, 2);
|
||
- });
|
||
-
|
||
- test('deduplicates directories', () => {
|
||
- const skills: INamedPluginResource[] = [
|
||
- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' },
|
||
- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' },
|
||
- ];
|
||
- const result = toSdkSkillDirectories(skills);
|
||
- assert.strictEqual(result.length, 1);
|
||
- });
|
||
-
|
||
- test('handles empty input', () => {
|
||
- const result = toSdkSkillDirectories([]);
|
||
- assert.deepStrictEqual(result, []);
|
||
- });
|
||
- });
|
||
-
|
||
- // ---- parsedPluginsEqual ---------------------------------------------
|
||
-
|
||
- suite('parsedPluginsEqual', () => {
|
||
-
|
||
- function makePlugin(overrides?: Partial<IParsedPlugin>): IParsedPlugin {
|
||
- return {
|
||
- hooks: [],
|
||
- mcpServers: [],
|
||
- skills: [],
|
||
- agents: [],
|
||
- ...overrides,
|
||
- };
|
||
- }
|
||
-
|
||
- test('returns true for identical empty plugins', () => {
|
||
- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true);
|
||
- });
|
||
-
|
||
- test('returns true for same content', () => {
|
||
- const a = makePlugin({
|
||
- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],
|
||
- mcpServers: [{
|
||
- name: 'server',
|
||
- uri: URI.file('/mcp'),
|
||
- configuration: { type: McpServerType.LOCAL, command: 'node' },
|
||
- }],
|
||
- });
|
||
- const b = makePlugin({
|
||
- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],
|
||
- mcpServers: [{
|
||
- name: 'server',
|
||
- uri: URI.file('/mcp'),
|
||
- configuration: { type: McpServerType.LOCAL, command: 'node' },
|
||
- }],
|
||
- });
|
||
- assert.strictEqual(parsedPluginsEqual([a], [b]), true);
|
||
- });
|
||
-
|
||
- test('returns false for different content', () => {
|
||
- const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] });
|
||
- const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] });
|
||
- assert.strictEqual(parsedPluginsEqual([a], [b]), false);
|
||
- });
|
||
-
|
||
- test('returns false for different lengths', () => {
|
||
- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false);
|
||
- });
|
||
-
|
||
- test('returns true for empty arrays', () => {
|
||
- assert.strictEqual(parsedPluginsEqual([], []), true);
|
||
- });
|
||
- });
|
||
-});
|
||
diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
|
||
index 9872642c..f679eae4 100644
|
||
--- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
|
||
+++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts
|
||
@@ -49,3 +49,3 @@ suite('mapSessionEvents', () => {
|
||
assert.strictEqual(result[1].type, 'message');
|
||
- assert.strictEqual((result[1] as { role: string }).role, 'assistant');
|
||
+ assert.strictEqual((result[1] as { role: string; }).role, 'assistant');
|
||
});
|
||
@@ -69,3 +69,3 @@ suite('mapSessionEvents', () => {
|
||
|
||
- const complete = result[1] as { result: { content?: readonly { type: string; text?: string }[] } };
|
||
+ const complete = result[1] as { result: { content?: readonly { type: string; text?: string; }[]; }; };
|
||
assert.ok(complete.result.content);
|
||
@@ -126,3 +126,3 @@ suite('mapSessionEvents', () => {
|
||
|
||
- const content = (complete as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
|
||
+ const content = (complete as { result: { content?: readonly Record<string, unknown>[]; }; }).result.content;
|
||
assert.ok(content);
|
||
@@ -134,3 +134,3 @@ suite('mapSessionEvents', () => {
|
||
// File edit URIs should be parseable
|
||
- const fileEdit = content[1] as { before: { uri: any; content: { uri: any } }; after: { uri: any; content: { uri: any } }; diff?: { added?: number; removed?: number } };
|
||
+ const fileEdit = content[1] as { before: { uri: any; content: { uri: any; }; }; after: { uri: any; content: { uri: any; }; }; diff?: { added?: number; removed?: number; }; };
|
||
const beforeFields = parseSessionDbUri(fileEdit.before.content.uri);
|
||
@@ -179,3 +179,3 @@ suite('mapSessionEvents', () => {
|
||
const result = await mapSessionEvents(session, db, events);
|
||
- const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
|
||
+ const content = (result[1] as { result: { content?: readonly Record<string, unknown>[]; }; }).result.content;
|
||
assert.ok(content);
|
||
@@ -199,3 +199,3 @@ suite('mapSessionEvents', () => {
|
||
const result = await mapSessionEvents(session, undefined, events);
|
||
- const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
|
||
+ const content = (result[1] as { result: { content?: readonly Record<string, unknown>[]; }; }).result.content;
|
||
assert.ok(content);
|
||
@@ -221,3 +221,3 @@ suite('mapSessionEvents', () => {
|
||
const result = await mapSessionEvents(session, db, events);
|
||
- const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
|
||
+ const content = (result[1] as { result: { content?: readonly Record<string, unknown>[]; }; }).result.content;
|
||
assert.ok(content);
|
||
@@ -248,3 +248,3 @@ suite('mapSessionEvents', () => {
|
||
assert.strictEqual(result[0].type, 'subagent_started');
|
||
- const event = result[0] as { type: string; toolCallId: string; agentName: string; agentDisplayName: string };
|
||
+ const event = result[0] as { type: string; toolCallId: string; agentName: string; agentDisplayName: string; };
|
||
assert.strictEqual(event.toolCallId, 'tc-1');
|