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 { 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()); - readonly onDidSessionProgress = this._onDidSessionProgress.event; - - private _client: CopilotClient | undefined; - private _clientStarting: Promise | undefined; - private _githubToken: string | undefined; - private readonly _sessions = this._register(new DisposableMap()); - private readonly _sessionSequencer = new SequencerByKey(); - 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 { - 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 { - 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 = 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 { - this._logService.info('[Copilot] Listing sessions...'); - const client = await this._ensureClient(); - const sessions = await client.listSessions(); - const projectLimiter = new Limiter(4); - const projectByContext = new Map>(); - 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 { - 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 { - 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 { - 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 { - 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 { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - this._sessions.deleteAndDispose(sessionId); - }); - } - - async abortSession(session: URI): Promise { - 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 { - 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 { - 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 { - 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 { - 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): 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[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 { - 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[] = []; - 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, projectByContext: Map>): Promise { - 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(); - private _lastSynced: Promise<{ synced: ISyncedCustomization[]; parsed: IParsedPlugin[] }> = Promise.resolve({ synced: [], parsed: [] }); - - /** Parsed plugin contents from the most recently applied sync. */ - private _appliedParsed = new WeakMap(); - - 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 { - 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 { - 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; - 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 { - return new Promise((resolve, reject) => { - db.exec(sql, err => err ? reject(err) : resolve()); - }); -} - -function dbRun(db: Database, sql: string, params: unknown[]): Promise { - 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[]> { - return new Promise((resolve, reject) => { - db.all(sql, params, (err: Error | null, rows: Record[]) => { - if (err) { - return reject(err); - } - resolve(rows); - }); - }); -} - -function dbClose(db: Database): Promise { - return new Promise((resolve, reject) => { - db.close(err => err ? reject(err) : resolve()); - }); -} - -function dbOpen(dbPath: string): Promise { - 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(); - 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(); - 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 { - 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 ":turn:"; 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 { - 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 ":turn:" - 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 { - 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 | 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 { - 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; - readonly onUserInputRequest: (request: IUserInputRequest, invocation: { sessionId: string }) => Promise; - readonly hooks: { - readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; - readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; - }; -}) => Promise; - -/** 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 : 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 | undefined }>(); - /** Pending permission requests awaiting a renderer-side decision. */ - private readonly _pendingPermissions = new Map>(); - /** Pending user input requests awaiting a renderer-side answer. */ - private readonly _pendingUserInputs = new Map }>; questionId: string }>(); - /** File edit tracker for this session. */ - private readonly _editTracker: FileEditTracker; - /** Session database reference. */ - private readonly _databaseRef: IReference; - /** 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, - 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 { - 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 { - 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 { - 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 { - 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 { - await this._wrapper.session.destroy(); - } - - async setModel(model: string): Promise { - 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 { - 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(); - 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 { - 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 }>(); - 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): 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 | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } 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 { - 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 { - 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 { - 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; - -// --------------------------------------------------------------------------- -// MCP servers -// --------------------------------------------------------------------------- - -/** - * Converts parsed MCP server definitions into the SDK's `mcpServers` config. - */ -export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record { - const result: Record = {}; - 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`). - */ -function toStringEnv(env: Record): Record { - const result: Record = {}; - 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 { - 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(); - 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 { - const command = resolveEffectiveCommand(hook, OS); - if (!command) { - return Promise.resolve(''); - } - - const timeout = (hook.timeout ?? 30) * 1000; - const cwd = hook.cwd?.fsPath; - - return new Promise((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 = { - '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; - readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; - }, -): SessionHooks { - // Group all commands by SDK handler key - const commandsByKey = new Map(); - 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`. 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> | undefined; - get onMessageDelta(): Event> { - return this._onMessageDelta ??= this._sdkEvent('assistant.message_delta'); - } - - private _onMessage: Event> | undefined; - get onMessage(): Event> { - return this._onMessage ??= this._sdkEvent('assistant.message'); - } - - private _onToolStart: Event> | undefined; - get onToolStart(): Event> { - return this._onToolStart ??= this._sdkEvent('tool.execution_start'); - } - - private _onToolComplete: Event> | undefined; - get onToolComplete(): Event> { - return this._onToolComplete ??= this._sdkEvent('tool.execution_complete'); - } - - private _onIdle: Event> | undefined; - get onIdle(): Event> { - return this._onIdle ??= this._sdkEvent('session.idle'); - } - - private _onSessionStart: Event> | undefined; - get onSessionStart(): Event> { - return this._onSessionStart ??= this._sdkEvent('session.start'); - } - - private _onSessionResume: Event> | undefined; - get onSessionResume(): Event> { - return this._onSessionResume ??= this._sdkEvent('session.resume'); - } - - private _onSessionError: Event> | undefined; - get onSessionError(): Event> { - return this._onSessionError ??= this._sdkEvent('session.error'); - } - - private _onSessionInfo: Event> | undefined; - get onSessionInfo(): Event> { - return this._onSessionInfo ??= this._sdkEvent('session.info'); - } - - private _onSessionModelChange: Event> | undefined; - get onSessionModelChange(): Event> { - return this._onSessionModelChange ??= this._sdkEvent('session.model_change'); - } - - private _onSessionHandoff: Event> | undefined; - get onSessionHandoff(): Event> { - return this._onSessionHandoff ??= this._sdkEvent('session.handoff'); - } - - private _onSessionTruncation: Event> | undefined; - get onSessionTruncation(): Event> { - return this._onSessionTruncation ??= this._sdkEvent('session.truncation'); - } - - private _onSessionSnapshotRewind: Event> | undefined; - get onSessionSnapshotRewind(): Event> { - return this._onSessionSnapshotRewind ??= this._sdkEvent('session.snapshot_rewind'); - } - - private _onSessionShutdown: Event> | undefined; - get onSessionShutdown(): Event> { - return this._onSessionShutdown ??= this._sdkEvent('session.shutdown'); - } - - private _onSessionUsageInfo: Event> | undefined; - get onSessionUsageInfo(): Event> { - return this._onSessionUsageInfo ??= this._sdkEvent('session.usage_info'); - } - - private _onSessionCompactionStart: Event> | undefined; - get onSessionCompactionStart(): Event> { - return this._onSessionCompactionStart ??= this._sdkEvent('session.compaction_start'); - } - - private _onSessionCompactionComplete: Event> | undefined; - get onSessionCompactionComplete(): Event> { - return this._onSessionCompactionComplete ??= this._sdkEvent('session.compaction_complete'); - } - - private _onUserMessage: Event> | undefined; - get onUserMessage(): Event> { - return this._onUserMessage ??= this._sdkEvent('user.message'); - } - - private _onPendingMessagesModified: Event> | undefined; - get onPendingMessagesModified(): Event> { - return this._onPendingMessagesModified ??= this._sdkEvent('pending_messages.modified'); - } - - private _onTurnStart: Event> | undefined; - get onTurnStart(): Event> { - return this._onTurnStart ??= this._sdkEvent('assistant.turn_start'); - } - - private _onIntent: Event> | undefined; - get onIntent(): Event> { - return this._onIntent ??= this._sdkEvent('assistant.intent'); - } - - private _onReasoning: Event> | undefined; - get onReasoning(): Event> { - return this._onReasoning ??= this._sdkEvent('assistant.reasoning'); - } - - private _onReasoningDelta: Event> | undefined; - get onReasoningDelta(): Event> { - return this._onReasoningDelta ??= this._sdkEvent('assistant.reasoning_delta'); - } - - private _onTurnEnd: Event> | undefined; - get onTurnEnd(): Event> { - return this._onTurnEnd ??= this._sdkEvent('assistant.turn_end'); - } - - private _onUsage: Event> | undefined; - get onUsage(): Event> { - return this._onUsage ??= this._sdkEvent('assistant.usage'); - } - - private _onAbort: Event> | undefined; - get onAbort(): Event> { - return this._onAbort ??= this._sdkEvent('abort'); - } - - private _onToolUserRequested: Event> | undefined; - get onToolUserRequested(): Event> { - return this._onToolUserRequested ??= this._sdkEvent('tool.user_requested'); - } - - private _onToolPartialResult: Event> | undefined; - get onToolPartialResult(): Event> { - return this._onToolPartialResult ??= this._sdkEvent('tool.execution_partial_result'); - } - - private _onToolProgress: Event> | undefined; - get onToolProgress(): Event> { - return this._onToolProgress ??= this._sdkEvent('tool.execution_progress'); - } - - private _onSkillInvoked: Event> | undefined; - get onSkillInvoked(): Event> { - return this._onSkillInvoked ??= this._sdkEvent('skill.invoked'); - } - - private _onSubagentStarted: Event> | undefined; - get onSubagentStarted(): Event> { - return this._onSubagentStarted ??= this._sdkEvent('subagent.started'); - } - - private _onSubagentCompleted: Event> | undefined; - get onSubagentCompleted(): Event> { - return this._onSubagentCompleted ??= this._sdkEvent('subagent.completed'); - } - - private _onSubagentFailed: Event> | undefined; - get onSubagentFailed(): Event> { - return this._onSubagentFailed ??= this._sdkEvent('subagent.failed'); - } - - private _onSubagentSelected: Event> | undefined; - get onSubagentSelected(): Event> { - return this._onSubagentSelected ??= this._sdkEvent('subagent.selected'); - } - - private _onHookStart: Event> | undefined; - get onHookStart(): Event> { - return this._onHookStart ??= this._sdkEvent('hook.start'); - } - - private _onHookEnd: Event> | undefined; - get onHookEnd(): Event> { - return this._onHookEnd ??= this._sdkEvent('hook.end'); - } - - private _onSystemMessage: Event> | undefined; - get onSystemMessage(): Event> { - return this._onSystemMessage ??= this._sdkEvent('system.message'); - } - - private _sdkEvent(eventType: K): Event> { - const emitter = this._register(new Emitter>()); - const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => 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: `<<_EXIT_>>`. - */ -const SENTINEL_PREFIX = '<<(); - private readonly _toolCallShells = new Map(); - - 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 { - 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 { - 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(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[] { - const shellType: ShellType = platform.isWindows ? 'powershell' : 'bash'; - - const primaryTool: Tool = { - 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 = { - 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 = { - 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 = { - 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> = { - 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 | undefined }>(); + const toolInfoByCallId = new Map | 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 })._sessions.set(sessionId, sessionUri); + (agent as unknown as { _sessions: Map; })._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 { - 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).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 { - 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 { - 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[]> { - return new Promise((resolve, reject) => { - db.all(sql, params, (err: Error | null, rows: Record[]) => { - if (err) { - return reject(err); - } - resolve(rows); - }); - }); - } - - function close(db: import('@vscode/sqlite3').Database): Promise { - return new Promise((resolve, reject) => { - db.close(err => err ? reject(err) : resolve()); - }); - } - - async function setupSchema(db: import('@vscode/sqlite3').Database): Promise { - 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 { - 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 { - 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 { - 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[]> { - return new Promise((resolve, reject) => { - db.all(sql, params, (err: Error | null, rows: Record[]) => { - if (err) { - return reject(err); - } - resolve(rows); - }); - }); - } - - function close(db: import('@vscode/sqlite3').Database): Promise { - return new Promise((resolve, reject) => { - db.close(err => err ? reject(err) : resolve()); - }); - } - - async function setupSchema(db: import('@vscode/sqlite3').Database): Promise { - 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 void>>(); - - on(eventType: K, handler: TypedSessionEventHandler): () => 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(type: K, data: SessionEventPayload['data']): void { - const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload; - 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()); - 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 { - 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 { - 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 }).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 { - 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[] } }).result.content; + const content = (complete as { result: { content?: readonly Record[]; }; }).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[] } }).result.content; + const content = (result[1] as { result: { content?: readonly Record[]; }; }).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[] } }).result.content; + const content = (result[1] as { result: { content?: readonly Record[]; }; }).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[] } }).result.content; + const content = (result[1] as { result: { content?: readonly Record[]; }; }).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');