diff --git a/.vscode/launch.json b/.vscode/launch.json index 47d901042e3..0f249a8548d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,6 +52,15 @@ "${workspaceFolder}/out/**/*.js" ] }, + { + "type": "node", + "request": "attach", + "name": "Attach to Agent Host Process", + "port": 5878, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ] + }, { "type": "node", "request": "attach", @@ -701,6 +710,7 @@ "Attach to Main Process", "Attach to Extension Host", "Attach to Shared Process", + "Attach to Agent Host Process" ], "preLaunchTask": "Ensure Prelaunch Dependencies", "presentation": { diff --git a/build/.moduleignore b/build/.moduleignore index ed36151130c..faa4973e2dc 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -188,3 +188,28 @@ zone.js/dist/** @xterm/xterm-addon-*/fixtures/** @xterm/xterm-addon-*/out/** @xterm/xterm-addon-*/out-test/** + +# @github/copilot - strip unneeded binaries and files +@github/copilot/sdk/index.js +@github/copilot/prebuilds/** +@github/copilot/clipboard/** +@github/copilot/ripgrep/** +@github/copilot/**/keytar.node + +# @github/copilot platform binaries - not needed +@github/copilot-darwin-arm64/** +@github/copilot-darwin-x64/** +@github/copilot-linux-arm64/** +@github/copilot-linux-x64/** +@github/copilot-win32-arm64/** +@github/copilot-win32-x64/** + +# @github/copilot-sdk - strip the nested @github/copilot CLI runtime +# The SDK only needs its own dist/ files; the CLI is resolved via cliPath at runtime +@github/copilot-sdk/node_modules/@github/copilot/** +@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-darwin-x64/** +@github/copilot-sdk/node_modules/@github/copilot-linux-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-linux-x64/** +@github/copilot-sdk/node_modules/@github/copilot-win32-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-win32-x64/** diff --git a/build/buildfile.ts b/build/buildfile.ts index 47b0476892c..80c97ff1daa 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -24,6 +24,7 @@ export const workbenchDesktop = [ createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), + createModuleDescription('vs/platform/agentHost/node/agentHostMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/workbench/workbench.desktop.main'), createModuleDescription('vs/sessions/sessions.desktop.main') @@ -53,7 +54,8 @@ export const codeServer = [ // 'vs/server/node/server.cli' is not included here because it gets inlined via ./src/server-cli.js createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), - createModuleDescription('vs/platform/terminal/node/ptyHostMain') + createModuleDescription('vs/platform/terminal/node/ptyHostMain'), + createModuleDescription('vs/platform/agentHost/node/agentHostMain') ]; export const entrypoint = createModuleDescription; diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 9e90e31491f..c3de9766a05 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -10,6 +10,30 @@ import { makeUniversalApp } from 'vscode-universal-bundler'; const root = path.dirname(path.dirname(import.meta.dirname)); +const nodeModulesBases = [ + path.join('Contents', 'Resources', 'app', 'node_modules'), + path.join('Contents', 'Resources', 'app', 'node_modules.asar.unpacked'), +]; + +/** + * Ensures a directory exists in both the x64 and arm64 app bundles by copying + * it from whichever build has it to the one that does not. This is needed for + * platform-specific native module directories that npm only installs for the + * host architecture. + */ +function crossCopyPlatformDir(x64AppPath: string, arm64AppPath: string, relativePath: string): void { + const inX64 = path.join(x64AppPath, relativePath); + const inArm64 = path.join(arm64AppPath, relativePath); + + if (fs.existsSync(inX64) && !fs.existsSync(inArm64)) { + fs.mkdirSync(inArm64, { recursive: true }); + fs.cpSync(inX64, inArm64, { recursive: true }); + } else if (fs.existsSync(inArm64) && !fs.existsSync(inX64)) { + fs.mkdirSync(inX64, { recursive: true }); + fs.cpSync(inArm64, inX64, { recursive: true }); + } +} + async function main(buildDir?: string) { const arch = process.env['VSCODE_ARCH']; @@ -25,10 +49,39 @@ async function main(buildDir?: string) { const outAppPath = path.join(buildDir, `VSCode-darwin-${arch}`, appName); const productJsonPath = path.resolve(outAppPath, 'Contents', 'Resources', 'app', 'product.json'); + // Copilot SDK ships platform-specific native binaries that npm only installs + // for the host architecture. The universal app merger requires both builds to + // have identical file trees, so we cross-copy each missing directory from the + // other build. The binaries are then excluded from comparison (filesToSkip) + // and the x64 binary is tagged as arch-specific (x64ArchFiles) so the merger + // keeps both. + for (const plat of ['darwin-x64', 'darwin-arm64']) { + for (const base of nodeModulesBases) { + // @github/copilot-{platform} packages (e.g. copilot-darwin-x64) + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@github', `copilot-${plat}`)); + // @github/copilot/prebuilds/{platform} (pty.node, spawn-helper) + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@github', 'copilot', 'prebuilds', plat)); + // @github/copilot/ripgrep/bin/{platform} (rg binary) + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@github', 'copilot', 'ripgrep', 'bin', plat)); + } + } + const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', - '**/policies/{*.mobileconfig,**/*.plist}' + '**/policies/{*.mobileconfig,**/*.plist}', + '**/node_modules/@github/copilot-darwin-x64/**', + '**/node_modules/@github/copilot-darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-arm64/**', + '**/node_modules/@github/copilot/prebuilds/darwin-x64/**', + '**/node_modules/@github/copilot/prebuilds/darwin-arm64/**', + '**/node_modules/@github/copilot/ripgrep/bin/darwin-x64/**', + '**/node_modules/@github/copilot/ripgrep/bin/darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot/ripgrep/bin/darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot/ripgrep/bin/darwin-arm64/**', ]; await makeUniversalApp({ @@ -38,7 +91,7 @@ async function main(buildDir?: string) { outAppPath, force: true, mergeASARs: true, - x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node}', + x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node,**/node_modules/@github/copilot-darwin-*/copilot,**/node_modules/@github/copilot/prebuilds/darwin-*/*,**/node_modules/@github/copilot/ripgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot-darwin-*/copilot,**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/ripgrep/bin/darwin-*/*}', filesToSkipComparison: (file: string) => { for (const expected of filesToSkip) { if (minimatch(file, expected)) { diff --git a/build/darwin/verify-macho.ts b/build/darwin/verify-macho.ts index 7770b9c36cd..8443ca51641 100644 --- a/build/darwin/verify-macho.ts +++ b/build/darwin/verify-macho.ts @@ -26,6 +26,16 @@ const FILES_TO_SKIP = [ // MSAL runtime files are only present in ARM64 builds '**/extensions/microsoft-authentication/dist/libmsalruntime.dylib', '**/extensions/microsoft-authentication/dist/msal-node-runtime.node', + // Copilot SDK: universal app has both x64 and arm64 platform packages + '**/node_modules/@github/copilot-darwin-x64/**', + '**/node_modules/@github/copilot-darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-arm64/**', + // Copilot prebuilds and ripgrep: single-arch binaries in per-platform directories + '**/node_modules/@github/copilot/prebuilds/darwin-*/**', + '**/node_modules/@github/copilot/ripgrep/bin/darwin-*/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/**', + '**/node_modules.asar.unpacked/@github/copilot/ripgrep/bin/darwin-*/**', ]; function isFileSkipped(file: string): boolean { diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 9d63587993b..7a6f6e41ba7 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -318,6 +318,38 @@ function computeChecksum(filename: string): string { return hash; } +const copilotPlatforms = [ + 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', + 'win32-arm64', 'win32-x64', +]; + +/** + * Returns a glob filter that strips @github/copilot platform packages and + * prebuilt native modules for architectures other than the build target. + * On stable builds, all copilot SDK dependencies are stripped entirely. + */ +function getCopilotExcludeFilter(platform: string, arch: string, quality: string | undefined): string[] { + const targetPlatformArch = `${platform}-${arch}`; + const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); + + // Strip wrong-architecture @github/copilot-{platform} packages. + // All copilot prebuilds are stripped by .moduleignore; VS Code's own + // node-pty is copied into the prebuilds location by a post-packaging task. + const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); + + // Strip agent host SDK dependencies entirely from stable builds + if (quality === 'stable') { + excludes.push( + '!**/node_modules/@github/copilot/**', + '!**/node_modules/@github/copilot-sdk/**', + '!**/node_modules/@github/copilot-*/**', + ); + } + + return ['**', ...excludes]; +} + function packageTask(platform: string, arch: string, sourceFolderName: string, destinationFolderName: string, _opts?: { stats?: boolean }) { const destination = path.join(path.dirname(root), destinationFolderName); platform = platform || process.platform; @@ -437,12 +469,14 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(filter(getCopilotExcludeFilter(platform, arch, quality))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) .pipe(createAsar(path.join(process.cwd(), 'node_modules'), [ '**/*.node', '**/@vscode/ripgrep/bin/*', + '**/@github/copilot-*/**', '**/node-pty/build/Release/*', '**/node-pty/build/Release/conpty/*', '**/node-pty/lib/worker/conoutSocketWorker.js', @@ -679,6 +713,67 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } +/** + * Copies VS Code's own node-pty and ripgrep binaries into the copilot SDK's + * expected locations so the copilot CLI subprocess can find them at runtime. + * The copilot-bundled prebuilds and ripgrep are stripped by .moduleignore; + * this replaces them with the same binaries VS Code already ships, avoiding + * new system dependency requirements. + * + * node-pty: `prebuilds/{platform}-{arch}/` (pty.node + spawn-helper) + * ripgrep: `ripgrep/bin/{platform}-{arch}/` (rg binary) + */ +function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) { + const outputDir = path.join(path.dirname(root), destinationFolderName); + + return async () => { + const quality = (product as { quality?: string }).quality; + + // On stable builds the copilot SDK is stripped entirely -- nothing to copy into. + if (quality === 'stable') { + console.log(`[copyCopilotNativeDeps] Skipping -- stable build`); + return; + } + + // 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'); + + // Source and destination are both in node_modules/, which exists as a real + // directory on disk on all platforms after packaging. + const nodeModulesDir = path.join(appBase, 'node_modules'); + const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); + const platformArch = `${platform === 'win32' ? 'win32' : platform}-${arch}`; + + const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); + const rgBinary = platform === 'win32' ? 'rg.exe' : 'rg'; + const ripgrepSource = path.join(nodeModulesDir, '@vscode', 'ripgrep', 'bin', rgBinary); + + // Fail-fast: source binaries must exist on non-stable builds. + if (!fs.existsSync(nodePtySource)) { + throw new Error(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}`); + } + if (!fs.existsSync(ripgrepSource)) { + throw new Error(`[copyCopilotNativeDeps] ripgrep source not found at ${ripgrepSource}`); + } + + // Copy node-pty (pty.node + spawn-helper) into copilot prebuilds + const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); + fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); + fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); + console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + + // Copy ripgrep (rg binary) into copilot ripgrep + const copilotRipgrepDir = path.join(copilotBase, 'ripgrep', 'bin', platformArch); + fs.mkdirSync(copilotRipgrepDir, { recursive: true }); + fs.copyFileSync(ripgrepSource, path.join(copilotRipgrepDir, rgBinary)); + console.log(`[copyCopilotNativeDeps] Copied ripgrep from ${ripgrepSource} to ${copilotRipgrepDir}`); + }; +} + const buildRoot = path.dirname(root); const BUILD_TARGETS = [ @@ -703,7 +798,8 @@ BUILD_TARGETS.forEach(buildTarget => { const packageTasks: task.Task[] = [ compileNativeExtensionsBuildTask, util.rimraf(path.join(buildRoot, destinationFolderName)), - packageTask(platform, arch, sourceFolderName, destinationFolderName, opts) + packageTask(platform, arch, sourceFolderName, destinationFolderName, opts), + copyCopilotNativeDepsTask(platform, arch, destinationFolderName) ]; if (platform === 'win32') { diff --git a/build/next/index.ts b/build/next/index.ts index a5bb0796da0..786fde3bb6f 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -101,6 +101,7 @@ const desktopEntryPoints = [ 'vs/workbench/contrib/debug/node/telemetryApp', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', 'vs/workbench/api/node/extensionHostProcess', ]; diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index ae2651cd188..18c0fbabb89 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -288,6 +288,19 @@ async function main() { fs.writeFileSync(stateFile, JSON.stringify(_state)); fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); + + // 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 + const sessionFile = path.join(root, '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('.', 'Patched @github/copilot-sdk session.js (vscode-jsonrpc ESM import fix)'); + } + } } main().catch(err => { diff --git a/eslint.config.js b/eslint.config.js index 5dadd67e26e..b81042f7755 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1456,6 +1456,7 @@ export default tseslint.config( // - electron-main 'when': 'hasNode', 'allow': [ + '@github/copilot-sdk', '@anthropic-ai/sandbox-runtime', '@parcel/watcher', '@vscode/sqlite3', @@ -1499,6 +1500,7 @@ export default tseslint.config( 'vscode-regexpp', 'vscode-textmate', 'worker_threads', + 'ws', '@xterm/addon-clipboard', '@xterm/addon-image', '@xterm/addon-ligatures', diff --git a/package-lock.json b/package-lock.json index 9a6ada65f34..a5e0ceae70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.23", + "@github/copilot": "^1.0.4-0", + "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", @@ -57,6 +59,7 @@ "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", + "ws": "^8.19.0", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, @@ -80,6 +83,7 @@ "@types/wicg-file-system-access": "^2023.10.7", "@types/windows-foreground-love": "^0.3.0", "@types/winreg": "^1.2.30", + "@types/ws": "^8.18.1", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", @@ -1393,6 +1397,255 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@github/copilot": { + "version": "1.0.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4-0.tgz", + "integrity": "sha512-K2mf+4nTvjbyghSNI/ysRNu0y2SI7spJDO50sfGLaJAso9hqlYGSBqdLeHTc27bjDxnPyIguUrLa2tMkERUwWg==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.4-0", + "@github/copilot-darwin-x64": "1.0.4-0", + "@github/copilot-linux-arm64": "1.0.4-0", + "@github/copilot-linux-x64": "1.0.4-0", + "@github/copilot-win32-arm64": "1.0.4-0", + "@github/copilot-win32-x64": "1.0.4-0" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4-0.tgz", + "integrity": "sha512-bvWPvla+G3nzGVgEt5hfSMkc2ShLD5+pAGwIy6Qubvl0SxhsULR9zz8UvrX9adPamCWDUwcJhJRhDOzcvt0f4A==", + "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.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4-0.tgz", + "integrity": "sha512-AKy9Uq/3trHxrt55ZwzkojBJTcYl6ycyTWvIVNaRg5Ypbrf2ED4ZQDR8ElQi/mJk3kadzgXsZCztZtQan3IGqw==", + "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.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4-0.tgz", + "integrity": "sha512-BgKuyOpY0qbGzP7xmgCi6UnjUXG+/oTI5KU7izPHhP8ph7lF96ZQdrcZ/I6+Ag+Gxy/hZGKS4kKk4Xfph14xKA==", + "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.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4-0.tgz", + "integrity": "sha512-MYVxHvV4+m8WfYIyW94PUs7mAcmFkWHHlVYS+Zg+cTR6//aKZbLuGssPl+HpwqdfteiDxdjUqXbIl9zWwDrIKw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", + "integrity": "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.2", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.3.tgz", + "integrity": "sha512-5J68wbShQq8biIgHD3ixlEg9hdj4kE72L2U7VwNXnhQ6tJNJtnXPHIyNbcc4L5ncu3k7IRmHMquJ76OApwvHxA==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.3", + "@github/copilot-darwin-x64": "1.0.3", + "@github/copilot-linux-arm64": "1.0.3", + "@github/copilot-linux-x64": "1.0.3", + "@github/copilot-win32-arm64": "1.0.3", + "@github/copilot-win32-x64": "1.0.3" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-gWeMjR6yP+F1SIY4RNm54C35ryYEyOg8ejOyM3lO3I9Xbq9IzBFCdOxhXSSeNPz6x1VF3vOIh/sxLPIOL1Y/Gg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.3.tgz", + "integrity": "sha512-kPvMctqiPW6Jq8yxxgbGzYvgtOj9U7Hk8MJknt+9nhrf/duvUobWuYJ6/FivMowGisYYtDbGjknM351vOUC7qA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.3.tgz", + "integrity": "sha512-AVveXRt3QKXSCYIbHTQABLRw4MbmJeRxZgHrR2h3qHMmpUkXf5dM+9Ott12LPENILU962w3kB/j1Q+QqJUhAUw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.3.tgz", + "integrity": "sha512-adCgNMBeeMqs3C0jumjv/ewIvBo37b3QGFSm21pBpvZIA9Td9gZXVF4+1uBMeUrOLy/8okNGuO7ao9r8jhrR5g==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.3.tgz", + "integrity": "sha512-vmHkjwzr4VZFOTE17n5GxL2qP9GPr6Z39xzdtLfGnv1uJOIk1UPKdpzBUoFNVTumtz0I0ZnRPJI1jF+MgKiafQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.3.tgz", + "integrity": "sha512-hIbzYdpXuM6PoSTS4NX8UOlbOPwCJ7bSsAe8JvJdo7lRv6Fcj4Xj/ZQmC9gDsiTZxBL2aIxQtn0WVYLFWnvMjQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "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.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4-0.tgz", + "integrity": "sha512-S4l3rybmZ0T1WWvmm7ao5T8BfDwEd7dRVLLuagnYRkI+WMB9zQqIcv5pNw6653x73H8gmcOTyY8aKGdD1+3m0g==", + "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.4-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4-0.tgz", + "integrity": "sha512-t5JY0YoNpdiQUrS0IOQzf6OpjxO7GbGoJL7TVF/KwqOzN9FHluimJR6rn4txuPWZUoH60m5jO90k8i7/xGoSbw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", @@ -20569,6 +20822,15 @@ "node": ">= 0.10" } }, + "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": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", @@ -20969,7 +21231,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 66af8170f51..4758b5a295c 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.23", + "@github/copilot": "^1.0.4-0", + "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", @@ -127,6 +129,7 @@ "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", + "ws": "^8.19.0", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, @@ -150,6 +153,7 @@ "@types/wicg-file-system-access": "^2023.10.7", "@types/windows-foreground-love": "^0.3.0", "@types/winreg": "^1.2.30", + "@types/ws": "^8.18.1", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", diff --git a/scripts/code-agent-host.js b/scripts/code-agent-host.js new file mode 100644 index 00000000000..c618870e396 --- /dev/null +++ b/scripts/code-agent-host.js @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +const cp = require('child_process'); +const path = require('path'); +const minimist = require('minimist'); + +async function main() { + const args = minimist(process.argv.slice(2), { + boolean: ['help', 'no-launch'], + string: ['port'], + }); + + if (args.help) { + console.log( + 'Usage: ./scripts/code-agent-host.sh [options]\n' + + '\n' + + 'Options:\n' + + ' --port Port to listen on (default: 8081, or VSCODE_AGENT_HOST_PORT env)\n' + + ' --no-launch Start server without additional actions\n' + + ' --help Show this help message', + ); + return; + } + + const port = args.port || process.env['VSCODE_AGENT_HOST_PORT'] || '8081'; + const addr = await startServer(['--port', String(port)]); + console.log(`Agent Host server listening on ${addr}`); +} + +function startServer(programArgs) { + return new Promise((resolve, reject) => { + const env = { ...process.env }; + const entryPoint = path.join( + __dirname, + '..', + 'out', + 'vs', + 'platform', + 'agentHost', + 'node', + 'agentHostServerMain.js', + ); + + console.log( + `Starting agent host server: ${entryPoint} ${programArgs.join(' ')}`, + ); + const proc = cp.spawn(process.execPath, [entryPoint, ...programArgs], { + env, + stdio: [process.stdin, null, process.stderr], + }); + proc.stdout.on('data', (data) => { + const text = data.toString(); + process.stdout.write(text); + const m = text.match(/READY:(\d+)/); + if (m) { + resolve(`ws://127.0.0.1:${m[1]}`); + } + }); + + proc.on('exit', (code) => process.exit(code)); + + process.on('exit', () => proc.kill()); + process.on('SIGINT', () => { + proc.kill(); + process.exit(128 + 2); + }); + process.on('SIGTERM', () => { + proc.kill(); + process.exit(128 + 15); + }); + }); +} + +main(); diff --git a/scripts/code-agent-host.sh b/scripts/code-agent-host.sh new file mode 100755 index 00000000000..663f938ea1c --- /dev/null +++ b/scripts/code-agent-host.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +if [[ "$OSTYPE" == "darwin"* ]]; then + realpath() { [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"; } + ROOT=$(dirname $(dirname $(realpath "$0"))) +else + ROOT=$(dirname $(dirname $(readlink -f $0))) +fi + +function code() { + pushd $ROOT + + # Get electron, compile, built-in extensions + if [[ -z "${VSCODE_SKIP_PRELAUNCH}" ]]; then + node build/lib/preLaunch.ts + fi + + NODE=$(node build/lib/node.ts) + if [ ! -e $NODE ];then + # Load remote node + npm run gulp node + fi + + popd + + NODE_ENV=development \ + VSCODE_DEV=1 \ + exec "$NODE" "$ROOT/scripts/code-agent-host.js" "$@" +} + +code "$@" diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 91a5d361c92..da9cf6cf725 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -121,6 +121,9 @@ import { ipcUtilityProcessWorkerChannelName } from '../../platform/utilityProces import { ILocalPtyService, LocalReconnectConstants, TerminalIpcChannels, TerminalSettingId } from '../../platform/terminal/common/terminal.js'; import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/electronPtyHostStarter.js'; import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; +import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; +import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; +import { AgentHostEnabledSettingId } from '../../platform/agentHost/common/agentService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; import { Lazy } from '../../base/common/lazy.js'; import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindows.js'; @@ -1108,6 +1111,12 @@ export class CodeApplication extends Disposable { ); services.set(ILocalPtyService, ptyHostService); + // Agent Host + if (this.configurationService.getValue(AgentHostEnabledSettingId)) { + const agentHostStarter = new ElectronAgentHostStarter(this.environmentMainService, this.lifecycleMainService, this.logService); + this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); + } + // External terminal if (isWindows) { services.set(IExternalTerminalMainService, new SyncDescriptor(WindowsExternalTerminalService)); diff --git a/src/vs/platform/agentHost/architecture.md b/src/vs/platform/agentHost/architecture.md new file mode 100644 index 00000000000..e10015a7c5b --- /dev/null +++ b/src/vs/platform/agentHost/architecture.md @@ -0,0 +1,224 @@ +# Agent host process architecture + +> **Keep this document in sync with the code.** If you change the IPC contract, add new event types, modify the process lifecycle, or restructure files, update this document as part of the same change. + +For design decisions, see [design.md](design.md). For the client-server state protocol, see [protocol.md](protocol.md). For chat session wiring, see [sessions.md](sessions.md). + +## Overview + +The agent host runs as either an Electron **utility process** (desktop) or a **standalone WebSocket server** (headless / development). It hosts agent backends (CopilotAgent, MockAgent) and exposes session state to clients through two communication layers: + +1. **MessagePort / ProxyChannel** (desktop only) -- the renderer connects directly to the utility process via MessagePort. `AgentHostServiceClient` proxies `IAgentService` methods and forwards action/notification events. +2. **WebSocket / JSON-RPC protocol** (standalone server) -- multiple clients connect over WebSocket. Session state is synchronized via actions, subscriptions, and write-ahead reconciliation. See [protocol.md](protocol.md) for the full specification. + +In both modes, the server holds an authoritative state tree (`SessionStateManager`) mutated by actions flowing through pure reducers. Raw `IAgentProgressEvent`s from agent backends are mapped to state actions via `agentEventMapper.ts`. + +The entire feature is gated behind the `chat.agentHost.enabled` setting (default `false`). When disabled, the process is not spawned and no agents are registered. + +## Process Model + +``` ++--------------------------------------------------------------+ +| Renderer Window (Desktop) | +| | +| AgentHostContribution (discovers agents via listAgents()) | +| +-- per agent: SessionHandler, ListCtrl, LMProvider | +| +-- SessionClientState (write-ahead reconciliation) | +| +-- stateToProgressAdapter (state -> IChatProgress[]) | +| | +| AgentHostServiceClient (IAgentHostService singleton) | +| +-- ProxyChannel over delayed MessagePort | +| (revive() applied to event payloads) | ++---------------- MessagePort (direct) -------------------------+ +| Agent Host Utility Process (agentHostMain.ts) | +| -- or -- | +| Standalone Server (agentHostServerMain.ts) | +| | +| SessionStateManager (server-authoritative state tree) | +| +-- rootReducer / sessionReducer | +| +-- action envelope sequencing | +| | +| ProtocolServerHandler (JSON-RPC routing, broadcasts) | +| +-- per-client subscriptions, replay buffer | +| | +| Agent registry (Map) | +| +-- CopilotAgent (id='copilot') | +| | +-- CopilotClient (@github/copilot-sdk) | +| +-- ScriptedMockAgent (id='mock', opt-in via flag) | +| | +| agentEventMapper.ts | +| +-- IAgentProgressEvent -> ISessionAction mapping | ++---------------- UtilityProcess lifecycle ---------------------+ +| Main Process (Desktop only) | +| | +| ElectronAgentHostStarter (IAgentHostStarter) | +| +-- Spawns utility process, brokers MessagePort to windows | +| AgentHostProcessManager | +| +-- Lazy start on first window connection, crash recovery | ++---------------------------------------------------------------+ +``` + +## File Layout + +``` +src/vs/platform/agentHost/ ++-- common/ +| +-- agent.ts # IAgentHostStarter, IAgentHostConnection (starter contract) +| +-- agentService.ts # IAgent, IAgentService, IAgentHostService interfaces, +| # IPC data types, IAgentProgressEvent union, +| # AgentSession namespace (URI helpers), +| # AgentHostEnabledSettingId +| +-- state/ +| +-- sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) +| +-- sessionActions.ts # Action discriminated union + ActionEnvelope + Notifications +| +-- sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) +| +-- sessionProtocol.ts # JSON-RPC message types, request params/results +| +-- sessionCapabilities.ts # Version constants + ProtocolCapabilities +| +-- sessionClientState.ts # Client-side state manager with write-ahead reconciliation +| +-- sessionTransport.ts # IProtocolTransport / IProtocolServer abstractions +| +-- versions/ +| +-- v1.ts # v1 wire format types (tip -- editable, compiler-enforced compat) +| +-- versionRegistry.ts # Compile-time compat checks + runtime action->version map ++-- electron-browser/ +| +-- agentHostService.ts # AgentHostServiceClient (renderer singleton, direct MessagePort) ++-- electron-main/ +| +-- electronAgentHostStarter.ts # Spawns utility process, brokers MessagePort connections ++-- node/ +| +-- agentHostMain.ts # Entry point inside the Electron utility process +| +-- agentHostServerMain.ts # Entry point for standalone WebSocket server +| +-- agentService.ts # AgentService: dispatches to registered IAgent providers +| +-- agentHostService.ts # AgentHostProcessManager: lifecycle, crash recovery +| +-- agentEventMapper.ts # Maps IAgentProgressEvent -> ISessionAction +| +-- sessionStateManager.ts # Server-authoritative state tree + reducer dispatch +| +-- protocolServerHandler.ts # JSON-RPC routing, client subscriptions, action broadcast +| +-- webSocketTransport.ts # WebSocket IProtocolTransport + IProtocolServer impl +| +-- nodeAgentHostStarter.ts # Node.js (non-Electron) starter +| +-- copilot/ +| +-- copilotAgent.ts # CopilotAgent: IAgent backed by Copilot SDK +| +-- copilotSessionWrapper.ts +| +-- copilotToolDisplay.ts # Copilot-specific tool name -> display string mapping ++-- test/ + +-- (test files) + +src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/ ++-- agentHostChatContribution.ts # AgentHostContribution: discovers agents, registers dynamically ++-- agentHostLanguageModelProvider.ts # ILanguageModelChatProvider for SDK models ++-- agentHostSessionHandler.ts # AgentHostSessionHandler: generic, config-driven ++-- agentHostSessionListController.ts # Lists persisted sessions from agent host ++-- stateToProgressAdapter.ts # Converts protocol state -> IChatProgress[] for chat UI + +src/vs/workbench/contrib/chat/electron-browser/ ++-- chat.contribution.ts # Desktop-only: registers AgentHostContribution +``` + +## Session URIs + +Sessions are identified by URIs where the **scheme is the provider name** and the **path is the raw session ID**: `copilot:/`. Helper functions in the `AgentSession` namespace: + +| Helper | Purpose | +|---|---| +| `AgentSession.uri(provider, rawId)` | Create a session URI | +| `AgentSession.id(session)` | Extract raw session ID from URI | +| `AgentSession.provider(session)` | Extract provider name from URI scheme | + +The renderer uses UI resource schemes (`agent-host-copilot`) for session resources. The `AgentHostSessionHandler` converts these to provider URIs before IPC calls. + +## Communication Layers + +### Layer 1: IAgent interface (internal) + +The `IAgent` interface in `agentService.ts` is what each agent backend implements. It fires `IAgentProgressEvent`s (raw SDK events) and exposes methods for session management: + +| Method | Description | +|---|---| +| `createSession(config?)` | Create a new session (returns session URI) | +| `sendMessage(session, prompt, attachments?)` | Send a user message | +| `abortSession(session)` | Abort the current turn | +| `respondToPermissionRequest(requestId, approved)` | Grant/deny a permission | +| `getDescriptor()` | Return agent metadata | +| `listModels()` | List available models | +| `listSessions()` | List persisted sessions | +| `setAuthToken(token)` | Set auth credentials | +| `changeModel?(session, model)` | Change model for a session | + +### Layer 2: Sessions state protocol (client-facing) + +The server maps raw `IAgentProgressEvent`s to state actions via `agentEventMapper.ts`, dispatches them through `SessionStateManager`, and broadcasts to subscribed clients. See [protocol.md](protocol.md) for the full JSON-RPC specification, action types, state model, and versioning. + +### Layer 3: MessagePort relay (desktop renderer) + +`AgentHostServiceClient` in `electron-browser/agentHostService.ts` connects to the utility process via MessagePort and proxies `IAgentService` methods. It also forwards action envelopes and notifications as events so the renderer can feed them into `SessionClientState`. + +## How It Works + +### Setting Gate + +The `chat.agentHost.enabled` setting (default `false`) controls the entire feature: +- **Main process** (`app.ts`): skips creating `ElectronAgentHostStarter` + `AgentHostProcessManager` +- **Renderer proxy** (`AgentHostServiceClient`): skips MessagePort connection +- **Contribution** (`AgentHostContribution`): returns early without discovering or registering agents + +### Startup (lazy) + +1. `ElectronAgentHostStarter` is created in `app.ts` (if setting enabled) and handed to `AgentHostProcessManager`. +2. The utility process is **not** spawned until the first window requests a MessagePort connection. +3. On start, the starter spawns the utility process with entry point `vs/platform/agent/node/agentHostMain`. +4. Each renderer window gets its own MessagePort via `acquirePort('vscode:createAgentHostMessageChannel', ...)`. + +### Standalone Server Mode + +The agent host can also run as a standalone WebSocket server (`agentHostServerMain.ts`): + +```bash +node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] +``` + +This mode creates a `WebSocketProtocolServer` and `ProtocolServerHandler` directly without Electron. Useful for development and headless scenarios. + +### Dynamic Agent Discovery + +On startup (if the setting is enabled), `AgentHostContribution` calls `listAgents()` to discover available backends from the agent host process. Each returned `IAgentDescriptor` contains: + +| Field | Purpose | +|---|---| +| `provider` | Agent provider ID (`'copilot'`) | +| `displayName` | Human-readable name for UI | +| `description` | Description string | +| `requiresAuth` | Whether the renderer should push a GitHub auth token | + +For each descriptor, the contribution dynamically registers: +- Chat session contribution (type = `agent-host-{provider}`) +- `AgentHostSessionHandler` configured with the descriptor's metadata +- `AgentHostSessionListController` for the session sidebar +- `AgentHostLanguageModelProvider` for the model picker +- Auth token wiring (only if `requiresAuth` is true) + +### Auth Token Flow + +Only agents with `requiresAuth: true` (currently Copilot) get auth wiring: +1. On startup and on account/session changes, retrieves the GitHub OAuth token +2. Pushes it to the agent host via `IAgentHostService.setAuthToken(token)` +3. `CopilotAgent` passes it to `CopilotClient({ githubToken })` on next client creation + +### Crash Recovery + +`AgentHostProcessManager` monitors the utility process exit. On unexpected termination, it automatically restarts (up to 5 times). + +## Build / Packaging + +| File | Purpose | +|---|---| +| `build/next/index.ts` | Agent host entry point in esbuild config | +| `build/buildfile.ts` | Agent host entry point in legacy bundler config | +| `build/gulpfile.vscode.ts` | Strip wrong-arch copilot packages; ASAR unpack copilot binaries | +| `build/.moduleignore` | Strip unnecessary copilot prebuilds/ripgrep/clipboard | +| `build/darwin/create-universal-app.ts` | macOS universal binary support for copilot CLI | +| `build/darwin/verify-macho.ts` | Skip copilot binaries in Mach-O verification | + +## Closest Analogs + +| Component | Pattern | Key Difference | +|---|---|---| +| **Pty Host** | Singleton utility process, MessagePort, lazy start, crash recovery | Also has heartbeat monitoring and reconnect logic | +| **Shared Process** | Singleton utility process, MessagePort | Much heavier, hosts many services | +| **Extension Host** | Per-window utility process, custom `RPCProtocol` | Uses custom RPC, not standard channels | diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts new file mode 100644 index 00000000000..c649a047d99 --- /dev/null +++ b/src/vs/platform/agentHost/common/agent.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; + +// Agent host process starter and connection abstractions. +// Used by the main process to spawn and connect to the agent host utility process. + +export interface IAgentHostConnection { + readonly client: IChannelClient; + readonly store: DisposableStore; + readonly onDidProcessExit: Event<{ code: number; signal: string }>; +} + +export interface IAgentHostStarter extends IDisposable { + readonly onRequestConnection?: Event; + readonly onWillShutdown?: Event; + + /** + * Creates the agent host utility process and connects to it. + */ + start(): IAgentHostConnection; +} diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts new file mode 100644 index 00000000000..55dae1d1d4d --- /dev/null +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; +import type { IStateSnapshot } from './state/sessionProtocol.js'; + +// IPC contract between the renderer and the agent host utility process. +// Defines all serializable event types, the IAgent provider interface, +// and the IAgentService / IAgentHostService service decorators. + +export const enum AgentHostIpcChannels { + /** Channel for the agent host service on the main-process side */ + AgentHost = 'agentHost', + /** Channel for log forwarding from the agent host process */ + Logger = 'agentHostLogger', +} + +/** Configuration key that controls whether the agent host process is spawned. */ +export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; + +// ---- IPC data types (serializable across MessagePort) ----------------------- + +export interface IAgentSessionMetadata { + readonly session: URI; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; +} + +export type AgentProvider = 'copilot' | 'mock'; + +/** Metadata describing an agent backend, discovered over IPC. */ +export interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + /** Whether the renderer should push a GitHub auth token for this agent. */ + readonly requiresAuth: boolean; +} + +export interface IAgentCreateSessionConfig { + readonly provider?: AgentProvider; + readonly model?: string; + readonly session?: URI; + readonly workingDirectory?: string; +} + +/** Serializable attachment passed alongside a message to the agent host. */ +export interface IAgentAttachment { + readonly type: 'file' | 'directory' | 'selection'; + readonly path: string; + readonly displayName?: string; + /** For selections: the selected text. */ + readonly text?: string; + /** For selections: line/character range. */ + readonly selection?: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; +} + +/** Serializable model information from the agent host. */ +export interface IAgentModelInfo { + readonly provider: AgentProvider; + readonly id: string; + readonly name: string; + readonly maxContextWindow: number; + readonly supportsVision: boolean; + readonly supportsReasoningEffort: boolean; + readonly supportedReasoningEfforts?: readonly string[]; + readonly defaultReasoningEffort?: string; + readonly policyState?: 'enabled' | 'disabled' | 'unconfigured'; + readonly billingMultiplier?: number; +} + +// ---- Progress events (discriminated union by `type`) ------------------------ + +interface IAgentProgressEventBase { + readonly session: URI; +} + +/** Streaming text delta from the assistant (`assistant.message_delta`). */ +export interface IAgentDeltaEvent extends IAgentProgressEventBase { + readonly type: 'delta'; + readonly messageId: string; + readonly content: string; + readonly parentToolCallId?: string; +} + +/** A complete assistant message (`assistant.message`), used for history reconstruction. */ +export interface IAgentMessageEvent extends IAgentProgressEventBase { + readonly type: 'message'; + readonly role: 'user' | 'assistant'; + readonly messageId: string; + readonly content: string; + readonly toolRequests?: readonly { + readonly toolCallId: string; + readonly name: string; + /** Serialized JSON of arguments, if available. */ + readonly arguments?: string; + readonly type?: 'function' | 'custom'; + }[]; + readonly reasoningOpaque?: string; + readonly reasoningText?: string; + readonly encryptedContent?: string; + readonly parentToolCallId?: string; +} + +/** The session has finished processing and is waiting for input (`session.idle`). */ +export interface IAgentIdleEvent extends IAgentProgressEventBase { + readonly type: 'idle'; +} + +/** A tool has started executing (`tool.execution_start`). */ +export interface IAgentToolStartEvent extends IAgentProgressEventBase { + readonly type: 'tool_start'; + readonly toolCallId: string; + readonly toolName: string; + /** Human-readable display name for this tool. */ + readonly displayName: string; + /** Message describing the tool invocation in progress (e.g., "Running `echo hello`"). */ + readonly invocationMessage: string; + /** A representative input string for display in the UI (e.g., the shell command). */ + readonly toolInput?: string; + /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands). */ + readonly toolKind?: 'terminal'; + /** Language identifier for syntax highlighting (e.g., 'shellscript', 'powershell'). Used with toolKind 'terminal'. */ + readonly language?: string; + /** Serialized JSON of the tool arguments, if available. */ + readonly toolArguments?: string; + readonly mcpServerName?: string; + readonly mcpToolName?: string; + readonly parentToolCallId?: string; +} + +/** A tool has finished executing (`tool.execution_complete`). */ +export interface IAgentToolCompleteEvent extends IAgentProgressEventBase { + readonly type: 'tool_complete'; + readonly toolCallId: string; + readonly success: boolean; + /** Message describing the completed tool invocation (e.g., "Ran `echo hello`"). */ + readonly pastTenseMessage: string; + /** Tool output content for display in the UI. */ + readonly toolOutput?: string; + readonly isUserRequested?: boolean; + readonly result?: { + readonly content: string; + readonly detailedContent?: string; + }; + readonly error?: { + readonly message: string; + readonly code?: string; + }; + /** Serialized JSON of tool-specific telemetry data. */ + readonly toolTelemetry?: string; + readonly parentToolCallId?: string; +} + +/** The session title has been updated. */ +export interface IAgentTitleChangedEvent extends IAgentProgressEventBase { + readonly type: 'title_changed'; + readonly title: string; +} + +/** An error occurred during session processing. */ +export interface IAgentErrorEvent extends IAgentProgressEventBase { + readonly type: 'error'; + readonly errorType: string; + readonly message: string; + readonly stack?: string; +} + +/** Token usage information for a request. */ +export interface IAgentUsageEvent extends IAgentProgressEventBase { + readonly type: 'usage'; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly model?: string; + readonly cacheReadTokens?: number; +} + +/** A tool permission request from the SDK requiring a renderer-side decision. */ +export interface IAgentPermissionRequestEvent extends IAgentProgressEventBase { + readonly type: 'permission_request'; + /** Unique ID for correlating the response. */ + readonly requestId: string; + /** The kind of permission being requested. */ + readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; + /** The tool call ID that triggered this permission request. */ + readonly toolCallId?: string; + /** File path involved (for read/write). */ + readonly path?: string; + /** For shell: the full command text. */ + readonly fullCommandText?: string; + /** For shell: the intention description. */ + readonly intention?: string; + /** For MCP: the server name. */ + readonly serverName?: string; + /** For MCP: the tool name. */ + readonly toolName?: string; + /** Serialized JSON of the full permission request for fallback display. */ + readonly rawRequest: string; +} + +/** Streaming reasoning/thinking content from the assistant. */ +export interface IAgentReasoningEvent extends IAgentProgressEventBase { + readonly type: 'reasoning'; + readonly content: string; +} + +export type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentPermissionRequestEvent + | IAgentReasoningEvent; + +// ---- Session URI helpers ---------------------------------------------------- + +export namespace AgentSession { + + /** + * Creates a session URI from a provider name and raw session ID. + * The URI scheme is the provider name (e.g., `copilot:/`). + */ + export function uri(provider: AgentProvider, rawSessionId: string): URI { + return URI.from({ scheme: provider, path: `/${rawSessionId}` }); + } + + /** + * Extracts the raw session ID from a session URI (the path without leading slash). + */ + export function id(session: URI): string { + return session.path.substring(1); + } + + /** + * Extracts the provider name from a session URI scheme. + */ + export function provider(session: URI): AgentProvider | undefined { + const scheme = session.scheme; + if (scheme === 'copilot' || scheme === 'mock') { + return scheme; + } + return undefined; + } +} + +// ---- Agent provider interface ----------------------------------------------- + +/** + * Implemented by each agent backend (e.g. Copilot SDK). + * The {@link IAgentService} dispatches to the appropriate agent based on + * the agent id. + */ +export interface IAgent { + /** Unique identifier for this provider (e.g. `'copilot'`). */ + readonly id: AgentProvider; + + /** Fires when the provider streams progress for a session. */ + readonly onDidSessionProgress: Event; + + /** Create a new session. Returns the session URI. */ + createSession(config?: IAgentCreateSessionConfig): Promise; + + /** Send a user message into an existing session. */ + sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; + + /** Retrieve all session events/messages for reconstruction. */ + getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]>; + + /** Dispose a session, freeing resources. */ + disposeSession(session: URI): Promise; + + /** Abort the current turn, stopping any in-flight processing. */ + abortSession(session: URI): Promise; + + /** Change the model for an existing session. */ + changeModel(session: URI, model: string): Promise; + + /** Respond to a pending permission request from the SDK. */ + respondToPermissionRequest(requestId: string, approved: boolean): void; + + /** Return the descriptor for this agent. */ + getDescriptor(): IAgentDescriptor; + + /** List available models from this provider. */ + listModels(): Promise; + + /** List persisted sessions from this provider. */ + listSessions(): Promise; + + /** Set the authentication token for this provider. */ + setAuthToken(token: string): Promise; + + /** Gracefully shut down all sessions. */ + shutdown(): Promise; + + /** Dispose this provider and all its resources. */ + dispose(): void; +} + +// ---- Service interfaces ----------------------------------------------------- + +export const IAgentService = createDecorator('agentService'); + +/** + * Service contract for communicating with the agent host process. Methods here + * are proxied across MessagePort via `ProxyChannel`. + * + * State is synchronized via the subscribe/unsubscribe/dispatchAction protocol. + * Clients observe root state (agents, models) and session state via subscriptions, + * and mutate state by dispatching actions (e.g. session/turnStarted, session/turnCancelled). + */ +export interface IAgentService { + readonly _serviceBrand: undefined; + + /** Discover available agent backends from the agent host. */ + listAgents(): Promise; + + /** Set the GitHub auth token used by the Copilot SDK. */ + setAuthToken(token: string): Promise; + + /** + * Refresh the model list from all providers, publishing updated + * agents (with models) to root state via `root/agentsChanged`. + */ + refreshModels(): Promise; + + /** List all available sessions from the Copilot CLI. */ + listSessions(): Promise; + + /** Create a new session. Returns the session URI. */ + createSession(config?: IAgentCreateSessionConfig): Promise; + + /** Dispose a session in the agent host, freeing SDK resources. */ + disposeSession(session: URI): Promise; + + /** Gracefully shut down all sessions and the underlying client. */ + shutdown(): Promise; + + // ---- Protocol methods (sessions process protocol) ---------------------- + + /** + * Subscribe to state at the given URI. Returns a snapshot of the current + * state and the serverSeq at snapshot time. Subsequent actions for this + * resource arrive via {@link onDidAction}. + */ + subscribe(resource: URI): Promise; + + /** Unsubscribe from state updates for the given URI. */ + unsubscribe(resource: URI): void; + + /** + * Fires when the server applies an action to subscribable state. + * Clients use this alongside {@link subscribe} to keep their local + * state in sync. + */ + readonly onDidAction: Event; + + /** + * Fires when the server broadcasts an ephemeral notification + * (e.g. sessionAdded, sessionRemoved). + */ + readonly onDidNotification: Event; + + /** + * Dispatch a client-originated action to the server. The server applies + * it to state, triggers side effects, and echoes it back via + * {@link onDidAction} with the client's origin for reconciliation. + */ + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; +} + +export const IAgentHostService = createDecorator('agentHostService'); + +/** + * The local wrapper around the agent host process (manages lifecycle, restart, + * exposes the proxied service). Consumed by the main process and workbench. + */ +export interface IAgentHostService extends IAgentService { + + /** Unique identifier for this client window, used as the origin in action envelopes. */ + readonly clientId: string; + readonly onAgentHostExit: Event; + readonly onAgentHostStart: Event; + + restartAgentHost(): Promise; +} diff --git a/src/vs/platform/agentHost/common/state/AGENTS.md b/src/vs/platform/agentHost/common/state/AGENTS.md new file mode 100644 index 00000000000..25db0bd998f --- /dev/null +++ b/src/vs/platform/agentHost/common/state/AGENTS.md @@ -0,0 +1,81 @@ +# Protocol versioning instructions + +This directory contains the protocol version system. Read this before modifying any protocol types. + +## Overview + +The protocol has **living types** (in `sessionState.ts`, `sessionActions.ts`) and **version type snapshots** (in `versions/v1.ts`, etc.). The `versions/versionRegistry.ts` file contains compile-time checks that enforce backwards compatibility between them, plus a runtime map that tracks which action types belong to which version. + +The latest version file is the **tip** — it can be edited. Older version files are frozen. + +## Adding optional fields to existing types + +This is the most common change. No version bump needed. + +1. Add the optional field to the living type in `sessionState.ts` or `sessionActions.ts`: + ```typescript + export interface IToolCallState { + // ...existing fields... + readonly mcpServerName?: string; // new optional field + } + ``` +2. Add the same optional field to the corresponding type in the **tip** version file (currently `versions/v1.ts`): + ```typescript + export interface IV1_ToolCallState { + // ...existing fields... + readonly mcpServerName?: string; + } + ``` +3. Compile. If it passes, you're done. If it fails, you tried to do something incompatible. + +You can also skip step 2 — the tip is allowed to be a subset of the living type. But adding it to the tip documents that the field exists at this version. + +## Adding new action types + +Adding a new action type is backwards-compatible and does **not** require a version bump. Old clients at the same version ignore unknown action types (reducers return state unchanged). Old servers at the same version simply never produce the action. + +1. **Add the new action interface** to `sessionActions.ts` and include it in the `ISessionAction` or `IRootAction` union. +2. **Add the action to `ACTION_INTRODUCED_IN`** in `versions/versionRegistry.ts` with the **current** version number. The compiler will force you to do this — if you add a type to the union without a map entry, it won't compile. +3. **Add the type to the tip version file** (currently `versions/v1.ts`) and add an `AssertCompatible` check in `versions/versionRegistry.ts`. +4. **Add a reducer case** in `sessionReducers.ts` to handle the new action. +5. **Update `../../../protocol.md`** to document the new action. + +### When to bump the version + +Bump `PROTOCOL_VERSION` when you need a **capability boundary** — i.e., a client needs to check "does this server support feature X?" before sending commands or rendering UI. Examples: + +- A new **client-sendable** action that requires server-side support (the client must know the server can handle it before sending) +- A group of related actions that form a new feature area (subagents, model selection, etc.) + +When bumping: +1. **Bump `PROTOCOL_VERSION`** in `versions/versionRegistry.ts`. +2. **Create the new tip version file** `versions/v{N}.ts`. Copy the previous tip and add your new types. The previous tip is now frozen — do not edit it. +3. **Add `AssertCompatible` checks** in `versions/versionRegistry.ts` for the new version's types. +4. **Add `ProtocolCapabilities` fields** in `sessionCapabilities.ts` for the new feature area. +5. Assign your new action types version N in `ACTION_INTRODUCED_IN`. +6. **Update `../../../protocol.md`** version history. + +## Adding new notification types + +Same process as new action types, but use `NOTIFICATION_INTRODUCED_IN` instead of `ACTION_INTRODUCED_IN`. + +## Raising the minimum protocol version + +This drops support for old clients and lets you delete compatibility cruft. + +1. **Raise `MIN_PROTOCOL_VERSION`** in `versions/versionRegistry.ts` from N to N+1. +2. **Delete `versions/v{N}.ts`**. +3. **Remove the v{N} `AssertCompatible` checks** and version-grouped type aliases from `versions/versionRegistry.ts`. +4. **Compile.** The compiler will surface any code that referenced the deleted version types — clean it up. +5. **Update `../../../protocol.md`** version history. + +## What the compiler catches + +| Mistake | Compile error | +|---|---| +| Remove a field from a living type | `Current extends Frozen` fails in `AssertCompatible` | +| Change a field's type | `Current extends Frozen` fails in `AssertCompatible` | +| Add a required field to a living type | `Frozen extends Current` fails in `AssertCompatible` | +| Add action to union, forget `ACTION_INTRODUCED_IN` entry | Mapped type index is incomplete | +| Add notification to union, forget `NOTIFICATION_INTRODUCED_IN` entry | Mapped type index is incomplete | +| Remove action type that a version still references | Version-grouped union no longer extends living union | diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts new file mode 100644 index 00000000000..8362d44564b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Action and notification types for the sessions process protocol. +// See protocol.md -> Actions for the full design. +// +// Actions mutate subscribable state via reducers. Notifications are ephemeral +// broadcasts not stored in state. Both flow through ActionEnvelopes. +// +// Asymmetry: not all actions can be triggered by clients. Root actions are +// server-only. Session actions are mixed — see the "Client-sendable?" column +// in protocol.md for the authoritative list. + +import { URI } from '../../../../base/common/uri.js'; +import type { + IAgentInfo, + IErrorInfo, + IPermissionRequest, + IResponsePart, + ISessionSummary, + IToolCallState, + IUsageInfo, + IUserMessage, +} from './sessionState.js'; + +// ---- Action envelope -------------------------------------------------------- + +/** + * Wraps every action with server-assigned sequencing and origin tracking. + * This enables write-ahead reconciliation: the client can tell whether an + * incoming action was its own (echo) or from another source (rebase needed). + */ +export interface IActionEnvelope { + /** The action payload. */ + readonly action: A; + /** Monotonically increasing sequence number assigned by the server. */ + readonly serverSeq: number; + /** + * Origin tracking. `undefined` means the action was produced by the server + * itself (e.g. from an agent backend). Otherwise identifies the client that + * sent the command which triggered this action. + */ + readonly origin: IActionOrigin | undefined; + /** + * Set to `true` when the server rejected the command that produced this + * action. The client should revert its optimistic prediction. + */ + readonly rejected?: true; +} + +export interface IActionOrigin { + readonly clientId: string; + readonly clientSeq: number; +} + +// ---- Root actions (server-only, mutate RootState) --------------------------- + +export interface IAgentsChangedAction { + readonly type: 'root/agentsChanged'; + readonly agents: readonly IAgentInfo[]; +} + +export type IRootAction = + | IAgentsChangedAction; + +// ---- Session actions (mutate SessionState, scoped to a session URI) --------- + +interface ISessionActionBase { + /** URI identifying the session this action applies to. */ + readonly session: URI; +} + +// -- Lifecycle (server-only) -- + +export interface ISessionReadyAction extends ISessionActionBase { + readonly type: 'session/ready'; +} + +export interface ISessionCreationFailedAction extends ISessionActionBase { + readonly type: 'session/creationFailed'; + readonly error: IErrorInfo; +} + +// -- Turn lifecycle -- + +/** Client-dispatchable. Server starts agent processing on receipt. */ +export interface ITurnStartedAction extends ISessionActionBase { + readonly type: 'session/turnStarted'; + readonly turnId: string; + readonly userMessage: IUserMessage; +} + +/** Server-only. */ +export interface IDeltaAction extends ISessionActionBase { + readonly type: 'session/delta'; + readonly turnId: string; + readonly content: string; +} + +/** Server-only. */ +export interface IResponsePartAction extends ISessionActionBase { + readonly type: 'session/responsePart'; + readonly turnId: string; + readonly part: IResponsePart; +} + +// -- Tool calls (server-only) -- + +export interface IToolStartAction extends ISessionActionBase { + readonly type: 'session/toolStart'; + readonly turnId: string; + readonly toolCall: IToolCallState; +} + +export interface IToolCompleteAction extends ISessionActionBase { + readonly type: 'session/toolComplete'; + readonly turnId: string; + readonly toolCallId: string; + readonly result: IToolCompleteResult; +} + +/** The data delivered with a tool completion event. */ +export interface IToolCompleteResult { + readonly success: boolean; + readonly pastTenseMessage: string; + readonly toolOutput?: string; + readonly error?: { readonly message: string; readonly code?: string }; +} + +// -- Permissions -- + +/** Server-only. */ +export interface IPermissionRequestAction extends ISessionActionBase { + readonly type: 'session/permissionRequest'; + readonly turnId: string; + readonly request: IPermissionRequest; +} + +/** Client-dispatchable. Server unblocks pending tool execution. */ +export interface IPermissionResolvedAction extends ISessionActionBase { + readonly type: 'session/permissionResolved'; + readonly turnId: string; + readonly requestId: string; + readonly approved: boolean; +} + +// -- Turn completion -- + +/** Server-only. */ +export interface ITurnCompleteAction extends ISessionActionBase { + readonly type: 'session/turnComplete'; + readonly turnId: string; +} + +/** Client-dispatchable. Server aborts in-progress processing. */ +export interface ITurnCancelledAction extends ISessionActionBase { + readonly type: 'session/turnCancelled'; + readonly turnId: string; +} + +/** Server-only. */ +export interface ISessionErrorAction extends ISessionActionBase { + readonly type: 'session/error'; + readonly turnId: string; + readonly error: IErrorInfo; +} + +// -- Metadata & informational -- + +/** Server-only. */ +export interface ITitleChangedAction extends ISessionActionBase { + readonly type: 'session/titleChanged'; + readonly title: string; +} + +/** Server-only. */ +export interface IUsageAction extends ISessionActionBase { + readonly type: 'session/usage'; + readonly turnId: string; + readonly usage: IUsageInfo; +} + +/** Server-only. */ +export interface IReasoningAction extends ISessionActionBase { + readonly type: 'session/reasoning'; + readonly turnId: string; + readonly content: string; +} + +/** Server-only. Dispatched when the session's model is changed. */ +export interface IModelChangedAction extends ISessionActionBase { + readonly type: 'session/modelChanged'; + readonly model: string; +} + +export type ISessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ITurnStartedAction + | IDeltaAction + | IResponsePartAction + | IToolStartAction + | IToolCompleteAction + | IPermissionRequestAction + | IPermissionResolvedAction + | ITurnCompleteAction + | ITurnCancelledAction + | ISessionErrorAction + | ITitleChangedAction + | IUsageAction + | IReasoningAction + | IModelChangedAction; + +// ---- Combined state action type --------------------------------------------- + +/** Any action that mutates subscribable state (processed by a reducer). */ +export type IStateAction = IRootAction | ISessionAction; + +// ---- Notifications (ephemeral, not stored in state) ------------------------- + +/** + * Broadcast to all connected clients when a session is created. + * Not processed by reducers — used by clients to maintain a local session list. + */ +export interface ISessionAddedNotification { + readonly type: 'notify/sessionAdded'; + readonly summary: ISessionSummary; +} + +/** + * Broadcast to all connected clients when a session is disposed. + * Not processed by reducers — used by clients to maintain a local session list. + */ +export interface ISessionRemovedNotification { + readonly type: 'notify/sessionRemoved'; + readonly session: URI; +} + +export type INotification = + | ISessionAddedNotification + | ISessionRemovedNotification; + +// ---- Type guards ------------------------------------------------------------ + +export function isRootAction(action: IStateAction): action is IRootAction { + return action.type.startsWith('root/'); +} + +export function isSessionAction(action: IStateAction): action is ISessionAction { + return action.type.startsWith('session/'); +} diff --git a/src/vs/platform/agentHost/common/state/sessionCapabilities.ts b/src/vs/platform/agentHost/common/state/sessionCapabilities.ts new file mode 100644 index 00000000000..b10b8ca4664 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionCapabilities.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol version constants and capability derivation. +// See protocol.md -> Versioning for the full design. +// +// The authoritative version numbers and action-filtering logic live in +// versions/versionRegistry.ts. This file re-exports them and provides the +// capability-object API that client code uses to gate features. + +export { + ACTION_INTRODUCED_IN, + isActionKnownToVersion, + isNotificationKnownToVersion, + MIN_PROTOCOL_VERSION, + NOTIFICATION_INTRODUCED_IN, + PROTOCOL_VERSION, +} from './versions/versionRegistry.js'; + +/** + * Capabilities derived from a protocol version. + * Core features (v1) are always-present literal `true`. + * Features from later versions are optional `true | undefined`. + */ +export interface ProtocolCapabilities { + // v1 — always present + readonly sessions: true; + readonly tools: true; + readonly permissions: true; +} + +/** + * Derives the set of capabilities available at a given protocol version. + * Newer clients use this to determine which features the server supports. + */ +export function capabilitiesForVersion(version: number): ProtocolCapabilities { + if (version < 1) { + throw new Error(`Unsupported protocol version: ${version}`); + } + + return { + sessions: true, + tools: true, + permissions: true, + // Future versions add fields here: + // ...(version >= 2 ? { reasoning: true as const } : {}), + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts new file mode 100644 index 00000000000..3d26433161d --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Client-side state manager for the sessions process protocol. +// See protocol.md -> Write-ahead reconciliation for the full design. +// +// Manages confirmed state (last server-acknowledged), pending actions queue +// (optimistically applied), and reconciliation when the server echoes back +// or sends concurrent actions from other sources. +// +// This operates on two kinds of subscribable state: +// - Root state (agents + their models) — server-only mutations, no write-ahead. +// - Session state — mixed: some actions client-sendable (write-ahead), +// others server-only. + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js'; +import { rootReducer, sessionReducer } from './sessionReducers.js'; +import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js'; + +// ---- Pending action tracking ------------------------------------------------ + +interface IPendingAction { + readonly clientSeq: number; + readonly action: IStateAction; +} + +// ---- Client state manager --------------------------------------------------- + +/** + * Manages the client's local view of the state tree with write-ahead + * reconciliation. The client can optimistically apply its own session + * actions and reconcile when the server echoes them back (possibly + * interleaved with actions from other clients or the server). + * + * Usage: + * 1. Call `handleSnapshot(resource, state, fromSeq)` for each snapshot + * from the handshake or a subscribe response. + * 2. Call `applyOptimistic(action)` when the user does something + * (returns a clientSeq for the command). + * 3. Call `receiveEnvelope(envelope)` for each action from the server. + * 4. Call `receiveNotification(notification)` for each notification. + * 5. Read `rootState` / `getSessionState(uri)` for the current view. + */ +export class SessionClientState extends Disposable { + + private readonly _clientId: string; + private _nextClientSeq = 1; + private _lastSeenServerSeq = 0; + + // Confirmed state — reflects only what the server has acknowledged + private _confirmedRootState: IRootState | undefined; + private readonly _confirmedSessionStates = new Map(); + + // Pending session actions (root actions are server-only, never pending) + private readonly _pendingActions: IPendingAction[] = []; + + // Cached optimistic state — recomputed when confirmed or pending changes + private _optimisticRootState: IRootState | undefined; + private readonly _optimisticSessionStates = new Map(); + + private readonly _onDidChangeRootState = this._register(new Emitter()); + readonly onDidChangeRootState: Event = this._onDidChangeRootState.event; + + private readonly _onDidChangeSessionState = this._register(new Emitter<{ session: URI; state: ISessionState }>()); + readonly onDidChangeSessionState: Event<{ session: URI; state: ISessionState }> = this._onDidChangeSessionState.event; + + private readonly _onDidReceiveNotification = this._register(new Emitter()); + readonly onDidReceiveNotification: Event = this._onDidReceiveNotification.event; + + constructor(clientId: string) { + super(); + this._clientId = clientId; + } + + get clientId(): string { + return this._clientId; + } + + get lastSeenServerSeq(): number { + return this._lastSeenServerSeq; + } + + /** Current root state, or undefined if not yet subscribed. */ + get rootState(): IRootState | undefined { + return this._optimisticRootState; + } + + /** Current optimistic session state, or undefined if not subscribed. */ + getSessionState(session: URI): ISessionState | undefined { + return this._optimisticSessionStates.get(session.toString()); + } + + /** URIs of sessions the client is currently subscribed to. */ + get subscribedSessions(): readonly URI[] { + return [...this._confirmedSessionStates.keys()].map(k => URI.parse(k)); + } + + // ---- Snapshot handling --------------------------------------------------- + + /** + * Apply a state snapshot received from the server (from handshake, + * subscribe response, or reconnection). + */ + handleSnapshot(resource: URI, state: IRootState | ISessionState, fromSeq: number): void { + this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, fromSeq); + + if (resource.toString() === ROOT_STATE_URI.toString()) { + const rootState = state as IRootState; + this._confirmedRootState = rootState; + this._optimisticRootState = rootState; + this._onDidChangeRootState.fire(rootState); + } else { + const key = resource.toString(); + const sessionState = state as ISessionState; + this._confirmedSessionStates.set(key, sessionState); + this._optimisticSessionStates.set(key, sessionState); + // Re-apply any pending session actions for this session + this._recomputeOptimisticSession(resource); + this._onDidChangeSessionState.fire({ + session: resource, + state: this._optimisticSessionStates.get(key)!, + }); + } + } + + /** + * Unsubscribe from a resource, dropping its local state. + */ + unsubscribe(resource: URI): void { + const key = resource.toString(); + if (key === ROOT_STATE_URI.toString()) { + this._confirmedRootState = undefined; + this._optimisticRootState = undefined; + } else { + this._confirmedSessionStates.delete(key); + this._optimisticSessionStates.delete(key); + // Remove pending actions for this session + for (let i = this._pendingActions.length - 1; i >= 0; i--) { + const action = this._pendingActions[i].action; + if (isSessionAction(action) && action.session.toString() === key) { + this._pendingActions.splice(i, 1); + } + } + } + } + + // ---- Write-ahead -------------------------------------------------------- + + /** + * Optimistically apply a session action locally. Returns the clientSeq + * that should be sent to the server with the corresponding command so + * the server can echo it back for reconciliation. + * + * Only session actions can be write-ahead (root actions are server-only). + */ + applyOptimistic(action: ISessionAction): number { + const clientSeq = this._nextClientSeq++; + this._pendingActions.push({ clientSeq, action }); + this._applySessionToOptimistic(action); + return clientSeq; + } + + // ---- Receiving server messages ------------------------------------------ + + /** + * Process an action envelope received from the server. + * This is the core reconciliation algorithm. + */ + receiveEnvelope(envelope: IActionEnvelope): void { + this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, envelope.serverSeq); + + const origin = envelope.origin; + const isOwnAction = origin !== undefined && origin.clientId === this._clientId; + + if (isOwnAction) { + const headIdx = this._pendingActions.findIndex(p => p.clientSeq === origin.clientSeq); + + if (headIdx !== -1) { + if (envelope.rejected) { + this._pendingActions.splice(headIdx, 1); + } else { + this._applyToConfirmed(envelope.action); + this._pendingActions.splice(headIdx, 1); + } + } else { + this._applyToConfirmed(envelope.action); + } + } else { + this._applyToConfirmed(envelope.action); + } + + // Recompute optimistic state from confirmed + remaining pending + this._recomputeOptimistic(envelope.action); + } + + /** + * Process an ephemeral notification from the server. + * Not stored in state — just forwarded to listeners. + */ + receiveNotification(notification: INotification): void { + this._onDidReceiveNotification.fire(notification); + } + + // ---- Internal state management ------------------------------------------ + + private _applyToConfirmed(action: IStateAction): void { + if (isRootAction(action) && this._confirmedRootState) { + this._confirmedRootState = rootReducer(this._confirmedRootState, action); + } + if (isSessionAction(action)) { + const key = action.session.toString(); + const state = this._confirmedSessionStates.get(key); + if (state) { + this._confirmedSessionStates.set(key, sessionReducer(state, action)); + } + } + } + + private _applySessionToOptimistic(action: ISessionAction): void { + const key = action.session.toString(); + const state = this._optimisticSessionStates.get(key); + if (state) { + const newState = sessionReducer(state, action); + this._optimisticSessionStates.set(key, newState); + this._onDidChangeSessionState.fire({ session: action.session, state: newState }); + } + } + + /** + * After applying a server action to confirmed state, recompute optimistic + * state by replaying pending actions on top of confirmed. + */ + private _recomputeOptimistic(triggerAction: IStateAction): void { + // Root state: no pending actions (server-only), so optimistic = confirmed + if (isRootAction(triggerAction) && this._confirmedRootState) { + this._optimisticRootState = this._confirmedRootState; + this._onDidChangeRootState.fire(this._confirmedRootState); + } + + // Session states: recompute only affected sessions + if (isSessionAction(triggerAction)) { + this._recomputeOptimisticSession(triggerAction.session); + } + + // Also recompute any sessions that have pending actions + const affectedKeys = new Set(); + for (const pending of this._pendingActions) { + if (isSessionAction(pending.action)) { + affectedKeys.add(pending.action.session.toString()); + } + } + for (const key of affectedKeys) { + const uri = URI.parse(key); + this._recomputeOptimisticSession(uri); + } + } + + private _recomputeOptimisticSession(session: URI): void { + const key = session.toString(); + const confirmed = this._confirmedSessionStates.get(key); + if (!confirmed) { + return; + } + + let state = confirmed; + for (const pending of this._pendingActions) { + if (isSessionAction(pending.action) && pending.action.session.toString() === key) { + state = sessionReducer(state, pending.action); + } + } + + this._optimisticSessionStates.set(key, state); + this._onDidChangeSessionState.fire({ session, state }); + } +} diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts new file mode 100644 index 00000000000..dde5a517479 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol messages using JSON-RPC 2.0 framing for the sessions process. +// See protocol.md for the full design. +// +// Client → Server messages are either: +// - Notifications (fire-and-forget): initialize, reconnect, unsubscribe, dispatchAction +// - Requests (expect a correlated response): subscribe, createSession, disposeSession, +// listSessions, fetchTurns, fetchContent +// +// Server → Client messages are either: +// - Notifications (pushed to clients): serverHello, reconnectResponse, action, notification +// - Responses (correlated to a client request by id) + +import { hasKey } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import type { IActionEnvelope, INotification, ISessionAction, IStateAction } from './sessionActions.js'; +import type { IRootState, ISessionState, ISessionSummary } from './sessionState.js'; + +// ---- JSON-RPC 2.0 base types ----------------------------------------------- + +/** A JSON-RPC notification: has `method` but no `id`. */ +export interface IProtocolNotification { + readonly jsonrpc: '2.0'; + readonly method: string; + readonly params?: unknown; +} + +/** A JSON-RPC request: has both `method` and `id`. */ +export interface IProtocolRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params?: unknown; +} + +/** A JSON-RPC success response. */ +export interface IJsonRpcSuccessResponse { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly result: unknown; +} + +/** A JSON-RPC error response. */ +export interface IJsonRpcErrorResponse { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly error: { + readonly code: number; + readonly message: string; + readonly data?: unknown; + }; +} + +export type IJsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; + +/** Any message that flows over the protocol transport. */ +export type IProtocolMessage = IProtocolNotification | IProtocolRequest | IJsonRpcResponse; + +// ---- Type guards ----------------------------------------------------------- + +export function isJsonRpcRequest(msg: IProtocolMessage): msg is IProtocolRequest { + return hasKey(msg, { id: true, method: true }); +} + +export function isJsonRpcNotification(msg: IProtocolMessage): msg is IProtocolNotification { + return hasKey(msg, { method: true }) && !hasKey(msg, { id: true }); +} + +export function isJsonRpcResponse(msg: IProtocolMessage): msg is IJsonRpcResponse { + return hasKey(msg, { id: true }) && !hasKey(msg, { method: true }); +} + +// ---- JSON-RPC error codes --------------------------------------------------- + +export const JSON_RPC_INTERNAL_ERROR = -32603; + +// ---- Shared data types ------------------------------------------------------ + +/** State snapshot returned by subscribe and included in handshake/reconnect. */ +export interface IStateSnapshot { + readonly resource: URI; + readonly state: IRootState | ISessionState; + readonly fromSeq: number; +} + +// ---- Client → Server: Notification params ----------------------------------- + +export interface IInitializeParams { + readonly protocolVersion: number; + readonly clientId: string; + readonly initialSubscriptions?: readonly URI[]; +} + +export interface IReconnectParams { + readonly clientId: string; + readonly lastSeenServerSeq: number; + readonly subscriptions: readonly URI[]; +} + +export interface IUnsubscribeParams { + readonly resource: URI; +} + +export interface IDispatchActionParams { + readonly clientSeq: number; + readonly action: ISessionAction; +} + +// ---- Client → Server: Request params and results ---------------------------- + +export interface ISubscribeParams { + readonly resource: URI; +} +// Result: IStateSnapshot + +export interface ICreateSessionParams { + readonly session: URI; + readonly provider?: string; + readonly model?: string; + readonly workingDirectory?: string; +} +// Result: void (null) + +export interface IDisposeSessionParams { + readonly session: URI; +} +// Result: void (null) + +// listSessions: no params +export interface IListSessionsResult { + readonly sessions: readonly ISessionSummary[]; +} + +export interface IFetchTurnsParams { + readonly session: URI; + readonly startTurn: number; + readonly count: number; +} + +export interface IFetchTurnsResult { + readonly session: URI; + readonly startTurn: number; + readonly turns: ISessionState['turns']; + readonly totalTurns: number; +} + +export interface IFetchContentParams { + readonly uri: URI; +} + +export interface IFetchContentResult { + readonly uri: URI; + readonly data: string; // base64-encoded for binary safety + readonly mimeType?: string; +} + +// ---- Server → Client: Notification params ----------------------------------- + +export interface IServerHelloParams { + readonly protocolVersion: number; + readonly serverSeq: number; + readonly snapshots: readonly IStateSnapshot[]; +} + +export interface IReconnectResponseParams { + readonly serverSeq: number; + readonly snapshots: readonly IStateSnapshot[]; +} + +export interface IActionBroadcastParams { + readonly envelope: IActionEnvelope; +} + +export interface INotificationBroadcastParams { + readonly notification: INotification; +} diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts new file mode 100644 index 00000000000..df2103809ff --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Pure reducer functions for the sessions process protocol. +// See protocol.md -> Reducers for the full design. +// +// Both the server and clients run the same reducers. This is what makes +// write-ahead possible: the client can locally predict the result of its +// own action using the exact same logic the server will run. +// +// IMPORTANT: Reducers must be pure — no side effects, no I/O, no service +// calls. Server-side effects (e.g. forwarding to the Copilot SDK) are +// handled by a separate dispatch layer. + +import type { IRootAction, ISessionAction } from './sessionActions.js'; +import { + type ICompletedToolCall, + type IErrorInfo, + type IRootState, + type ISessionState, + type IToolCallState, + type ITurn, + createActiveTurn, + SessionLifecycle, + SessionStatus, + ToolCallStatus, + TurnState, +} from './sessionState.js'; + +// ---- Root reducer ----------------------------------------------------------- + +/** + * Reduces root-level actions into a new RootState. + * Root actions are server-only (clients observe but cannot produce them). + */ +export function rootReducer(state: IRootState, action: IRootAction): IRootState { + switch (action.type) { + case 'root/agentsChanged': { + return { ...state, agents: action.agents }; + } + } +} + +// ---- Session reducer -------------------------------------------------------- + +/** + * Reduces session-level actions into a new SessionState. + * Handles lifecycle, turn lifecycle, streaming deltas, tool calls, permissions. + */ +export function sessionReducer(state: ISessionState, action: ISessionAction): ISessionState { + switch (action.type) { + case 'session/ready': { + return { ...state, lifecycle: SessionLifecycle.Ready }; + } + case 'session/creationFailed': { + return { + ...state, + lifecycle: SessionLifecycle.CreationFailed, + creationError: action.error, + }; + } + case 'session/turnStarted': { + const activeTurn = createActiveTurn(action.turnId, action.userMessage); + return { + ...state, + activeTurn, + summary: { ...state.summary, status: SessionStatus.InProgress }, + }; + } + case 'session/delta': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + streamingText: state.activeTurn.streamingText + action.content, + }, + }; + } + case 'session/responsePart': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + responseParts: [...state.activeTurn.responseParts, action.part], + }, + }; + } + case 'session/toolStart': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const toolCalls = new Map(state.activeTurn.toolCalls); + toolCalls.set(action.toolCall.toolCallId, action.toolCall); + return { + ...state, + activeTurn: { ...state.activeTurn, toolCalls }, + }; + } + case 'session/toolComplete': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const toolCall = state.activeTurn.toolCalls.get(action.toolCallId); + if (!toolCall) { + return state; + } + const toolCalls = new Map(state.activeTurn.toolCalls); + toolCalls.set(action.toolCallId, { + ...toolCall, + status: action.result.success ? ToolCallStatus.Completed : ToolCallStatus.Failed, + pastTenseMessage: action.result.pastTenseMessage, + toolOutput: action.result.toolOutput, + error: action.result.error, + }); + return { + ...state, + activeTurn: { ...state.activeTurn, toolCalls }, + }; + } + case 'session/permissionRequest': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const pendingPermissions = new Map(state.activeTurn.pendingPermissions); + pendingPermissions.set(action.request.requestId, action.request); + let toolCalls: ReadonlyMap = state.activeTurn.toolCalls; + if (action.request.toolCallId) { + const toolCall = toolCalls.get(action.request.toolCallId); + if (toolCall) { + const mutable = new Map(toolCalls); + mutable.set(action.request.toolCallId, { + ...toolCall, + status: ToolCallStatus.PendingPermission, + }); + toolCalls = mutable; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + case 'session/permissionResolved': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const pendingPermissions = new Map(state.activeTurn.pendingPermissions); + const resolved = pendingPermissions.get(action.requestId); + pendingPermissions.delete(action.requestId); + let toolCalls: ReadonlyMap = state.activeTurn.toolCalls; + if (resolved?.toolCallId) { + const toolCall = toolCalls.get(resolved.toolCallId); + if (toolCall && toolCall.status === ToolCallStatus.PendingPermission) { + const mutable = new Map(toolCalls); + mutable.set(resolved.toolCallId, { + ...toolCall, + status: action.approved ? ToolCallStatus.Running : ToolCallStatus.Cancelled, + confirmed: action.approved ? 'user-action' : 'denied', + cancellationReason: action.approved ? undefined : 'denied', + }); + toolCalls = mutable; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + case 'session/turnComplete': { + return finalizeTurn(state, action.turnId, TurnState.Complete); + } + case 'session/turnCancelled': { + return finalizeTurn(state, action.turnId, TurnState.Cancelled); + } + case 'session/error': { + return finalizeTurn(state, action.turnId, TurnState.Error, action.error); + } + case 'session/titleChanged': { + return { + ...state, + summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, + }; + } + case 'session/modelChanged': { + return { + ...state, + summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, + }; + } + case 'session/usage': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + usage: action.usage, + }, + }; + } + case 'session/reasoning': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + reasoning: state.activeTurn.reasoning + action.content, + }, + }; + } + } +} + +// ---- Helpers ---------------------------------------------------------------- + +/** + * Moves the active turn into the completed turns array and clears `activeTurn`. + */ +function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState, error?: IErrorInfo): ISessionState { + if (!state.activeTurn || state.activeTurn.id !== turnId) { + return state; + } + const active = state.activeTurn; + + const completedToolCalls: ICompletedToolCall[] = []; + for (const tc of active.toolCalls.values()) { + completedToolCalls.push({ + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + invocationMessage: tc.invocationMessage, + success: tc.status === ToolCallStatus.Completed, + pastTenseMessage: tc.pastTenseMessage ?? tc.invocationMessage, + toolInput: tc.toolInput, + toolKind: tc.toolKind, + language: tc.language, + toolOutput: tc.toolOutput, + error: tc.error, + }); + } + + const finalizedTurn: ITurn = { + id: active.id, + userMessage: active.userMessage, + responseText: active.streamingText, + responseParts: active.responseParts, + toolCalls: completedToolCalls, + usage: active.usage, + state: turnState, + error, + }; + + return { + ...state, + turns: [...state.turns, finalizedTurn], + activeTurn: undefined, + summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() }, + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts new file mode 100644 index 00000000000..7ef6d763454 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -0,0 +1,287 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Immutable state types for the sessions process protocol. +// See protocol.md for the full design rationale. +// +// These types represent the server-authoritative state tree. Both the server +// and clients use the same types — clients hold a local copy that they keep +// in sync via actions from the server. + +import { URI } from '../../../../base/common/uri.js'; +import type { AgentProvider } from '../agentService.js'; + +// ---- Well-known URIs -------------------------------------------------------- + +/** URI for the root state subscription. */ +export const ROOT_STATE_URI = URI.from({ scheme: 'agenthost', path: '/root' }); + +// ---- Lightweight session metadata ------------------------------------------- + +export const enum SessionStatus { + Idle = 'idle', + InProgress = 'in-progress', + Error = 'error', +} + +/** + * Lightweight session summary used in the session list and as embedded + * metadata within a subscribed session. Identified by a URI. + */ +export interface ISessionSummary { + readonly resource: URI; + readonly provider: AgentProvider; + readonly title: string; + readonly status: SessionStatus; + readonly createdAt: number; + readonly modifiedAt: number; + readonly model?: string; +} + +// ---- Model info ------------------------------------------------------------- + +export interface ISessionModelInfo { + readonly id: string; + readonly provider: AgentProvider; + readonly name: string; + readonly maxContextWindow?: number; + readonly supportsVision?: boolean; + readonly policyState?: 'enabled' | 'disabled' | 'unconfigured'; +} + +// ---- Root state (subscribable at ROOT_STATE_URI) ---------------------------- + +/** + * Global state shared with every client subscribed to {@link ROOT_STATE_URI}. + * Does **not** contain the session list — that is fetched imperatively via + * `listSessions()` RPC. See protocol.md -> Session list. + */ +export interface IRootState { + readonly agents: readonly IAgentInfo[]; +} + +export interface IAgentInfo { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + readonly models: readonly ISessionModelInfo[]; +} + +// ---- Session lifecycle ------------------------------------------------------ + +export const enum SessionLifecycle { + /** The server is asynchronously initializing the agent backend. */ + Creating = 'creating', + /** The session is ready for use. */ + Ready = 'ready', + /** Backend initialization failed. See {@link ISessionState.creationError}. */ + CreationFailed = 'creationFailed', +} + +// ---- Per-session state (subscribable at session URI) ------------------------ + +/** + * Full state for a single session, loaded when a client subscribes to + * the session's URI. + */ +export interface ISessionState { + readonly summary: ISessionSummary; + readonly lifecycle: SessionLifecycle; + readonly creationError?: IErrorInfo; + readonly turns: readonly ITurn[]; + readonly activeTurn: IActiveTurn | undefined; +} + +// ---- Turn types ------------------------------------------------------------- + +export interface IUserMessage { + readonly text: string; + readonly attachments?: readonly IMessageAttachment[]; +} + +export interface IMessageAttachment { + readonly type: 'file' | 'directory' | 'selection'; + readonly path: string; + readonly displayName?: string; +} + +/** + * A completed request/response cycle. + */ +export interface ITurn { + readonly id: string; + readonly userMessage: IUserMessage; + /** The final assistant response text (captured from streamingText on turn completion). */ + readonly responseText: string; + readonly responseParts: readonly IResponsePart[]; + readonly toolCalls: readonly ICompletedToolCall[]; + readonly usage: IUsageInfo | undefined; + readonly state: TurnState; + /** Error info if the turn ended with {@link TurnState.Error}. */ + readonly error?: IErrorInfo; +} + +export const enum TurnState { + Complete = 'complete', + Cancelled = 'cancelled', + Error = 'error', +} + +/** + * An in-progress turn — the assistant is actively streaming a response. + */ +export interface IActiveTurn { + readonly id: string; + readonly userMessage: IUserMessage; + readonly streamingText: string; + readonly responseParts: readonly IResponsePart[]; + readonly toolCalls: ReadonlyMap; + readonly pendingPermissions: ReadonlyMap; + readonly reasoning: string; + readonly usage: IUsageInfo | undefined; +} + +// ---- Response parts --------------------------------------------------------- + +export const enum ResponsePartKind { + Markdown = 'markdown', + ContentRef = 'contentRef', +} + +export interface IMarkdownResponsePart { + readonly kind: ResponsePartKind.Markdown; + readonly content: string; +} + +/** + * A reference to large content stored outside the state tree. + * The client fetches the content separately via fetchContent(). + */ +export interface IContentRef { + readonly kind: ResponsePartKind.ContentRef; + readonly uri: string; + readonly sizeHint?: number; + readonly mimeType?: string; +} + +export type IResponsePart = IMarkdownResponsePart | IContentRef; + +// ---- Tool calls ------------------------------------------------------------- + +export const enum ToolCallStatus { + /** Tool is actively executing. */ + Running = 'running', + /** Waiting for user to approve before execution. */ + PendingPermission = 'pending-permission', + /** Tool finished successfully. */ + Completed = 'completed', + /** Tool failed with an error. */ + Failed = 'failed', + /** Tool was denied or skipped by the user. */ + Cancelled = 'cancelled', +} + +/** + * Represents the full lifecycle state of a tool invocation within an active turn. + * Modeled after {@link IChatToolInvocation.State} to enable direct mapping to the chat UI. + */ +export interface IToolCallState { + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: string; + readonly toolInput?: string; + readonly toolKind?: 'terminal'; + readonly language?: string; + readonly toolArguments?: string; + readonly status: ToolCallStatus; + /** Parsed tool parameters (from toolArguments). */ + readonly parameters?: unknown; + /** How the tool was confirmed before execution (set after PendingPermission → Running). */ + readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped'; + /** Set when status transitions to Completed or Failed. */ + readonly pastTenseMessage?: string; + /** Set when status transitions to Completed or Failed. */ + readonly toolOutput?: string; + /** Set when status transitions to Failed. */ + readonly error?: { readonly message: string; readonly code?: string }; + /** Why the tool was cancelled (set when status is Cancelled). */ + readonly cancellationReason?: 'denied' | 'skipped'; +} + +export interface ICompletedToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: string; + readonly success: boolean; + readonly pastTenseMessage: string; + readonly toolInput?: string; + readonly toolKind?: 'terminal'; + readonly language?: string; + readonly toolOutput?: string; + readonly error?: { readonly message: string; readonly code?: string }; +} + +// ---- Permission requests ---------------------------------------------------- + +export interface IPermissionRequest { + readonly requestId: string; + readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; + readonly toolCallId?: string; + readonly path?: string; + readonly fullCommandText?: string; + readonly intention?: string; + readonly serverName?: string; + readonly toolName?: string; + readonly rawRequest?: string; +} + +// ---- Usage info ------------------------------------------------------------- + +export interface IUsageInfo { + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly model?: string; + readonly cacheReadTokens?: number; +} + +// ---- Error info ------------------------------------------------------------- + +export interface IErrorInfo { + readonly errorType: string; + readonly message: string; + readonly stack?: string; +} + +// ---- Factory helpers -------------------------------------------------------- + +export function createRootState(): IRootState { + return { + agents: [], + }; +} + +export function createSessionState(summary: ISessionSummary): ISessionState { + return { + summary, + lifecycle: SessionLifecycle.Creating, + turns: [], + activeTurn: undefined, + }; +} + +export function createActiveTurn(id: string, userMessage: IUserMessage): IActiveTurn { + return { + id, + userMessage, + streamingText: '', + responseParts: [], + toolCalls: new Map(), + pendingPermissions: new Map(), + reasoning: '', + usage: undefined, + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts new file mode 100644 index 00000000000..a876f59de88 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Transport abstraction for the sessions process protocol. +// See protocol.md -> Client-server protocol for the full design. +// +// The transport is pluggable — the same protocol runs over MessagePort +// (ProxyChannel), WebSocket, or stdio. This module defines the contract; +// concrete implementations live in platform-specific folders. + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import type { IProtocolMessage } from './sessionProtocol.js'; + +/** + * A bidirectional transport for protocol messages. Implementations handle + * serialization, framing, and connection management. + */ +export interface IProtocolTransport extends IDisposable { + /** Fires when a message is received from the remote end. */ + readonly onMessage: Event; + + /** Fires when the transport connection closes. */ + readonly onClose: Event; + + /** Send a message to the remote end. */ + send(message: IProtocolMessage): void; +} + +/** + * Server-side transport that accepts multiple client connections. + * Each connected client gets its own {@link IProtocolTransport}. + */ +export interface IProtocolServer extends IDisposable { + /** Fires when a new client connects. */ + readonly onConnection: Event; + + /** The port or address the server is listening on. */ + readonly address: string | undefined; +} diff --git a/src/vs/platform/agentHost/common/state/versions/v1.ts b/src/vs/platform/agentHost/common/state/versions/v1.ts new file mode 100644 index 00000000000..78a66215c64 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/versions/v1.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol version 1 wire types — the current tip. +// See ../AGENTS.md for modification instructions. +// +// While this is the tip (PROTOCOL_VERSION === 1), you may add optional +// fields freely. When PROTOCOL_VERSION is bumped, this file freezes and +// a new tip is created. Delete when MIN_PROTOCOL_VERSION passes 1. + +import type { URI } from '../../../../../base/common/uri.js'; +import type { AgentProvider } from '../../agentService.js'; + +// ---- State types (wire format) ---------------------------------------------- + +export interface IV1_RootState { + readonly agents: readonly IV1_AgentInfo[]; +} + +export interface IV1_AgentInfo { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + readonly models: readonly IV1_SessionModelInfo[]; +} + +export interface IV1_SessionModelInfo { + readonly id: string; + readonly provider: AgentProvider; + readonly name: string; + readonly maxContextWindow?: number; + readonly supportsVision?: boolean; + readonly policyState?: 'enabled' | 'disabled' | 'unconfigured'; +} + +export interface IV1_SessionSummary { + readonly resource: URI; + readonly provider: AgentProvider; + readonly title: string; + readonly status: 'idle' | 'in-progress' | 'error'; + readonly createdAt: number; + readonly modifiedAt: number; + readonly model?: string; +} + +export interface IV1_SessionState { + readonly summary: IV1_SessionSummary; + readonly lifecycle: 'creating' | 'ready' | 'creationFailed'; + readonly creationError?: IV1_ErrorInfo; + readonly turns: readonly IV1_Turn[]; + readonly activeTurn: IV1_ActiveTurn | undefined; +} + +export interface IV1_UserMessage { + readonly text: string; + readonly attachments?: readonly IV1_MessageAttachment[]; +} + +export interface IV1_MessageAttachment { + readonly type: 'file' | 'directory' | 'selection'; + readonly path: string; + readonly displayName?: string; +} + +export interface IV1_Turn { + readonly id: string; + readonly userMessage: IV1_UserMessage; + readonly responseText: string; + readonly responseParts: readonly IV1_ResponsePart[]; + readonly toolCalls: readonly IV1_CompletedToolCall[]; + readonly usage: IV1_UsageInfo | undefined; + readonly state: 'complete' | 'cancelled' | 'error'; + readonly error?: IV1_ErrorInfo; +} + +export interface IV1_ActiveTurn { + readonly id: string; + readonly userMessage: IV1_UserMessage; + readonly streamingText: string; + readonly responseParts: readonly IV1_ResponsePart[]; + readonly toolCalls: ReadonlyMap; + readonly pendingPermissions: ReadonlyMap; + readonly reasoning: string; + readonly usage: IV1_UsageInfo | undefined; +} + +export interface IV1_MarkdownResponsePart { + readonly kind: 'markdown'; + readonly content: string; +} + +export interface IV1_ContentRef { + readonly kind: 'contentRef'; + readonly uri: string; + readonly sizeHint?: number; + readonly mimeType?: string; +} + +export type IV1_ResponsePart = IV1_MarkdownResponsePart | IV1_ContentRef; + +export interface IV1_ToolCallState { + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: string; + readonly toolInput?: string; + readonly toolKind?: 'terminal'; + readonly language?: string; + readonly toolArguments?: string; + readonly status: 'running' | 'pending-permission' | 'completed' | 'failed' | 'cancelled'; + readonly parameters?: unknown; + readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped'; + readonly pastTenseMessage?: string; + readonly toolOutput?: string; + readonly error?: { readonly message: string; readonly code?: string }; + readonly cancellationReason?: 'denied' | 'skipped'; +} + +export interface IV1_CompletedToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: string; + readonly success: boolean; + readonly pastTenseMessage: string; + readonly toolInput?: string; + readonly toolKind?: 'terminal'; + readonly language?: string; + readonly toolOutput?: string; + readonly error?: { readonly message: string; readonly code?: string }; +} + +export interface IV1_PermissionRequest { + readonly requestId: string; + readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; + readonly toolCallId?: string; + readonly path?: string; + readonly fullCommandText?: string; + readonly intention?: string; + readonly serverName?: string; + readonly toolName?: string; + readonly rawRequest?: string; +} + +export interface IV1_UsageInfo { + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly model?: string; + readonly cacheReadTokens?: number; +} + +export interface IV1_ErrorInfo { + readonly errorType: string; + readonly message: string; + readonly stack?: string; +} + +// ---- Action types (wire format) --------------------------------------------- + +interface IV1_SessionActionBase { + readonly session: URI; +} + +export interface IV1_AgentsChangedAction { + readonly type: 'root/agentsChanged'; + readonly agents: readonly IV1_AgentInfo[]; +} + +export interface IV1_SessionReadyAction extends IV1_SessionActionBase { + readonly type: 'session/ready'; +} + +export interface IV1_SessionCreationFailedAction extends IV1_SessionActionBase { + readonly type: 'session/creationFailed'; + readonly error: IV1_ErrorInfo; +} + +export interface IV1_TurnStartedAction extends IV1_SessionActionBase { + readonly type: 'session/turnStarted'; + readonly turnId: string; + readonly userMessage: IV1_UserMessage; +} + +export interface IV1_DeltaAction extends IV1_SessionActionBase { + readonly type: 'session/delta'; + readonly turnId: string; + readonly content: string; +} + +export interface IV1_ResponsePartAction extends IV1_SessionActionBase { + readonly type: 'session/responsePart'; + readonly turnId: string; + readonly part: IV1_ResponsePart; +} + +export interface IV1_ToolStartAction extends IV1_SessionActionBase { + readonly type: 'session/toolStart'; + readonly turnId: string; + readonly toolCall: IV1_ToolCallState; +} + +export interface IV1_ToolCompleteAction extends IV1_SessionActionBase { + readonly type: 'session/toolComplete'; + readonly turnId: string; + readonly toolCallId: string; + readonly result: IV1_ToolCompleteResult; +} + +export interface IV1_ToolCompleteResult { + readonly success: boolean; + readonly pastTenseMessage: string; + readonly toolOutput?: string; + readonly error?: { readonly message: string; readonly code?: string }; +} + +export interface IV1_PermissionRequestAction extends IV1_SessionActionBase { + readonly type: 'session/permissionRequest'; + readonly turnId: string; + readonly request: IV1_PermissionRequest; +} + +export interface IV1_PermissionResolvedAction extends IV1_SessionActionBase { + readonly type: 'session/permissionResolved'; + readonly turnId: string; + readonly requestId: string; + readonly approved: boolean; +} + +export interface IV1_TurnCompleteAction extends IV1_SessionActionBase { + readonly type: 'session/turnComplete'; + readonly turnId: string; +} + +export interface IV1_TurnCancelledAction extends IV1_SessionActionBase { + readonly type: 'session/turnCancelled'; + readonly turnId: string; +} + +export interface IV1_SessionErrorAction extends IV1_SessionActionBase { + readonly type: 'session/error'; + readonly turnId: string; + readonly error: IV1_ErrorInfo; +} + +export interface IV1_TitleChangedAction extends IV1_SessionActionBase { + readonly type: 'session/titleChanged'; + readonly title: string; +} + +export interface IV1_UsageAction extends IV1_SessionActionBase { + readonly type: 'session/usage'; + readonly turnId: string; + readonly usage: IV1_UsageInfo; +} + +export interface IV1_ReasoningAction extends IV1_SessionActionBase { + readonly type: 'session/reasoning'; + readonly turnId: string; + readonly content: string; +} + +export interface IV1_ModelChangedAction extends IV1_SessionActionBase { + readonly type: 'session/modelChanged'; + readonly model: string; +} + +export type IV1_RootAction = + | IV1_AgentsChangedAction; + +export type IV1_SessionAction = + | IV1_SessionReadyAction + | IV1_SessionCreationFailedAction + | IV1_TurnStartedAction + | IV1_DeltaAction + | IV1_ResponsePartAction + | IV1_ToolStartAction + | IV1_ToolCompleteAction + | IV1_PermissionRequestAction + | IV1_PermissionResolvedAction + | IV1_TurnCompleteAction + | IV1_TurnCancelledAction + | IV1_SessionErrorAction + | IV1_TitleChangedAction + | IV1_UsageAction + | IV1_ReasoningAction + | IV1_ModelChangedAction; + +export type IV1_StateAction = IV1_RootAction | IV1_SessionAction; + +// ---- Notification types (wire format) --------------------------------------- + +export interface IV1_SessionAddedNotification { + readonly type: 'notify/sessionAdded'; + readonly summary: IV1_SessionSummary; +} + +export interface IV1_SessionRemovedNotification { + readonly type: 'notify/sessionRemoved'; + readonly session: URI; +} + +export type IV1_Notification = + | IV1_SessionAddedNotification + | IV1_SessionRemovedNotification; + +/** All action type strings known to v1. */ +export type IV1_ActionType = IV1_StateAction['type']; diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts new file mode 100644 index 00000000000..c650d977a9a --- /dev/null +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Version registry: compile-time compatibility checks + runtime action filtering. +// See ../AGENTS.md for modification instructions. + +import type { + IAgentsChangedAction, + IDeltaAction, + IModelChangedAction, + INotification, + IPermissionRequestAction, + IPermissionResolvedAction, + IReasoningAction, + IResponsePartAction, + IRootAction, + ISessionAction, + ISessionCreationFailedAction, + ISessionErrorAction, + ISessionReadyAction, + IStateAction, + ITitleChangedAction, + IToolCompleteAction, + IToolStartAction, + ITurnCancelledAction, + ITurnCompleteAction, + ITurnStartedAction, + IUsageAction, +} from '../sessionActions.js'; +import type { + IActiveTurn, + IAgentInfo, + ICompletedToolCall, + IContentRef, + IErrorInfo, + IMarkdownResponsePart, + IMessageAttachment, + IPermissionRequest, + IRootState, + ISessionModelInfo, + ISessionState, + ISessionSummary, + IToolCallState, + ITurn, + IUsageInfo, + IUserMessage, +} from '../sessionState.js'; + +import type { + IV1_ActiveTurn, + IV1_AgentInfo, + IV1_AgentsChangedAction, + IV1_CompletedToolCall, + IV1_ContentRef, + IV1_DeltaAction, + IV1_ErrorInfo, + IV1_MarkdownResponsePart, + IV1_MessageAttachment, + IV1_ModelChangedAction, + IV1_PermissionRequest, + IV1_PermissionRequestAction, + IV1_PermissionResolvedAction, + IV1_ReasoningAction, + IV1_ResponsePartAction, + IV1_RootState, + IV1_SessionCreationFailedAction, + IV1_SessionErrorAction, + IV1_SessionModelInfo, + IV1_SessionReadyAction, + IV1_SessionState, + IV1_SessionSummary, + IV1_TitleChangedAction, + IV1_ToolCallState, + IV1_ToolCompleteAction, + IV1_ToolStartAction, + IV1_Turn, + IV1_TurnCancelledAction, + IV1_TurnCompleteAction, + IV1_TurnStartedAction, + IV1_UsageAction, + IV1_UsageInfo, + IV1_UserMessage, +} from './v1.js'; + +// ---- Protocol version constants --------------------------------------------- + +/** + * Current protocol version. This is the version that NEW code speaks. + * Increment when adding new action types or changing behavior. + * + * Version history: + * 1 — Initial: root state, session lifecycle, streaming, tools, permissions + */ +export const PROTOCOL_VERSION = 1; + +/** + * Minimum protocol version we maintain backward compatibility with. + * Raise this to drop old compat code: delete the version file, + * remove its checks below, and the compiler shows what's now dead. + */ +export const MIN_PROTOCOL_VERSION = 1; + +// ---- Compile-time compatibility checks -------------------------------------- +// +// AssertCompatible requires BIDIRECTIONAL assignability: +// - Current extends Frozen: can't remove fields or change field types +// - Frozen extends Current: can't add required fields +// +// The only allowed change is adding optional fields to the living type. +// If either direction fails, you get a compile error at the check site. + +type AssertCompatible = Frozen extends Current ? true : never; + +// -- v1 state compatibility -- + +type _v1_RootState = AssertCompatible; +type _v1_AgentInfo = AssertCompatible; +type _v1_SessionModelInfo = AssertCompatible; +type _v1_SessionSummary = AssertCompatible; +type _v1_SessionState = AssertCompatible; +type _v1_UserMessage = AssertCompatible; +type _v1_MessageAttachment = AssertCompatible; +type _v1_Turn = AssertCompatible; +type _v1_ActiveTurn = AssertCompatible; +type _v1_MarkdownResponsePart = AssertCompatible; +type _v1_ContentRef = AssertCompatible; +type _v1_ToolCallState = AssertCompatible; +type _v1_CompletedToolCall = AssertCompatible; +type _v1_PermissionRequest = AssertCompatible; +type _v1_UsageInfo = AssertCompatible; +type _v1_ErrorInfo = AssertCompatible; + +// -- v1 action compatibility -- + +type _v1_AgentsChanged = AssertCompatible; +type _v1_SessionReady = AssertCompatible; +type _v1_CreationFailed = AssertCompatible; +type _v1_TurnStarted = AssertCompatible; +type _v1_Delta = AssertCompatible; +type _v1_ResponsePart = AssertCompatible; +type _v1_ToolStart = AssertCompatible; +type _v1_ToolComplete = AssertCompatible; +type _v1_PermissionRequestAction = AssertCompatible; +type _v1_PermissionResolved = AssertCompatible; +type _v1_TurnComplete = AssertCompatible; +type _v1_TurnCancelled = AssertCompatible; +type _v1_SessionError = AssertCompatible; +type _v1_TitleChanged = AssertCompatible; +type _v1_Usage = AssertCompatible; +type _v1_Reasoning = AssertCompatible; +type _v1_ModelChanged = AssertCompatible; + +// Suppress unused-variable warnings for compile-time-only checks. +void (0 as unknown as + _v1_RootState & _v1_AgentInfo & _v1_SessionModelInfo & _v1_SessionSummary & + _v1_SessionState & _v1_UserMessage & _v1_MessageAttachment & _v1_Turn & + _v1_ActiveTurn & _v1_MarkdownResponsePart & _v1_ContentRef & + _v1_ToolCallState & _v1_CompletedToolCall & _v1_PermissionRequest & + _v1_UsageInfo & _v1_ErrorInfo & + _v1_AgentsChanged & _v1_SessionReady & _v1_CreationFailed & + _v1_TurnStarted & _v1_Delta & _v1_ResponsePart & _v1_ToolStart & + _v1_ToolComplete & _v1_PermissionRequestAction & _v1_PermissionResolved & + _v1_TurnComplete & _v1_TurnCancelled & _v1_SessionError & _v1_TitleChanged & + _v1_Usage & _v1_Reasoning & _v1_ModelChanged +); + +// ---- Runtime action → version map ------------------------------------------- +// +// The index signature [K in IStateAction['type']] forces TypeScript to require +// an entry for every action type in the union. If you add a new action type +// to ISessionAction or IRootAction but forget to register it here, you get +// a compile error. +// +// The value is the protocol version that introduced that action type. + +/** Maps every action type string to the protocol version that introduced it. */ +export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { + // Root actions (v1) + 'root/agentsChanged': 1, + // Session lifecycle (v1) + 'session/ready': 1, + 'session/creationFailed': 1, + // Turn lifecycle (v1) + 'session/turnStarted': 1, + 'session/delta': 1, + 'session/responsePart': 1, + // Tool calls (v1) + 'session/toolStart': 1, + 'session/toolComplete': 1, + // Permissions (v1) + 'session/permissionRequest': 1, + 'session/permissionResolved': 1, + // Turn completion (v1) + 'session/turnComplete': 1, + 'session/turnCancelled': 1, + 'session/error': 1, + // Metadata & informational (v1) + 'session/titleChanged': 1, + 'session/usage': 1, + 'session/reasoning': 1, + 'session/modelChanged': 1, +}; + +/** Maps every notification type string to the protocol version that introduced it. */ +export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { + 'notify/sessionAdded': 1, + 'notify/sessionRemoved': 1, +}; + +// ---- Runtime filtering helpers ---------------------------------------------- + +/** + * Returns `true` if the given action type is known to a client at `clientVersion`. + * The server uses this to avoid sending actions that the client can't process. + */ +export function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { + return ACTION_INTRODUCED_IN[action.type] <= clientVersion; +} + +/** + * Returns `true` if the given notification type is known to a client at `clientVersion`. + */ +export function isNotificationKnownToVersion(notification: INotification, clientVersion: number): boolean { + return NOTIFICATION_INTRODUCED_IN[notification.type] <= clientVersion; +} + +// ---- Version-grouped action types ------------------------------------------- +// +// Each version defines the set of action types it introduced. The cumulative +// union for a version is built by combining all versions up to that point. +// When you add a new protocol version, define its additions and extend the map. + +/** Action types introduced in v1. */ +type IRootAction_v1 = IV1_AgentsChangedAction; +type ISessionAction_v1 = IV1_SessionReadyAction | IV1_SessionCreationFailedAction + | IV1_TurnStartedAction | IV1_DeltaAction | IV1_ResponsePartAction + | IV1_ToolStartAction | IV1_ToolCompleteAction + | IV1_PermissionRequestAction | IV1_PermissionResolvedAction + | IV1_TurnCompleteAction | IV1_TurnCancelledAction | IV1_SessionErrorAction + | IV1_TitleChangedAction | IV1_UsageAction | IV1_ReasoningAction + | IV1_ModelChangedAction; + +/** + * Maps protocol versions to their cumulative action type unions. + * Used to type-check that existing version unions remain stable. + */ +export interface IVersionedActionMap { + 1: { root: IRootAction_v1; session: ISessionAction_v1 }; +} + +// Ensure the living union is a superset of every versioned union. +// If you remove an action type from the living union that a version +// still references, this fails to compile. +type _rootSuperset = IRootAction_v1 extends IRootAction ? true : never; +type _sessionSuperset = ISessionAction_v1 extends ISessionAction ? true : never; + +void (0 as unknown as _rootSuperset & _sessionSuperset); diff --git a/src/vs/platform/agentHost/design.md b/src/vs/platform/agentHost/design.md new file mode 100644 index 00000000000..9834fe8e44e --- /dev/null +++ b/src/vs/platform/agentHost/design.md @@ -0,0 +1,86 @@ +# Agent host design decisions + +> **Keep this document in sync with the code.** Any change to the agent-host protocol, tool rendering approach, or architectural boundaries must be reflected here. If you add a new `toolKind`, change how tool-specific data is populated, or modify the separation between agent-specific and generic code, update this document as part of the same change. + +Design decisions and principles for the agent-host feature. For process architecture and IPC details, see [architecture.md](architecture.md). For the client-server state protocol, see [protocol.md](protocol.md). + +## Agent-agnostic protocol + +**The protocol between the agent-host process and clients must remain agent-agnostic.** This is a hard rule. + +There are two protocol layers: + +1. **`IAgent` interface** (`common/agentService.ts`) - the internal interface that each agent backend (CopilotAgent, MockAgent) implements. It fires `IAgentProgressEvent`s (raw SDK events: `delta`, `tool_start`, `tool_complete`, etc.). This layer is agent-specific. + +2. **Sessions state protocol** (`common/state/`) - the client-facing protocol. The server maps raw `IAgentProgressEvent`s into state actions (`session/delta`, `session/toolStart`, etc.) via `agentEventMapper.ts`. Clients receive immutable state snapshots and action streams via JSON-RPC over WebSocket or MessagePort. **This layer is agent-agnostic.** + +All agent-specific logic -- translating tool names like `bash`/`view`/`grep` into display strings, extracting command lines from tool parameters, determining rendering hints like `toolKind: 'terminal'` -- lives in `copilotToolDisplay.ts` inside the agent-host process. These display-ready fields are carried on `IAgentToolStartEvent`/`IAgentToolCompleteEvent`, which `agentEventMapper.ts` then maps into `session/toolStart` and `session/toolComplete` state actions. + +Clients (renderers) never see agent-specific tool names. They consume `IToolCallState` and `ICompletedToolCall` from the session state tree, which carry generic display-ready fields (`displayName`, `invocationMessage`, `toolKind`, etc.). + +## Provider-agnostic renderer contributions + +The renderer contributions (`AgentHostSessionHandler`, `AgentHostSessionListController`, `AgentHostLanguageModelProvider`) are **completely generic**. They receive all provider-specific details via `IAgentHostSessionHandlerConfig`: + +```typescript +interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; // 'copilot' + readonly agentId: string; // e.g. 'agent-host' + readonly sessionType: string; // e.g. 'agent-host' + readonly fullName: string; // e.g. 'Agent Host - Copilot' + readonly description: string; +} +``` + +A single `AgentHostContribution` discovers agents via `listAgents()` and dynamically registers each one. Adding a new provider means adding a new `IAgent` implementation in the server process. No changes needed to the handler, list controller, or model provider. + +## State-based rendering + +The renderer subscribes to session state via `SessionClientState` (write-ahead reconciliation) and converts immutable state changes to `IChatProgress[]` via `stateToProgressAdapter.ts`. This adapter is the only place that inspects protocol state fields like `toolKind`: + +- **Shell commands** (`toolKind: 'terminal'`): Converted to `IChatTerminalToolInvocationData` with the command in a syntax-highlighted code block, output displayed below, and exit code for success/failure styling. +- **Everything else**: Converted to `ChatToolInvocation` using `invocationMessage` (while running) and `pastTenseMessage` (when complete). + +The adapter never checks tool names - it operates purely on the generic state fields. + +## Copilot SDK tool name mapping + +The Copilot CLI uses built-in tools. Tool names and parameter shapes are not typed in the SDK (`toolName` is `string`) - they come from the CLI server. The interfaces in `copilotToolDisplay.ts` are derived from observing actual CLI events. + +| SDK tool name | Display name | Rendering | +|---|---|---| +| `bash` | Bash | Terminal (`toolKind: 'terminal'`, language `shellscript`) | +| `powershell` | PowerShell | Terminal (`toolKind: 'terminal'`, language `powershell`) | +| `view` | View File | Progress message | +| `edit` | Edit File | Progress message | +| `write` | Write File | Progress message | +| `grep` | Search | Progress message | +| `glob` | Find Files | Progress message | +| `web_search` | Web Search | Progress message | + +This mapping lives in `copilotToolDisplay.ts` and is the only place that knows about Copilot-specific tool names. + +## Model ownership + +The SDK makes its own LM requests using the GitHub token. VS Code does not make direct LM calls for agent-host sessions. + +Each agent's models are published to root state via the `root/agentsChanged` action. The renderer's `AgentHostLanguageModelProvider` exposes these in the model picker. The selected model ID is passed to `createSession({ model })`. The `sendChatRequest` method throws - agent-host models aren't usable for direct LM calls, only for the agent loop. + +## Setting gate + +The entire feature is controlled by `chat.agentHost.enabled` (default `false`), defined as `AgentHostEnabledSettingId` in `agentService.ts`. When disabled: +- The main process does not spawn the agent host utility process +- The renderer does not connect via MessagePort +- No agents, sessions, or model providers are registered +- No agent-host entries appear in the UI + +## Multi-client state synchronization + +The sessions process uses a redux-like state model where all mutations flow through a discriminated union of actions processed by pure reducer functions. This design supports multiple connected clients seeing a synchronized view: + +- **Server-authoritative state**: The server holds the canonical state tree. Clients receive snapshots and incremental actions. +- **Write-ahead with reconciliation**: Clients optimistically apply their own actions locally (e.g., approving a permission, sending a message) and reconcile when the server echoes them back. Actions carry `(clientId, clientSeq)` tags for echo matching. +- **Lazy loading**: Clients connect with lightweight session metadata (enough for a sidebar list) and subscribe to full session state on demand. Large content (images, tool outputs) uses `ContentRef` placeholders fetched separately. +- **Forward-compatible versioning**: A single protocol version number maps to a `ProtocolCapabilities` object. Newer clients check capabilities before using features unavailable on older servers. + +Details and type definitions are in [protocol.md](protocol.md) and `common/state/`. diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts new file mode 100644 index 00000000000..ae16987a056 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; +import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js'; +import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { revive } from '../../../base/common/marshalling.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Renderer-side implementation of {@link IAgentHostService} that connects + * directly to the agent host utility process via MessagePort, bypassing + * the main process relay. Uses the same `getDelayedChannel` pattern as + * the pty host so the proxy is usable immediately while the port is acquired. + */ +class AgentHostServiceClient extends Disposable implements IAgentHostService { + declare readonly _serviceBrand: undefined; + + /** Unique identifier for this window, used in action envelope origin tracking. */ + readonly clientId = generateUuid(); + + private readonly _clientEventually = new DeferredPromise(); + private readonly _proxy: IAgentService; + + private readonly _onAgentHostExit = this._register(new Emitter()); + readonly onAgentHostExit = this._onAgentHostExit.event; + private readonly _onAgentHostStart = this._register(new Emitter()); + readonly onAgentHostStart = this._onAgentHostStart.event; + + private readonly _onDidAction = this._register(new Emitter()); + readonly onDidAction = this._onDidAction.event; + + private readonly _onDidNotification = this._register(new Emitter()); + readonly onDidNotification = this._onDidNotification.event; + + constructor( + @ILogService private readonly _logService: ILogService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + // Create a proxy backed by a delayed channel - usable immediately, + // calls queue until the MessagePort connection is established. + this._proxy = ProxyChannel.toService( + getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.AgentHost))) + ); + + if (configurationService.getValue(AgentHostEnabledSettingId)) { + this._connect(); + } + } + + private async _connect(): Promise { + this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); + const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); + this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); + + const store = this._register(new DisposableStore()); + const client = store.add(new MessagePortClient(port, `agentHost:window`)); + this._clientEventually.complete(client); + + store.add(this._proxy.onDidAction(e => { + this._onDidAction.fire(revive(e)); + })); + store.add(this._proxy.onDidNotification(e => { + this._onDidNotification.fire(revive(e)); + })); + this._logService.info('[AgentHost:renderer] Direct MessagePort connection established'); + this._onAgentHostStart.fire(); + } + + // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- + + setAuthToken(token: string): Promise { + return this._proxy.setAuthToken(token); + } + listAgents(): Promise { + return this._proxy.listAgents(); + } + refreshModels(): Promise { + return this._proxy.refreshModels(); + } + listSessions(): Promise { + return this._proxy.listSessions(); + } + createSession(config?: IAgentCreateSessionConfig): Promise { + return this._proxy.createSession(config); + } + disposeSession(session: URI): Promise { + return this._proxy.disposeSession(session); + } + shutdown(): Promise { + return this._proxy.shutdown(); + } + subscribe(resource: URI): Promise { + return this._proxy.subscribe(resource); + } + unsubscribe(resource: URI): void { + this._proxy.unsubscribe(resource); + } + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this._proxy.dispatchAction(action, clientId, clientSeq); + } + async restartAgentHost(): Promise { + // Restart is handled by the main process side + } +} + +registerSingleton(IAgentHostService, AgentHostServiceClient, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts new file mode 100644 index 00000000000..0af8235aeba --- /dev/null +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { deepClone } from '../../../base/common/objects.js'; +import { IpcMainEvent } from 'electron'; +import { validatedIpcMain } from '../../../base/parts/ipc/electron-main/ipcMain.js'; +import { Client as MessagePortClient } from '../../../base/parts/ipc/electron-main/ipc.mp.js'; +import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; +import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; +import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; +import { ILogService } from '../../log/common/log.js'; +import { Schemas } from '../../../base/common/network.js'; +import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; +import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; + +export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { + + private utilityProcess: UtilityProcess | undefined = undefined; + + private readonly _onRequestConnection = this._register(new Emitter()); + readonly onRequestConnection = this._onRequestConnection.event; + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown = this._onWillShutdown.event; + + constructor( + @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, + @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._lifecycleMainService.onWillShutdown(() => this._onWillShutdown.fire())); + + // Listen for new windows to establish a direct MessagePort connection to the agent host + const onWindowConnection = (e: IpcMainEvent, nonce: string) => this._onWindowConnection(e, nonce); + validatedIpcMain.on('vscode:createAgentHostMessageChannel', onWindowConnection); + this._register(toDisposable(() => { + validatedIpcMain.removeListener('vscode:createAgentHostMessageChannel', onWindowConnection); + })); + } + + start(): IAgentHostConnection { + this.utilityProcess = new UtilityProcess(this._logService, NullTelemetryService, this._lifecycleMainService); + + const inspectParams = parseAgentHostDebugPort(this._environmentMainService.args, this._environmentMainService.isBuilt); + const execArgv = inspectParams.port ? [ + '--nolazy', + `--inspect${inspectParams.break ? '-brk' : ''}=${inspectParams.port}` + ] : undefined; + + this.utilityProcess.start({ + type: 'agentHost', + name: 'agent-host', + entryPoint: 'vs/platform/agentHost/node/agentHostMain', + execArgv, + args: ['--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath], + env: { + ...deepClone(process.env), + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + } + }); + + const port = this.utilityProcess.connect(); + const client = new MessagePortClient(port, 'agentHost'); + + const store = new DisposableStore(); + store.add(client); + store.add(this.utilityProcess.onStderr(data => this._logService.error(`[AgentHost:stderr] ${data}`))); + store.add(toDisposable(() => { + this.utilityProcess?.kill(); + this.utilityProcess?.dispose(); + this.utilityProcess = undefined; + })); + + return { + client, + store, + onDidProcessExit: this.utilityProcess.onExit, + }; + } + + private _onWindowConnection(e: IpcMainEvent, nonce: string): void { + this._onRequestConnection.fire(); + + if (!this.utilityProcess) { + this._logService.error('AgentHostStarter: cannot create window connection, agent host process is not running'); + return; + } + + const port = this.utilityProcess.connect(); + + if (e.sender.isDestroyed()) { + port.close(); + return; + } + + e.sender.postMessage('vscode:createAgentHostMessageChannelResult', nonce, [port]); + } +} diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts new file mode 100644 index 00000000000..c7639b180e6 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { + IAgentProgressEvent, + IAgentToolStartEvent, + IAgentToolCompleteEvent, + IAgentPermissionRequestEvent, + IAgentErrorEvent, + IAgentReasoningEvent, + IAgentUsageEvent, + IAgentDeltaEvent, + IAgentTitleChangedEvent, +} from '../common/agentService.js'; +import type { + ISessionAction, + IDeltaAction, + IToolStartAction, + IToolCompleteAction, + ITurnCompleteAction, + ISessionErrorAction, + IUsageAction, + ITitleChangedAction, + IPermissionRequestAction, + IReasoningAction, +} from '../common/state/sessionActions.js'; +import { ToolCallStatus } from '../common/state/sessionState.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Maps a flat {@link IAgentProgressEvent} from the agent host into + * a protocol {@link ISessionAction} suitable for dispatch to the reducer. + * Returns `undefined` for events that have no corresponding action. + */ +export function mapProgressEventToAction(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | undefined { + switch (event.type) { + case 'delta': + return { + type: 'session/delta', + session, + turnId, + content: (event as IAgentDeltaEvent).content, + } satisfies IDeltaAction; + + case 'tool_start': { + const e = event as IAgentToolStartEvent; + return { + type: 'session/toolStart', + session, + turnId, + toolCall: { + toolCallId: e.toolCallId, + toolName: e.toolName, + displayName: e.displayName, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + toolKind: e.toolKind, + language: e.language, + toolArguments: e.toolArguments, + status: ToolCallStatus.Running, + }, + } satisfies IToolStartAction; + } + + case 'tool_complete': { + const e = event as IAgentToolCompleteEvent; + return { + type: 'session/toolComplete', + session, + turnId, + toolCallId: e.toolCallId, + result: { + success: e.success, + pastTenseMessage: e.pastTenseMessage, + toolOutput: e.toolOutput, + error: e.error, + }, + } satisfies IToolCompleteAction; + } + + case 'idle': + return { + type: 'session/turnComplete', + session, + turnId, + } satisfies ITurnCompleteAction; + + case 'error': { + const e = event as IAgentErrorEvent; + return { + type: 'session/error', + session, + turnId, + error: { + errorType: e.errorType, + message: e.message, + stack: e.stack, + }, + } satisfies ISessionErrorAction; + } + + case 'usage': { + const e = event as IAgentUsageEvent; + return { + type: 'session/usage', + session, + turnId, + usage: { + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + model: e.model, + cacheReadTokens: e.cacheReadTokens, + }, + } satisfies IUsageAction; + } + + case 'title_changed': + return { + type: 'session/titleChanged', + session, + title: (event as IAgentTitleChangedEvent).title, + } satisfies ITitleChangedAction; + + case 'permission_request': { + const e = event as IAgentPermissionRequestEvent; + return { + type: 'session/permissionRequest', + session, + turnId, + request: { + requestId: e.requestId, + permissionKind: e.permissionKind, + toolCallId: e.toolCallId, + path: e.path, + fullCommandText: e.fullCommandText, + intention: e.intention, + serverName: e.serverName, + toolName: e.toolName, + rawRequest: e.rawRequest, + }, + } satisfies IPermissionRequestAction; + } + + case 'reasoning': + return { + type: 'session/reasoning', + session, + turnId, + content: (event as IAgentReasoningEvent).content, + } satisfies IReasoningAction; + + case 'message': + return undefined; + + default: + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts new file mode 100644 index 00000000000..bbdfdcdd578 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; +import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; +import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { AgentHostIpcChannels } from '../common/agentService.js'; +import { AgentService } from './agentService.js'; +import { CopilotAgent } from './copilot/copilotAgent.js'; +import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; +import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; +import { getLogLevel } from '../../log/common/log.js'; +import { LogService } from '../../log/common/logService.js'; +import { LoggerService } from '../../log/node/loggerService.js'; +import { LoggerChannel } from '../../log/common/logIpc.js'; +import { DefaultURITransformer } from '../../../base/common/uriIpc.js'; +import product from '../../product/common/product.js'; +import { IProductService } from '../../product/common/productService.js'; +import { localize } from '../../../nls.js'; + +// Entry point for the agent host utility process. +// Sets up IPC, logging, and registers agent providers (Copilot). + +startAgentHost(); + +function startAgentHost(): void { + // Setup RPC - supports both Electron utility process and Node child process + let server: ChildProcessServer | UtilityProcessServer; + if (isUtilityProcess(process)) { + server = new UtilityProcessServer(); + } else { + server = new ChildProcessServer(AgentHostIpcChannels.AgentHost); + } + + const disposables = new DisposableStore(); + + // Services + const productService: IProductService = { _serviceBrand: undefined, ...product }; + const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); + const loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); + server.registerChannel(AgentHostIpcChannels.Logger, new LoggerChannel(loggerService, () => DefaultURITransformer)); + const logger = loggerService.createLogger('agenthost', { name: localize('agentHost', "Agent Host") }); + const logService = new LogService(logger); + logService.info('Agent Host process started successfully'); + + // Create the real service implementation that lives in this process + let agentService: AgentService; + try { + agentService = new AgentService(logService); + agentService.registerProvider(new CopilotAgent(logService)); + } catch (err) { + logService.error('Failed to create AgentService', err); + throw err; + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); + server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + + process.once('exit', () => { + agentService.dispose(); + logService.dispose(); + disposables.dispose(); + }); +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts new file mode 100644 index 00000000000..5d5c9616ec7 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Standalone agent host server with WebSocket protocol transport. +// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] + +import { fileURLToPath } from 'url'; + +// This standalone process isn't bootstrapped via bootstrap-esm.ts, so we must +// set _VSCODE_FILE_ROOT ourselves so that FileAccess can resolve module paths. +// This file lives at out/vs/platform/agentHost/node/ - the root is `out/`. +globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); + +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { observableValue } from '../../../base/common/observable.js'; +import { localize } from '../../../nls.js'; +import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; +import { INativeEnvironmentService } from '../../environment/common/environment.js'; +import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; +import { getLogLevel, ILogService, NullLogService } from '../../log/common/log.js'; +import { LogService } from '../../log/common/logService.js'; +import { LoggerService } from '../../log/node/loggerService.js'; +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 { AgentSession, type AgentProvider, type IAgent } from '../common/agentService.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { SessionStateManager } from './sessionStateManager.js'; +import { WebSocketProtocolServer } from './webSocketTransport.js'; +import { ProtocolServerHandler } from './protocolServerHandler.js'; + +// ---- Options ---------------------------------------------------------------- + +interface IServerOptions { + readonly port: number; + readonly enableMockAgent: boolean; + readonly quiet: boolean; +} + +function parseServerOptions(): IServerOptions { + const argv = process.argv.slice(2); + const envPort = parseInt(process.env['VSCODE_AGENT_HOST_PORT'] ?? '8081', 10); + const portIdx = argv.indexOf('--port'); + const port = portIdx >= 0 ? parseInt(argv[portIdx + 1], 10) : envPort; + const enableMockAgent = argv.includes('--enable-mock-agent'); + const quiet = argv.includes('--quiet'); + return { port, enableMockAgent, quiet }; +} + +// ---- Main ------------------------------------------------------------------- + +function main(): void { + const options = parseServerOptions(); + const disposables = new DisposableStore(); + + // Services — production logging unless --quiet + let logService: ILogService; + let loggerService: LoggerService | undefined; + + if (options.quiet) { + logService = new NullLogService(); + } else { + const services = new ServiceCollection(); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + services.set(IProductService, productService); + const args = parseArgs(process.argv.slice(2), OPTIONS); + const environmentService = new NativeEnvironmentService(args, productService); + services.set(INativeEnvironmentService, environmentService); + loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); + const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); + logService = disposables.add(new LogService(logger)); + services.set(ILogService, logService); + } + + logService.info('[AgentHostServer] Starting standalone agent host server'); + + // Create state manager + const stateManager = disposables.add(new SessionStateManager(logService)); + + // Agent registry — maps provider id to agent instance + const agents = new Map(); + + // Observable agents list for root state + const registeredAgents = observableValue('agents', []); + + // Shared side-effect handler + const sideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent(session) { + const provider = AgentSession.provider(session); + return provider ? agents.get(provider) : agents.values().next().value; + }, + agents: registeredAgents, + }, logService)); + + function registerAgent(agent: IAgent): void { + agents.set(agent.id, agent); + disposables.add(sideEffects.registerProgressListener(agent)); + registeredAgents.set([...agents.values()], undefined); + logService.info(`[AgentHostServer] Registered agent: ${agent.id}`); + } + + // Register agents + if (!options.quiet) { + // Production agents (require DI) + const services = new ServiceCollection(); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + services.set(IProductService, productService); + const args = parseArgs(process.argv.slice(2), OPTIONS); + const environmentService = new NativeEnvironmentService(args, productService); + services.set(INativeEnvironmentService, environmentService); + services.set(ILogService, logService); + const instantiationService = new InstantiationService(services); + const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); + registerAgent(copilotAgent); + } + + if (options.enableMockAgent) { + // Dynamic import to avoid bundling test code in production + import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { + const mockAgent = disposables.add(new ScriptedMockAgent()); + registerAgent(mockAgent); + }).catch(err => { + logService.error('[AgentHostServer] Failed to load mock agent', err); + }); + } + + // WebSocket server + const wsServer = disposables.add(new WebSocketProtocolServer(options.port, logService)); + + // Wire up protocol handler + disposables.add(new ProtocolServerHandler(stateManager, wsServer, sideEffects, logService)); + + // Report ready + const address = wsServer.address; + if (address) { + const listeningPort = address.split(':').pop(); + process.stdout.write(`READY:${listeningPort}\n`); + logService.info(`[AgentHostServer] WebSocket server listening on ws://${address}`); + } else { + const interval = setInterval(() => { + const addr = wsServer.address; + if (addr) { + clearInterval(interval); + const listeningPort = addr.split(':').pop(); + process.stdout.write(`READY:${listeningPort}\n`); + logService.info(`[AgentHostServer] WebSocket server listening on ws://${addr}`); + } + }, 10); + } + + // Keep alive until stdin closes or signal + process.stdin.resume(); + process.stdin.on('end', shutdown); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + function shutdown(): void { + logService.info('[AgentHostServer] Shutting down...'); + disposables.dispose(); + loggerService?.dispose(); + process.exit(0); + } +} + +main(); diff --git a/src/vs/platform/agentHost/node/agentHostService.ts b/src/vs/platform/agentHost/node/agentHostService.ts new file mode 100644 index 00000000000..7175eb47a32 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostService.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService, ILoggerService } from '../../log/common/log.js'; +import { RemoteLoggerChannelClient } from '../../log/common/logIpc.js'; +import { IAgentHostStarter } from '../common/agent.js'; +import { AgentHostIpcChannels } from '../common/agentService.js'; + +enum Constants { + MaxRestarts = 5, +} + +/** + * Main-process service that manages the agent host utility process lifecycle + * (lazy start, crash recovery, logger forwarding). The renderer communicates + * with the utility process directly via MessagePort - this class does not + * relay any agent service calls. + */ +export class AgentHostProcessManager extends Disposable { + + private _started = false; + private _wasQuitRequested = false; + private _restartCount = 0; + + constructor( + private readonly _starter: IAgentHostStarter, + @ILogService private readonly _logService: ILogService, + @ILoggerService private readonly _loggerService: ILoggerService, + ) { + super(); + + this._register(this._starter); + + // Start lazily when the first window asks for a connection + if (this._starter.onRequestConnection) { + this._register(Event.once(this._starter.onRequestConnection)(() => this._ensureStarted())); + } + + if (this._starter.onWillShutdown) { + this._register(this._starter.onWillShutdown(() => this._wasQuitRequested = true)); + } + } + + private _ensureStarted(): void { + if (!this._started) { + this._start(); + } + } + + private _start(): void { + const connection = this._starter.start(); + + this._logService.info('AgentHostProcessManager: agent host started'); + + // Connect logger channel so agent host logs appear in the output channel + this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + + // Handle unexpected exit + this._register(connection.onDidProcessExit(e => { + if (!this._wasQuitRequested && !this._store.isDisposed) { + if (this._restartCount <= Constants.MaxRestarts) { + this._logService.error(`AgentHostProcessManager: agent host terminated unexpectedly with code ${e.code}`); + this._restartCount++; + this._started = false; + connection.store.dispose(); + this._start(); + } else { + this._logService.error(`AgentHostProcessManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`); + } + } + })); + + this._register(toDisposable(() => connection.store.dispose())); + this._started = true; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts new file mode 100644 index 00000000000..3530ec91d1e --- /dev/null +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; +import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** + * The agent service implementation that runs inside the agent-host utility + * process. Dispatches to registered {@link IAgent} instances based + * on the provider identifier in the session configuration. + */ +export class AgentService extends Disposable implements IAgentService { + declare readonly _serviceBrand: undefined; + + /** Protocol: fires when state is mutated by an action. */ + private readonly _onDidAction = this._register(new Emitter()); + readonly onDidAction = this._onDidAction.event; + + /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */ + private readonly _onDidNotification = this._register(new Emitter()); + readonly onDidNotification = this._onDidNotification.event; + + /** Authoritative state manager for the sessions process protocol. */ + private readonly _stateManager: SessionStateManager; + + /** Registered providers keyed by their {@link AgentProvider} id. */ + private readonly _providers = new Map(); + /** Maps each active session URI (toString) to its owning provider. */ + private readonly _sessionToProvider = new Map(); + /** Subscriptions to provider progress events; cleared when providers change. */ + private readonly _providerSubscriptions = this._register(new DisposableStore()); + /** Default provider used when no explicit provider is specified. */ + private _defaultProvider: AgentProvider | undefined; + /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ + private readonly _agents = observableValue('agents', []); + /** Shared side-effect handler for action dispatch and session lifecycle. */ + private readonly _sideEffects: AgentSideEffects; + + constructor( + private readonly _logService: ILogService, + ) { + super(); + this._logService.info('AgentService initialized'); + this._stateManager = this._register(new SessionStateManager(_logService)); + this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); + this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); + this._sideEffects = this._register(new AgentSideEffects(this._stateManager, { + getAgent: session => this._findProviderForSession(session), + agents: this._agents, + }, this._logService)); + } + + // ---- provider registration ---------------------------------------------- + + registerProvider(provider: IAgent): void { + if (this._providers.has(provider.id)) { + throw new Error(`Agent provider already registered: ${provider.id}`); + } + this._logService.info(`Registering agent provider: ${provider.id}`); + this._providers.set(provider.id, provider); + this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); + if (!this._defaultProvider) { + this._defaultProvider = provider.id; + } + + // Update root state with current agents list + this._updateAgents(); + } + + // ---- auth --------------------------------------------------------------- + + async listAgents(): Promise { + return [...this._providers.values()].map(p => p.getDescriptor()); + } + + async setAuthToken(token: string): Promise { + this._logService.trace('[AgentService] setAuthToken called'); + const promises: Promise[] = []; + for (const provider of this._providers.values()) { + promises.push(provider.setAuthToken(token)); + } + await Promise.all(promises); + } + + // ---- session management ------------------------------------------------- + + async listSessions(): Promise { + this._logService.trace('[AgentService] listSessions called'); + const results = await Promise.all( + [...this._providers.values()].map(p => p.listSessions()) + ); + const flat = results.flat(); + this._logService.trace(`[AgentService] listSessions returned ${flat.length} sessions`); + return flat; + } + + /** + * Refreshes the model list from all providers and publishes the updated + * agents (with their models) to root state via `root/agentsChanged`. + */ + async refreshModels(): Promise { + this._logService.trace('[AgentService] refreshModels called'); + this._updateAgents(); + } + + async createSession(config?: IAgentCreateSessionConfig): Promise { + const providerId = config?.provider ?? this._defaultProvider; + const provider = providerId ? this._providers.get(providerId) : undefined; + if (!provider) { + throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); + } + this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`); + const session = await provider.createSession(config); + this._sessionToProvider.set(session.toString(), provider.id); + this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); + + // Create state in the state manager + const summary: ISessionSummary = { + resource: session, + provider: provider.id, + title: 'New Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + this._stateManager.createSession(summary); + this._stateManager.dispatchServerAction({ type: 'session/ready', session }); + + return session; + } + + async disposeSession(session: URI): Promise { + this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); + const provider = this._findProviderForSession(session); + if (provider) { + await provider.disposeSession(session); + this._sessionToProvider.delete(session.toString()); + } + this._stateManager.removeSession(session); + } + + // ---- Protocol methods --------------------------------------------------- + + async subscribe(resource: URI): Promise { + this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); + const snapshot = this._stateManager.getSnapshot(resource); + if (!snapshot) { + throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`); + } + return snapshot; + } + + unsubscribe(resource: URI): void { + this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`); + // Server-side tracking of per-client subscriptions will be added + // in Phase 4 (multi-client). For now this is a no-op. + } + + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); + + const origin = { clientId, clientSeq }; + const state = this._stateManager.dispatchClientAction(action, origin); + this._logService.trace(`[AgentService] resulting state:`, state); + + this._sideEffects.handleAction(action); + } + + async shutdown(): Promise { + this._logService.info('AgentService: shutting down all providers...'); + const promises: Promise[] = []; + for (const provider of this._providers.values()) { + promises.push(provider.shutdown()); + } + await Promise.all(promises); + this._sessionToProvider.clear(); + } + + // ---- helpers ------------------------------------------------------------ + + private _findProviderForSession(session: URI): IAgent | undefined { + const providerId = this._sessionToProvider.get(session.toString()); + if (providerId) { + return this._providers.get(providerId); + } + // Try to infer from URI scheme + const schemeProvider = AgentSession.provider(session); + if (schemeProvider) { + return this._providers.get(schemeProvider); + } + // Fallback: try the default provider (handles resumed sessions not yet tracked) + if (this._defaultProvider) { + return this._providers.get(this._defaultProvider); + } + return undefined; + } + + /** + * Sets the agents observable to trigger model re-fetch and + * `root/agentsChanged` via the autorun in {@link AgentSideEffects}. + */ + private _updateAgents(): void { + this._agents.set([...this._providers.values()], undefined); + } + + override dispose(): void { + for (const provider of this._providers.values()) { + provider.dispose(); + } + this._providers.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts new file mode 100644 index 00000000000..a128055f1f5 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -0,0 +1,223 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, IAgentAttachment, IAgent } from '../common/agentService.js'; +import type { ISessionAction } from '../common/state/sessionActions.js'; +import type { ICreateSessionParams } from '../common/state/sessionProtocol.js'; +import { + ISessionModelInfo, + SessionStatus, type ISessionSummary +} from '../common/state/sessionState.js'; +import { mapProgressEventToAction } from './agentEventMapper.js'; +import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** + * Options for constructing an {@link AgentSideEffects} instance. + */ +export interface IAgentSideEffectsOptions { + /** Resolve the agent responsible for a given session URI. */ + readonly getAgent: (session: URI) => IAgent | undefined; + /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */ + readonly agents: IObservable; +} + +/** + * Shared implementation of agent side-effect handling. + * + * Routes client-dispatched actions to the correct agent backend, handles + * session create/dispose/list operations, tracks pending permission requests, + * and wires up agent progress events to the state manager. + * + * Used by both the Electron utility-process path ({@link AgentService}) and + * the standalone WebSocket server (`agentHostServerMain`). + */ +export class AgentSideEffects extends Disposable implements IProtocolSideEffectHandler { + + /** Maps pending permission request IDs to the provider that issued them. */ + private readonly _pendingPermissions = new Map(); + + constructor( + private readonly _stateManager: SessionStateManager, + private readonly _options: IAgentSideEffectsOptions, + private readonly _logService: ILogService, + ) { + super(); + + // Whenever the agents observable changes, publish to root state. + this._register(autorun(reader => { + const agents = this._options.agents.read(reader); + this._publishAgentInfos(agents); + })); + } + + /** + * Fetches models from all agents and dispatches `root/agentsChanged`. + */ + private async _publishAgentInfos(agents: readonly IAgent[]): Promise { + const infos = await Promise.all(agents.map(async a => { + const d = a.getDescriptor(); + let models: ISessionModelInfo[]; + try { + const rawModels = await a.listModels(); + models = rawModels.map(m => ({ + id: m.id, provider: m.provider, name: m.name, + maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, + policyState: m.policyState, + })); + } catch { + models = []; + } + return { provider: d.provider, displayName: d.displayName, description: d.description, models }; + })); + this._stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: infos }); + } + + // ---- Agent registration ------------------------------------------------- + + /** + * Registers a progress-event listener on the given agent so that + * `IAgentProgressEvent`s are mapped to protocol actions and dispatched + * through the state manager. Returns a disposable that removes the + * listener. + */ + registerProgressListener(agent: IAgent): IDisposable { + const disposables = new DisposableStore(); + disposables.add(agent.onDidSessionProgress(e => { + // Track permission requests so handleAction can route responses + if (e.type === 'permission_request') { + this._pendingPermissions.set(e.requestId, agent.id); + } + + const turnId = this._stateManager.getActiveTurnId(e.session); + if (turnId) { + const action = mapProgressEventToAction(e, e.session, turnId); + if (action) { + this._stateManager.dispatchServerAction(action); + } + } + })); + return disposables; + } + + // ---- IProtocolSideEffectHandler ----------------------------------------- + + handleAction(action: ISessionAction): void { + switch (action.type) { + case 'session/turnStarted': { + const agent = this._options.getAgent(action.session); + if (!agent) { + this._stateManager.dispatchServerAction({ + type: 'session/error', + session: action.session, + turnId: action.turnId, + error: { errorType: 'noAgent', message: 'No agent found for session' }, + }); + return; + } + const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({ + type: a.type, + path: a.path, + displayName: a.displayName, + })); + agent.sendMessage(action.session, action.userMessage.text, attachments).catch(err => { + this._logService.error('[AgentSideEffects] sendMessage failed', err); + this._stateManager.dispatchServerAction({ + type: 'session/error', + session: action.session, + turnId: action.turnId, + error: { errorType: 'sendFailed', message: String(err) }, + }); + }); + break; + } + case 'session/permissionResolved': { + const providerId = this._pendingPermissions.get(action.requestId); + if (providerId) { + this._pendingPermissions.delete(action.requestId); + const agent = this._options.agents.get().find(a => a.id === providerId); + agent?.respondToPermissionRequest(action.requestId, action.approved); + } else { + this._logService.warn(`[AgentSideEffects] No pending permission request for: ${action.requestId}`); + } + break; + } + case 'session/turnCancelled': { + const agent = this._options.getAgent(action.session); + agent?.abortSession(action.session).catch(err => { + this._logService.error('[AgentSideEffects] abortSession failed', err); + }); + break; + } + case 'session/modelChanged': { + const agent = this._options.getAgent(action.session); + agent?.changeModel?.(action.session, action.model).catch(err => { + this._logService.error('[AgentSideEffects] changeModel failed', err); + }); + break; + } + } + } + + async handleCreateSession(command: ICreateSessionParams): Promise { + const provider = command.provider as AgentProvider | undefined; + if (!provider) { + throw new Error('No provider specified for session creation'); + } + const agent = this._options.agents.get().find(a => a.id === provider); + if (!agent) { + throw new Error(`No agent registered for provider: ${provider}`); + } + const session = await agent.createSession({ + provider, + model: command.model, + workingDirectory: command.workingDirectory, + }); + const summary: ISessionSummary = { + resource: session, + provider, + title: 'Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + this._stateManager.createSession(summary); + this._stateManager.dispatchServerAction({ type: 'session/ready', session }); + } + + handleDisposeSession(session: URI): void { + const agent = this._options.getAgent(session); + agent?.disposeSession(session).catch(() => { }); + this._stateManager.removeSession(session); + } + + async handleListSessions(): Promise { + const allSessions: ISessionSummary[] = []; + for (const agent of this._options.agents.get()) { + const sessions = await agent.listSessions(); + const provider = agent.id; + for (const s of sessions) { + allSessions.push({ + resource: s.session, + provider, + title: s.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + }); + } + } + return allSessions; + } + + override dispose(): void { + this._pendingPermissions.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts new file mode 100644 index 00000000000..368079ac0a3 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -0,0 +1,693 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { DeferredPromise } 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 { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; +import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +/** + * 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()); + /** Tracks active tool invocations so we can produce past-tense messages on completion. Keyed by `sessionId:toolCallId`. */ + private readonly _activeToolCalls = new Map | undefined }>(); + /** Pending permission requests awaiting a renderer-side decision. Keyed by requestId. */ + private readonly _pendingPermissions = new Map }>(); + /** Working directory per session, used when resuming. */ + private readonly _sessionWorkingDirs = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + // ---- auth --------------------------------------------------------------- + + getDescriptor(): IAgentDescriptor { + return { + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'Copilot SDK agent running in a dedicated process', + requiresAuth: true, + }; + } + + async setAuthToken(token: string): Promise { + const tokenChanged = this._githubToken !== token; + this._githubToken = token; + this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'} (${token.substring(0, 4)}...)`); + 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(); + } + } + + // ---- 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)' : '(using logged-in user)'}`); + + // 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'; + + // 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; + 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 result = sessions.map(s => ({ + session: AgentSession.uri(this.id, s.sessionId), + startTime: s.startTime.getTime(), + modifiedTime: s.modifiedTime.getTime(), + summary: s.summary, + })); + 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, + 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 raw = await client.createSession({ + model: config?.model, + sessionId: config?.session ? AgentSession.id(config.session) : undefined, + streaming: true, + workingDirectory: config?.workingDirectory, + onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), + }); + + const wrapper = this._trackSession(raw); + const session = AgentSession.uri(this.id, wrapper.sessionId); + if (config?.workingDirectory) { + this._sessionWorkingDirs.set(wrapper.sessionId, config.workingDirectory); + } + this._logService.info(`[Copilot] Session created: ${session.toString()}`); + return session; + } + + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { + const sessionId = AgentSession.id(session); + this._logService.info(`[Copilot:${sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); + this._logService.info(`[Copilot:${sessionId}] Found session wrapper, calling session.send()...`); + + 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:${sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); + } + + await entry.session.send({ prompt, attachments: sdkAttachments }); + this._logService.info(`[Copilot:${sessionId}] session.send() returned`); + } + + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + if (!entry) { + return []; + } + + const events = await entry.session.getMessages(); + return this._mapSessionEvents(session, events); + } + + async disposeSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + this._sessions.deleteAndDispose(sessionId); + this._clearToolCallsForSession(sessionId); + this._sessionWorkingDirs.delete(sessionId); + this._denyPendingPermissionsForSession(sessionId); + } + + async abortSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (entry) { + this._logService.info(`[Copilot:${sessionId}] Aborting session...`); + this._denyPendingPermissionsForSession(sessionId); + await entry.session.abort(); + } + } + + async changeModel(session: URI, model: string): Promise { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (entry) { + this._logService.info(`[Copilot:${sessionId}] Changing model to: ${model}`); + await entry.session.setModel(model); + } + } + + async shutdown(): Promise { + this._logService.info('[Copilot] Shutting down...'); + this._sessions.clearAndDisposeAll(); + this._activeToolCalls.clear(); + this._sessionWorkingDirs.clear(); + this._denyPendingPermissions(); + await this._client?.stop(); + this._client = undefined; + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + const entry = this._pendingPermissions.get(requestId); + if (entry) { + this._pendingPermissions.delete(requestId); + entry.deferred.complete(approved); + } + } + + /** + * Returns true if this provider owns the given session ID. + */ + hasSession(session: URI): boolean { + return this._sessions.has(AgentSession.id(session)); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Handles a permission request from the SDK by firing a progress event + * and waiting for the renderer to respond via respondToPermissionRequest. + */ + private async _handlePermissionRequest( + request: { kind: string; toolCallId?: string;[key: string]: unknown }, + invocation: { sessionId: string }, + ): Promise<{ kind: 'approved' | 'denied-interactively-by-user' }> { + const session = AgentSession.uri(this.id, invocation.sessionId); + + this._logService.info(`[Copilot:${invocation.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; + const workingDir = this._sessionWorkingDirs.get(invocation.sessionId); + if (requestPath && workingDir && requestPath.startsWith(workingDir)) { + this._logService.trace(`[Copilot:${invocation.sessionId}] Auto-approving read inside working directory: ${requestPath}`); + return { kind: 'approved' }; + } + } + + const requestId = generateUuid(); + this._logService.info(`[Copilot:${invocation.sessionId}] Requesting permission from renderer: requestId=${requestId}`); + + const deferred = new DeferredPromise(); + this._pendingPermissions.set(requestId, { sessionId: invocation.sessionId, deferred }); + + const permissionKind = (['shell', 'write', 'mcp', 'read', 'url'] as const).includes(request.kind as 'shell') + ? request.kind as 'shell' | 'write' | 'mcp' | 'read' | 'url' + : 'read'; // Treat unknown kinds as read (safest default) + + // Fire the event so the renderer can handle it + this._onDidSessionProgress.fire({ + session, + type: 'permission_request', + requestId, + permissionKind, + toolCallId: request.toolCallId, + path: typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined), + fullCommandText: typeof request.fullCommandText === 'string' ? request.fullCommandText : undefined, + intention: typeof request.intention === 'string' ? request.intention : undefined, + serverName: typeof request.serverName === 'string' ? request.serverName : undefined, + toolName: typeof request.toolName === 'string' ? request.toolName : undefined, + rawRequest: tryStringify(request) ?? '[unserializable permission request]', + }); + + const approved = await deferred.p; + this._logService.info(`[Copilot:${invocation.sessionId}] Permission response: requestId=${requestId}, approved=${approved}`); + return { kind: approved ? 'approved' : 'denied-interactively-by-user' }; + } + + private _clearToolCallsForSession(sessionId: string): void { + const prefix = `${sessionId}:`; + for (const key of this._activeToolCalls.keys()) { + if (key.startsWith(prefix)) { + this._activeToolCalls.delete(key); + } + } + } + + private _trackSession(raw: CopilotSession, sessionIdOverride?: string): CopilotSessionWrapper { + const wrapper = new CopilotSessionWrapper(raw); + const rawId = sessionIdOverride ?? wrapper.sessionId; + const session = AgentSession.uri(this.id, rawId); + + wrapper.onMessageDelta(e => { + this._logService.trace(`[Copilot:${rawId}] delta: ${e.data.deltaContent}`); + this._onDidSessionProgress.fire({ + session, + type: 'delta', + messageId: e.data.messageId, + content: e.data.deltaContent, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onMessage(e => { + this._logService.info(`[Copilot:${rawId}] 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, + }); + }); + + wrapper.onToolStart(e => { + if (isHiddenTool(e.data.toolName)) { + this._logService.trace(`[Copilot:${rawId}] Tool started (hidden): ${e.data.toolName}`); + return; + } + this._logService.info(`[Copilot:${rawId}] 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); + const trackingKey = `${rawId}:${e.data.toolCallId}`; + this._activeToolCalls.set(trackingKey, { 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, + }); + }); + + wrapper.onToolComplete(e => { + const trackingKey = `${rawId}:${e.data.toolCallId}`; + const tracked = this._activeToolCalls.get(trackingKey); + if (!tracked) { + return; + } + this._logService.info(`[Copilot:${rawId}] Tool completed: ${e.data.toolCallId}`); + this._activeToolCalls.delete(trackingKey); + const displayName = tracked.displayName; + const toolOutput = e.data.error?.message ?? e.data.result?.content; + this._onDidSessionProgress.fire({ + session, + type: 'tool_complete', + toolCallId: e.data.toolCallId, + success: e.data.success, + pastTenseMessage: getPastTenseMessage(tracked?.toolName ?? '', displayName, tracked?.parameters, e.data.success), + toolOutput, + isUserRequested: e.data.isUserRequested, + result: e.data.result, + error: e.data.error, + toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onIdle(() => { + this._logService.info(`[Copilot:${rawId}] Session idle`); + this._onDidSessionProgress.fire({ session, type: 'idle' }); + }); + + wrapper.onSessionError(e => { + this._logService.error(`[Copilot:${rawId}] 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, + }); + }); + + wrapper.onUsage(e => { + this._logService.trace(`[Copilot:${rawId}] 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, + }); + }); + + wrapper.onReasoningDelta(e => { + this._logService.trace(`[Copilot:${rawId}] Reasoning delta: ${e.data.deltaContent.length} chars`); + this._onDidSessionProgress.fire({ + session, + type: 'reasoning', + content: e.data.deltaContent, + }); + }); + + this._subscribeForLogging(wrapper, rawId); + + this._sessions.set(rawId, wrapper); + return wrapper; + } + + private _subscribeForLogging(wrapper: CopilotSessionWrapper, sessionId: string): void { + wrapper.onSessionStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); + }); + + wrapper.onSessionResume(e => { + this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); + }); + + wrapper.onSessionInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`); + }); + + wrapper.onSessionModelChange(e => { + this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); + }); + + wrapper.onSessionHandoff(e => { + this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); + }); + + wrapper.onSessionTruncation(e => { + this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); + }); + + wrapper.onSessionSnapshotRewind(e => { + this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); + }); + + wrapper.onSessionShutdown(e => { + this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`); + }); + + wrapper.onSessionUsageInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); + }); + + wrapper.onSessionCompactionStart(() => { + this._logService.trace(`[Copilot:${sessionId}] Compaction started`); + }); + + wrapper.onSessionCompactionComplete(e => { + this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); + }); + + wrapper.onUserMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); + }); + + wrapper.onPendingMessagesModified(() => { + this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); + }); + + wrapper.onTurnStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); + }); + + wrapper.onIntent(e => { + this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); + }); + + wrapper.onReasoning(e => { + this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); + }); + + wrapper.onTurnEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); + }); + + wrapper.onAbort(e => { + this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); + }); + + wrapper.onToolUserRequested(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); + }); + + wrapper.onToolPartialResult(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); + }); + + wrapper.onToolProgress(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); + }); + + wrapper.onSkillInvoked(e => { + this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); + }); + + wrapper.onSubagentStarted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); + }); + + wrapper.onSubagentCompleted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); + }); + + wrapper.onSubagentFailed(e => { + this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); + }); + + wrapper.onSubagentSelected(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); + }); + + wrapper.onHookStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); + }); + + wrapper.onHookEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); + }); + + wrapper.onSystemMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); + }); + } + + private async _resumeSession(sessionId: string): Promise { + this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); + const client = await this._ensureClient(); + const raw = await client.resumeSession(sessionId, { + onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), + workingDirectory: this._sessionWorkingDirs.get(sessionId), + }); + return this._trackSession(raw, sessionId); + } + + private _mapSessionEvents(session: URI, events: readonly SessionEvent[]): (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] { + const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + const toolInfoByCallId = new Map | undefined }>(); + + for (const e of events) { + if (e.type === 'assistant.message' || e.type === 'user.message') { + const d = (e as SessionEventPayload<'assistant.message'>).data; + result.push({ + session, + type: 'message', + role: e.type === 'user.message' ? 'user' : 'assistant', + messageId: d?.messageId ?? '', + content: d?.content ?? '', + toolRequests: d?.toolRequests?.map((tr: { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }) => ({ + toolCallId: tr.toolCallId, + name: tr.name, + arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, + type: tr.type, + })), + reasoningOpaque: d?.reasoningOpaque, + reasoningText: d?.reasoningText, + encryptedContent: d?.encryptedContent, + parentToolCallId: d?.parentToolCallId, + }); + } else if (e.type === 'tool.execution_start') { + const d = (e as SessionEventPayload<'tool.execution_start'>).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + let parameters: Record | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } + } + toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters }); + const displayName = getToolDisplayName(d.toolName); + const toolKind = getToolKind(d.toolName); + result.push({ + session, + type: 'tool_start', + toolCallId: d.toolCallId, + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, parameters), + toolInput: getToolInputString(d.toolName, parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, + toolArguments: toolArgs, + mcpServerName: d.mcpServerName, + mcpToolName: d.mcpToolName, + parentToolCallId: d.parentToolCallId, + }); + } else if (e.type === 'tool.execution_complete') { + const d = (e as SessionEventPayload<'tool.execution_complete'>).data; + const info = toolInfoByCallId.get(d.toolCallId); + if (!info) { + continue; + } + toolInfoByCallId.delete(d.toolCallId); + const displayName = getToolDisplayName(info.toolName); + result.push({ + session, + type: 'tool_complete', + toolCallId: d.toolCallId, + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), + toolOutput: d.error?.message ?? d.result?.content, + isUserRequested: d.isUserRequested, + result: d.result, + error: d.error, + toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, + }); + } + } + return result; + } + + override dispose(): void { + this._denyPendingPermissions(); + this._client?.stop().catch(() => { /* best-effort */ }); + super.dispose(); + } + + private _denyPendingPermissions(): void { + for (const [, entry] of this._pendingPermissions) { + entry.deferred.complete(false); + } + this._pendingPermissions.clear(); + } + + private _denyPendingPermissionsForSession(sessionId: string): void { + for (const [requestId, entry] of this._pendingPermissions) { + if (entry.sessionId === sessionId) { + entry.deferred.complete(false); + this._pendingPermissions.delete(requestId); + } + } + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts new file mode 100644 index 00000000000..36ad526d416 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts new file mode 100644 index 00000000000..3a181d85abc --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -0,0 +1,286 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; + +// ============================================================================= +// Copilot CLI built-in tool interfaces +// +// The Copilot CLI (via @github/copilot) exposes these built-in tools. Tool names +// and parameter shapes are not typed in the SDK -- they come from the CLI server +// as plain strings. These interfaces are derived from observing the CLI's actual +// tool events and the ShellConfig class in @github/copilot. +// +// Shell tool names follow a pattern per ShellConfig: +// shellToolName, readShellToolName, writeShellToolName, +// stopShellToolName, listShellsToolName +// For bash: bash, read_bash, write_bash, bash_shutdown, list_bash +// For powershell: powershell, read_powershell, write_powershell, list_powershell +// ============================================================================= + +/** + * Known Copilot CLI tool names. These are the `toolName` values that appear + * in `tool.execution_start` events from the SDK. + */ +const enum CopilotToolName { + Bash = 'bash', + ReadBash = 'read_bash', + WriteBash = 'write_bash', + BashShutdown = 'bash_shutdown', + ListBash = 'list_bash', + + PowerShell = 'powershell', + ReadPowerShell = 'read_powershell', + WritePowerShell = 'write_powershell', + ListPowerShell = 'list_powershell', + + View = 'view', + Edit = 'edit', + Write = 'write', + Grep = 'grep', + Glob = 'glob', + Patch = 'patch', + WebSearch = 'web_search', + AskUser = 'ask_user', + ReportIntent = 'report_intent', +} + +/** Parameters for the `bash` / `powershell` shell tools. */ +interface ICopilotShellToolArgs { + command: string; + timeout?: number; +} + +/** Parameters for file tools (`view`, `edit`, `write`). */ +interface ICopilotFileToolArgs { + file_path: string; +} + +/** Parameters for the `grep` tool. */ +interface ICopilotGrepToolArgs { + pattern: string; + path?: string; + include?: string; +} + +/** Parameters for the `glob` tool. */ +interface ICopilotGlobToolArgs { + pattern: string; + path?: string; +} + +/** Set of tool names that execute shell commands (bash or powershell). */ +const SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.Bash, + CopilotToolName.PowerShell, +]); + +/** + * Tools that should not be shown to the user. These are internal tools + * used by the CLI for its own purposes (e.g., reporting intent to the model). + */ +const HIDDEN_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.ReportIntent, +]); + +/** + * Returns true if the tool should be hidden from the UI. + */ +export function isHiddenTool(toolName: string): boolean { + return HIDDEN_TOOL_NAMES.has(toolName); +} + +// ============================================================================= +// Display helpers +// +// These functions translate Copilot CLI tool names and arguments into +// human-readable display strings. This logic lives here -- in the agent-host +// process -- so the IPC protocol stays agent-agnostic; the renderer never needs +// to know about specific tool names. +// ============================================================================= + +function truncate(text: string, maxLength: number): string { + return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; +} + +export function getToolDisplayName(toolName: string): string { + switch (toolName) { + case CopilotToolName.Bash: return localize('toolName.bash', "Bash"); + case CopilotToolName.PowerShell: return localize('toolName.powershell', "PowerShell"); + case CopilotToolName.ReadBash: + case CopilotToolName.ReadPowerShell: return localize('toolName.readShell', "Read Shell Output"); + case CopilotToolName.WriteBash: + case CopilotToolName.WritePowerShell: return localize('toolName.writeShell', "Write Shell Input"); + case CopilotToolName.BashShutdown: return localize('toolName.bashShutdown', "Stop Shell"); + case CopilotToolName.ListBash: + case CopilotToolName.ListPowerShell: return localize('toolName.listShells', "List Shells"); + case CopilotToolName.View: return localize('toolName.view', "View File"); + case CopilotToolName.Edit: return localize('toolName.edit', "Edit File"); + case CopilotToolName.Write: return localize('toolName.write', "Write File"); + case CopilotToolName.Grep: return localize('toolName.grep', "Search"); + case CopilotToolName.Glob: return localize('toolName.glob', "Find Files"); + case CopilotToolName.Patch: return localize('toolName.patch', "Patch"); + case CopilotToolName.WebSearch: return localize('toolName.webSearch', "Web Search"); + case CopilotToolName.AskUser: return localize('toolName.askUser', "Ask User"); + default: return toolName; + } +} + +export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): string { + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return localize('toolInvoke.shellCmd', "Running `{0}`", truncate(firstLine, 80)); + } + return localize('toolInvoke.shell', "Running {0} command", displayName); + } + + switch (toolName) { + case CopilotToolName.View: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolInvoke.viewFile', "Reading {0}", args.file_path); + } + return localize('toolInvoke.view', "Reading file"); + } + case CopilotToolName.Edit: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolInvoke.editFile', "Editing {0}", args.file_path); + } + return localize('toolInvoke.edit', "Editing file"); + } + case CopilotToolName.Write: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolInvoke.writeFile', "Writing to {0}", args.file_path); + } + return localize('toolInvoke.write', "Writing file"); + } + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + if (args?.pattern) { + return localize('toolInvoke.grepPattern', "Searching for `{0}`", truncate(args.pattern, 80)); + } + return localize('toolInvoke.grep', "Searching files"); + } + case CopilotToolName.Glob: { + const args = parameters as ICopilotGlobToolArgs | undefined; + if (args?.pattern) { + return localize('toolInvoke.globPattern', "Finding files matching `{0}`", truncate(args.pattern, 80)); + } + return localize('toolInvoke.glob', "Finding files"); + } + default: + return localize('toolInvoke.generic', "Using \"{0}\"", displayName); + } +} + +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): string { + if (!success) { + return localize('toolComplete.failed', "\"{0}\" failed", displayName); + } + + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return localize('toolComplete.shellCmd', "Ran `{0}`", truncate(firstLine, 80)); + } + return localize('toolComplete.shell', "Ran {0} command", displayName); + } + + switch (toolName) { + case CopilotToolName.View: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolComplete.viewFile', "Read {0}", args.file_path); + } + return localize('toolComplete.view', "Read file"); + } + case CopilotToolName.Edit: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolComplete.editFile', "Edited {0}", args.file_path); + } + return localize('toolComplete.edit', "Edited file"); + } + case CopilotToolName.Write: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.file_path) { + return localize('toolComplete.writeFile', "Wrote to {0}", args.file_path); + } + return localize('toolComplete.write', "Wrote file"); + } + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + if (args?.pattern) { + return localize('toolComplete.grepPattern', "Searched for `{0}`", truncate(args.pattern, 80)); + } + return localize('toolComplete.grep', "Searched files"); + } + case CopilotToolName.Glob: { + const args = parameters as ICopilotGlobToolArgs | undefined; + if (args?.pattern) { + return localize('toolComplete.globPattern', "Found files matching `{0}`", truncate(args.pattern, 80)); + } + return localize('toolComplete.glob', "Found files"); + } + default: + return localize('toolComplete.generic', "Used \"{0}\"", displayName); + } +} + +export function getToolInputString(toolName: string, parameters: Record | undefined, rawArguments: string | undefined): string | undefined { + if (!parameters && !rawArguments) { + return undefined; + } + + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + return args?.command ?? rawArguments; + } + + switch (toolName) { + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + return args?.pattern ?? rawArguments; + } + default: + // For other tools, show the formatted JSON arguments + if (parameters) { + try { + return JSON.stringify(parameters, null, 2); + } catch { + return rawArguments; + } + } + return rawArguments; + } +} + +/** + * Returns a rendering hint for the given tool. Currently only 'terminal' is + * supported, which tells the renderer to display the tool as a terminal command + * block. + */ +export function getToolKind(toolName: string): 'terminal' | undefined { + if (SHELL_TOOL_NAMES.has(toolName)) { + return 'terminal'; + } + return undefined; +} + +/** + * Returns the shell language identifier for syntax highlighting. + * Used when creating terminal tool-specific data for the renderer. + */ +export function getShellLanguage(toolName: string): string { + switch (toolName) { + case CopilotToolName.PowerShell: return 'powershell'; + default: return 'shellscript'; + } +} diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts new file mode 100644 index 00000000000..0a9678f35ad --- /dev/null +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { FileAccess, Schemas } from '../../../base/common/network.js'; +import { Client, IIPCOptions } from '../../../base/parts/ipc/node/ipc.cp.js'; +import { IEnvironmentService, INativeEnvironmentService } from '../../environment/common/environment.js'; +import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; + +/** + * Spawns the agent host as a Node child process (fallback when + * Electron utility process is unavailable, e.g. dev/test). + */ +export class NodeAgentHostStarter extends Disposable implements IAgentHostStarter { + constructor( + @IEnvironmentService private readonly _environmentService: INativeEnvironmentService + ) { + super(); + } + + start(): IAgentHostConnection { + const opts: IIPCOptions = { + serverName: 'Agent Host', + args: ['--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath], + env: { + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + } + }; + + const agentHostDebug = parseAgentHostDebugPort(this._environmentService.args, this._environmentService.isBuilt); + if (agentHostDebug) { + if (agentHostDebug.break && agentHostDebug.port) { + opts.debugBrk = agentHostDebug.port; + } else if (!agentHostDebug.break && agentHostDebug.port) { + opts.debug = agentHostDebug.port; + } + } + + const client = new Client(FileAccess.asFileUri('bootstrap-fork').fsPath, opts); + + const store = new DisposableStore(); + store.add(client); + + return { + client, + store, + onDidProcessExit: client.onDidProcessExit + }; + } +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts new file mode 100644 index 00000000000..cda85ebc0d7 --- /dev/null +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { IActionEnvelope, INotification, isSessionAction } from '../common/state/sessionActions.js'; +import { isActionKnownToVersion, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; +import { + isJsonRpcRequest, + isJsonRpcNotification, + JSON_RPC_INTERNAL_ERROR, + type ICreateSessionParams, + type IDispatchActionParams, + type IDisposeSessionParams, + type IFetchTurnsParams, + type IInitializeParams, + type IProtocolMessage, + type IReconnectParams, + type IStateSnapshot, + type ISubscribeParams, + type IUnsubscribeParams, +} from '../common/state/sessionProtocol.js'; +import { ROOT_STATE_URI } from '../common/state/sessionState.js'; +import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** Default capacity of the server-side action replay buffer. */ +const REPLAY_BUFFER_CAPACITY = 1000; + +/** + * Represents a connected protocol client with its subscription state. + */ +interface IConnectedClient { + readonly clientId: string; + readonly protocolVersion: number; + readonly transport: IProtocolTransport; + readonly subscriptions: Set; + readonly disposables: DisposableStore; +} + +/** + * Server-side handler that manages protocol connections, routes JSON-RPC + * messages to the state manager, and broadcasts actions/notifications + * to subscribed clients. + */ +export class ProtocolServerHandler extends Disposable { + + private readonly _clients = new Map(); + private readonly _replayBuffer: IActionEnvelope[] = []; + + constructor( + private readonly _stateManager: SessionStateManager, + private readonly _server: IProtocolServer, + private readonly _sideEffectHandler: IProtocolSideEffectHandler, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._server.onConnection(transport => { + this._handleNewConnection(transport); + })); + + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + this._replayBuffer.push(envelope); + if (this._replayBuffer.length > REPLAY_BUFFER_CAPACITY) { + this._replayBuffer.shift(); + } + this._broadcastAction(envelope); + })); + + this._register(this._stateManager.onDidEmitNotification(notification => { + this._broadcastNotification(notification); + })); + } + + // ---- Connection handling ------------------------------------------------- + + private _handleNewConnection(transport: IProtocolTransport): void { + const disposables = new DisposableStore(); + let client: IConnectedClient | undefined; + + disposables.add(transport.onMessage(msg => { + if (isJsonRpcRequest(msg)) { + // Request — expects a correlated response + if (!client) { + return; + } + this._handleRequest(client, msg.method, msg.params, msg.id); + } else if (isJsonRpcNotification(msg)) { + // Notification — fire-and-forget + switch (msg.method) { + case 'initialize': + client = this._handleInitialize(msg.params as IInitializeParams, transport, disposables); + break; + case 'reconnect': + client = this._handleReconnect(msg.params as IReconnectParams, transport, disposables); + break; + case 'unsubscribe': + if (client) { + client.subscriptions.delete((msg.params as IUnsubscribeParams).resource.toString()); + } + break; + case 'dispatchAction': + if (client) { + const params = msg.params as IDispatchActionParams; + const origin = { clientId: client.clientId, clientSeq: params.clientSeq }; + this._stateManager.dispatchClientAction(params.action, origin); + this._sideEffectHandler.handleAction(params.action); + } + break; + } + } + // Responses from the client (if any) are ignored on the server side. + })); + + disposables.add(transport.onClose(() => { + if (client) { + this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`); + this._clients.delete(client.clientId); + } + disposables.dispose(); + })); + + disposables.add(transport); + } + + // ---- Notifications (fire-and-forget) ------------------------------------ + + private _handleInitialize( + params: IInitializeParams, + transport: IProtocolTransport, + disposables: DisposableStore, + ): IConnectedClient { + this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, version=${params.protocolVersion}`); + + const client: IConnectedClient = { + clientId: params.clientId, + protocolVersion: params.protocolVersion, + transport, + subscriptions: new Set(), + disposables, + }; + this._clients.set(params.clientId, client); + + const snapshots: IStateSnapshot[] = []; + if (params.initialSubscriptions) { + for (const uri of params.initialSubscriptions) { + const snapshot = this._stateManager.getSnapshot(uri); + if (snapshot) { + snapshots.push(snapshot); + client.subscriptions.add(uri.toString()); + } + } + } + + this._sendNotification(transport, 'serverHello', { + protocolVersion: PROTOCOL_VERSION, + serverSeq: this._stateManager.serverSeq, + snapshots, + }); + + return client; + } + + private _handleReconnect( + params: IReconnectParams, + transport: IProtocolTransport, + disposables: DisposableStore, + ): IConnectedClient { + this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`); + + const client: IConnectedClient = { + clientId: params.clientId, + protocolVersion: PROTOCOL_VERSION, + transport, + subscriptions: new Set(), + disposables, + }; + this._clients.set(params.clientId, client); + + const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; + const canReplay = params.lastSeenServerSeq >= oldestBuffered; + + if (canReplay) { + for (const sub of params.subscriptions) { + client.subscriptions.add(sub.toString()); + } + for (const envelope of this._replayBuffer) { + if (envelope.serverSeq > params.lastSeenServerSeq) { + if (this._isRelevantToClient(client, envelope)) { + this._sendNotification(transport, 'action', { envelope }); + } + } + } + } else { + const snapshots: IStateSnapshot[] = []; + for (const sub of params.subscriptions) { + const snapshot = this._stateManager.getSnapshot(sub); + if (snapshot) { + snapshots.push(snapshot); + client.subscriptions.add(sub.toString()); + } + } + this._sendNotification(transport, 'reconnectResponse', { + serverSeq: this._stateManager.serverSeq, + snapshots, + }); + } + + return client; + } + + // ---- Requests (expect a response) --------------------------------------- + + private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { + this._handleRequestAsync(client, method, params).then(result => { + client.transport.send({ jsonrpc: '2.0', id, result: result ?? null }); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + client.transport.send({ + jsonrpc: '2.0', + id, + error: { code: JSON_RPC_INTERNAL_ERROR, message: String(err?.message ?? err) }, + }); + }); + } + + private async _handleRequestAsync(client: IConnectedClient, method: string, params: unknown): Promise { + switch (method) { + case 'subscribe': { + const p = params as ISubscribeParams; + const snapshot = this._stateManager.getSnapshot(p.resource); + if (snapshot) { + client.subscriptions.add(p.resource.toString()); + } + return snapshot ?? null; + } + case 'createSession': { + await this._sideEffectHandler.handleCreateSession(params as ICreateSessionParams); + return null; + } + case 'disposeSession': { + this._sideEffectHandler.handleDisposeSession((params as IDisposeSessionParams).session); + return null; + } + case 'listSessions': { + const sessions = await this._sideEffectHandler.handleListSessions(); + return { sessions }; + } + case 'fetchTurns': { + const p = params as IFetchTurnsParams; + const state = this._stateManager.getSessionState(p.session); + if (state) { + const turns = state.turns; + const start = Math.max(0, p.startTurn); + const end = Math.min(turns.length, start + p.count); + return { + session: p.session, + startTurn: start, + turns: turns.slice(start, end), + totalTurns: turns.length, + }; + } + return { + session: p.session, + startTurn: p.startTurn, + turns: [], + totalTurns: 0, + }; + } + default: + throw new Error(`Unknown method: ${method}`); + } + } + + // ---- Broadcasting ------------------------------------------------------- + + private _sendNotification(transport: IProtocolTransport, method: string, params: unknown): void { + transport.send({ jsonrpc: '2.0', method, params }); + } + + private _broadcastAction(envelope: IActionEnvelope): void { + const msg: IProtocolMessage = { jsonrpc: '2.0', method: 'action', params: { envelope } }; + for (const client of this._clients.values()) { + if (this._isRelevantToClient(client, envelope)) { + client.transport.send(msg); + } + } + } + + private _broadcastNotification(notification: INotification): void { + const msg: IProtocolMessage = { jsonrpc: '2.0', method: 'notification', params: { notification } }; + for (const client of this._clients.values()) { + client.transport.send(msg); + } + } + + private _isRelevantToClient(client: IConnectedClient, envelope: IActionEnvelope): boolean { + const action = envelope.action; + if (!isActionKnownToVersion(action, client.protocolVersion)) { + return false; + } + if (action.type.startsWith('root/')) { + return client.subscriptions.has(ROOT_STATE_URI.toString()); + } + if (isSessionAction(action)) { + return client.subscriptions.has(action.session.toString()); + } + return false; + } + + override dispose(): void { + for (const client of this._clients.values()) { + client.disposables.dispose(); + } + this._clients.clear(); + this._replayBuffer.length = 0; + super.dispose(); + } +} + +/** + * Interface for side effects that the protocol server delegates to. + * These are operations that involve I/O, agent backends, etc. + */ +export interface IProtocolSideEffectHandler { + handleAction(action: import('../common/state/sessionActions.js').ISessionAction): void; + handleCreateSession(command: import('../common/state/sessionProtocol.js').ICreateSessionParams): Promise; + handleDisposeSession(session: URI): void; + handleListSessions(): Promise; +} diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts new file mode 100644 index 00000000000..121daa48d8d --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; +import { IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; +import { createRootState, createSessionState, IRootState, ISessionState, ISessionSummary, ROOT_STATE_URI } from '../common/state/sessionState.js'; + +/** + * Server-side state manager for the sessions process protocol. + * + * Maintains the authoritative state tree (root + per-session), applies actions + * through pure reducers, assigns monotonic sequence numbers, and emits + * {@link IActionEnvelope}s for subscribed clients. + */ +export class SessionStateManager extends Disposable { + + private _serverSeq = 0; + + private _rootState: IRootState; + private readonly _sessionStates = new Map(); + + /** Tracks which session URI each active turn belongs to, keyed by turnId. */ + private readonly _activeTurnToSession = new Map(); + + private readonly _onDidEmitEnvelope = this._register(new Emitter()); + readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; + + private readonly _onDidEmitNotification = this._register(new Emitter()); + readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._rootState = createRootState(); + } + + // ---- State accessors ---------------------------------------------------- + + get rootState(): IRootState { + return this._rootState; + } + + getSessionState(session: URI): ISessionState | undefined { + return this._sessionStates.get(session.toString()); + } + + get serverSeq(): number { + return this._serverSeq; + } + + // ---- Snapshots ---------------------------------------------------------- + + /** + * Returns a state snapshot for a given resource URI. + * The `fromSeq` in the snapshot is the current serverSeq at snapshot time; + * the client should process subsequent envelopes with serverSeq > fromSeq. + */ + getSnapshot(resource: URI): IStateSnapshot | undefined { + const key = resource.toString(); + + if (key === ROOT_STATE_URI.toString()) { + return { + resource, + state: this._rootState, + fromSeq: this._serverSeq, + }; + } + + const sessionState = this._sessionStates.get(key); + if (!sessionState) { + return undefined; + } + + return { + resource, + state: sessionState, + fromSeq: this._serverSeq, + }; + } + + // ---- Session lifecycle -------------------------------------------------- + + /** + * Creates a new session in state with `lifecycle: 'creating'`. + * Returns the initial session state. + */ + createSession(summary: ISessionSummary): ISessionState { + const key = summary.resource.toString(); + if (this._sessionStates.has(key)) { + this._logService.warn(`[SessionStateManager] Session already exists: ${key}`); + return this._sessionStates.get(key)!; + } + + const state = createSessionState(summary); + this._sessionStates.set(key, state); + + this._logService.trace(`[SessionStateManager] Created session: ${key}`); + + this._onDidEmitNotification.fire({ + type: 'notify/sessionAdded', + summary, + }); + + return state; + } + + /** + * Removes a session from state and emits a sessionRemoved notification. + */ + removeSession(session: URI): void { + const key = session.toString(); + const state = this._sessionStates.get(key); + if (!state) { + return; + } + + // Clean up active turn tracking + if (state.activeTurn) { + this._activeTurnToSession.delete(state.activeTurn.id); + } + + this._sessionStates.delete(key); + this._logService.trace(`[SessionStateManager] Removed session: ${key}`); + + this._onDidEmitNotification.fire({ + type: 'notify/sessionRemoved', + session, + }); + } + + // ---- Turn tracking ------------------------------------------------------ + + /** + * Registers a mapping from turnId to session URI so that incoming + * provider events (which carry only session URI) can be associated + * with the correct active turn. + */ + getActiveTurnId(session: URI): string | undefined { + const state = this._sessionStates.get(session.toString()); + return state?.activeTurn?.id; + } + + // ---- Action dispatch ---------------------------------------------------- + + /** + * Dispatch a server-originated action (from the agent backend). + * The action is applied to state via the reducer and emitted as an + * envelope with no origin (server-produced). + */ + dispatchServerAction(action: IStateAction): void { + this._applyAndEmit(action, undefined); + } + + /** + * Dispatch a client-originated action (write-ahead from a renderer). + * The action is applied to state and emitted with the client's origin + * so the originating client can reconcile. + */ + dispatchClientAction(action: ISessionAction, origin: IActionOrigin): unknown { + return this._applyAndEmit(action, origin); + } + + // ---- Internal ----------------------------------------------------------- + + private _applyAndEmit(action: IStateAction, origin: IActionOrigin | undefined): unknown { + let resultingState: unknown = undefined; + // Apply to state + if (isRootAction(action)) { + this._rootState = rootReducer(this._rootState, action as IRootAction); + resultingState = this._rootState; + } + + if (isSessionAction(action)) { + const sessionAction = action as ISessionAction; + const key = sessionAction.session.toString(); + const state = this._sessionStates.get(key); + if (state) { + const newState = sessionReducer(state, sessionAction); + this._sessionStates.set(key, newState); + + // Track active turn for turn lifecycle + if (sessionAction.type === 'session/turnStarted') { + this._activeTurnToSession.set(sessionAction.turnId, key); + } else if ( + sessionAction.type === 'session/turnComplete' || + sessionAction.type === 'session/turnCancelled' || + sessionAction.type === 'session/error' + ) { + this._activeTurnToSession.delete(sessionAction.turnId); + } + + resultingState = newState; + } else { + this._logService.warn(`[SessionStateManager] Action for unknown session: ${key}, type=${action.type}`); + } + } + + // Emit envelope + const envelope: IActionEnvelope = { + action, + serverSeq: ++this._serverSeq, + origin, + }; + + this._logService.trace(`[SessionStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); + this._onDidEmitEnvelope.fire(envelope); + + return resultingState; + } +} diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts new file mode 100644 index 00000000000..a56c2b8060c --- /dev/null +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// WebSocket transport for the sessions process protocol. +// Uses JSON serialization with URI revival for cross-process communication. + +import { WebSocketServer, WebSocket } from 'ws'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import type { IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; + +// ---- JSON serialization helpers --------------------------------------------- + +function uriReplacer(_key: string, value: unknown): unknown { + if (value instanceof URI) { + return value.toJSON(); + } + if (value instanceof Map) { + return { $type: 'Map', entries: [...value.entries()] }; + } + return value; +} + +function uriReviver(_key: string, value: unknown): unknown { + if (value && typeof value === 'object') { + const obj = value as Record; + if (obj.$mid === 1) { + return URI.revive(value as URI); + } + if (obj.$type === 'Map' && Array.isArray(obj.entries)) { + return new Map(obj.entries as [unknown, unknown][]); + } + } + return value; +} + +// ---- Per-connection transport ----------------------------------------------- + +/** + * Wraps a single WebSocket connection as an {@link IProtocolTransport}. + * Messages are serialized as JSON with URI revival. + */ +export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + constructor(private readonly _ws: WebSocket) { + super(); + + this._ws.on('message', (data: Buffer | string) => { + try { + const text = typeof data === 'string' ? data : data.toString('utf-8'); + const message = JSON.parse(text, uriReviver) as IProtocolMessage; + this._onMessage.fire(message); + } catch { + // Malformed message — drop. No logger available at transport level. + } + }); + + this._ws.on('close', () => { + this._onClose.fire(); + }); + + this._ws.on('error', () => { + // Error always precedes close — closing is handled in the close handler. + this._onClose.fire(); + }); + } + + send(message: IProtocolMessage): void { + if (this._ws.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify(message, uriReplacer)); + } + } + + override dispose(): void { + this._ws.close(); + super.dispose(); + } +} + +// ---- Server ----------------------------------------------------------------- + +/** + * WebSocket server that accepts client connections and wraps each one + * as an {@link IProtocolTransport}. + */ +export class WebSocketProtocolServer extends Disposable implements IProtocolServer { + + private readonly _wss: WebSocketServer; + + private readonly _onConnection = this._register(new Emitter()); + readonly onConnection = this._onConnection.event; + + get address(): string | undefined { + const addr = this._wss.address(); + if (!addr || typeof addr === 'string') { + return addr ?? undefined; + } + return `${addr.address}:${addr.port}`; + } + + constructor( + private readonly _port: number, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._wss = new WebSocketServer({ port: this._port, host: '127.0.0.1' }); + this._logService.info(`[WebSocketProtocol] Server listening on 127.0.0.1:${this._port}`); + + this._wss.on('connection', (ws) => { + this._logService.trace('[WebSocketProtocol] New client connection'); + const transport = new WebSocketProtocolTransport(ws); + this._onConnection.fire(transport); + }); + + this._wss.on('error', (err) => { + this._logService.error('[WebSocketProtocol] Server error', err); + }); + } + + override dispose(): void { + this._wss.close(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/protocol.md b/src/vs/platform/agentHost/protocol.md new file mode 100644 index 00000000000..37c2b2ca1e1 --- /dev/null +++ b/src/vs/platform/agentHost/protocol.md @@ -0,0 +1,511 @@ +# Sessions process protocol + +> **Keep this document in sync with the code.** Changes to the state model, action types, protocol messages, or versioning strategy must be reflected here. Implementation lives in `common/state/`. + +> **Pre-production.** This protocol is under active development and is not shipped yet. Breaking changes to wire types, actions, and state shapes are fine — do not worry about backward compatibility until the protocol is in production. The versioning machinery exists for future use. + +For process architecture and IPC details, see [architecture.md](architecture.md). For design decisions, see [design.md](design.md). + +## Goal + +The sessions process is a portable, standalone server that multiple clients can connect to. Clients see a synchronized view of sessions and can send commands that are reflected back as state-changing actions. The protocol is designed around four requirements: + +1. **Synchronized multi-client state** — an immutable, redux-like state tree mutated exclusively by actions flowing through pure reducers. +2. **Lazy loading** — clients subscribe to state by URI and load data on demand. The session list is fetched imperatively. Large content (images, long tool outputs) is stored by reference and fetched separately. +3. **Write-ahead with reconciliation** — clients optimistically apply their own actions locally, then reconcile when the server echoes them back alongside any concurrent actions from other clients or the server itself. +4. **Forward-compatible versioning** — newer clients can connect to older servers. A single protocol version number maps to a capabilities object; clients check capabilities before using features. + +## Protocol development checklist + +Use this checklist when adding a new action, command, state field, or notification to the protocol. + +### Adding a new action type + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts` that exercises the action end-to-end through the WebSocket server. The test should fail until the implementation is complete. +2. **Add mock agent support** if the test needs a new prompt/behavior in `mockAgent.ts`. +3. **Define the action interface** in `sessionActions.ts`. Extend `ISessionActionBase` (for session-scoped) or define a standalone root action. Add it to the `ISessionAction` or `IRootAction` union. +4. **Add a reducer case** in `sessionReducers.ts`. The switch must remain exhaustive — the compiler will error if a case is missing. +5. **Add a v1 wire type** in `versions/v1.ts`. Mirror the action interface shape. Add it to the `IV1_SessionAction` or `IV1_RootAction` union. +6. **Register in `versionRegistry.ts`**: + - Import the new `IV1_*` type. + - Add an `AssertCompatible` check. + - Add the type to the `ISessionAction_v1` union. + - Add the type string to the suppress-warnings `void` expression. + - Add an entry to `ACTION_INTRODUCED_IN` (compiler enforces this). +7. **Update `protocol.md`** (this file) — add the action to the Actions table. +8. **Verify the E2E test passes.** + +### Adding a new command + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`. The test should fail until the implementation is complete. +2. **Define the request params and result interfaces** in `sessionProtocol.ts`. +3. **Handle it in `protocolServerHandler.ts`** `_handleRequestAsync()`. The method returns the result; the caller wraps it in a JSON-RPC response or error automatically. +4. **Add the side-effect** in `IProtocolSideEffectHandler` if the command requires I/O or agent interaction. Implement it in `agentHostServerMain.ts`. +5. **Update `protocol.md`** — add the command to the Commands table. +6. **Verify the E2E test passes.** + +### Adding a new state field + +1. **Add the field** to the relevant interface in `sessionState.ts` (e.g. `ISessionSummary`, `IActiveTurn`, `ITurn`). +2. **Update the factory** (`createSessionState()`, `createActiveTurn()`) to initialize the field. +3. **Add to the v1 wire type** in `versions/v1.ts`. Optional fields are safe; required fields break the bidirectional `AssertCompatible` check (intentionally — add as optional or bump the protocol version). +4. **Update reducers** in `sessionReducers.ts` if the field needs to be mutated by actions. +5. **Update `finalizeTurn()`** if the field lives on `IActiveTurn` and should transfer to `ITurn` on completion. + +### Adding a new notification + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`. +2. **Define the notification interface** in `sessionActions.ts`. Add it to the `INotification` union. +3. **Add to `NOTIFICATION_INTRODUCED_IN`** in `versionRegistry.ts`. +4. **Emit it** from `SessionStateManager` or the relevant server-side code. +5. **Verify the E2E test passes.** + +### Adding mock agent support (for testing) + +1. **Add a prompt case** in `mockAgent.ts` `sendMessage()` to trigger the behavior. +2. **Fire the corresponding `IAgentProgressEvent`** via `_fireSequence()` or manually through `_onDidSessionProgress`. + + +## URI-based subscriptions + +All state is identified by URIs. Clients subscribe to a URI to receive its current state snapshot and subsequent action updates. This is the single universal mechanism for state synchronization: + +- **Root state** (`agenthost:root`) — always-present global state (agents and their models). Clients subscribe to this on connect. +- **Session state** (`copilot:/`, etc.) — per-session state loaded on demand. Clients subscribe when opening a session. + +The `subscribe(uri)` / `unsubscribe(uri)` mechanism works identically for all resource types. + +## State model + +### Root state + +Subscribable at `agenthost:root`. Contains global, lightweight data that all clients need. **Does not contain the session list** — that is fetched imperatively via RPC (see Commands). + +``` +RootState { + agents: AgentInfo[] +} +``` + +Each `AgentInfo` includes the models available for that agent: + +``` +AgentInfo { + provider: string + displayName: string + description: string + models: ModelInfo[] +} +``` + +### Session state + +Subscribable at the session's URI (e.g. `copilot:/`). Contains the full state for a single session. + +``` +SessionState { + summary: SessionSummary + lifecycle: 'creating' | 'ready' | 'creationFailed' + creationError?: ErrorInfo + turns: Turn[] + activeTurn: ActiveTurn | undefined +} +``` + +`lifecycle` tracks the asynchronous creation process. When a client creates a session, it picks a URI, sends the command, and subscribes immediately. The initial snapshot has `lifecycle: 'creating'`. The server asynchronously initializes the backend and dispatches `session/ready` or `session/creationFailed`. + +``` +Turn { + id: string + userMessage: UserMessage + responseParts: ResponsePart[] + toolCalls: CompletedToolCall[] + usage: UsageInfo | undefined + state: 'complete' | 'cancelled' | 'error' +} + +ActiveTurn { + id: string + userMessage: UserMessage + streamingText: string + responseParts: ResponsePart[] + toolCalls: Map + pendingPermissions: Map + reasoning: string + usage: UsageInfo | undefined +} +``` + +### Session list + +The session list can be arbitrarily large and is **not** part of the state tree. Instead: +- Clients fetch the list imperatively via `listSessions()` RPC. +- The server sends lightweight **notifications** (`sessionAdded`, `sessionRemoved`) so connected clients can update a local cache without re-fetching. + +Notifications are ephemeral — not processed by reducers, not stored in state, not replayed on reconnect. On reconnect, clients re-fetch the list. + +### Content references + +Large content is **not** inlined in state. A `ContentRef` placeholder is used instead: + +``` +ContentRef { + uri: string // scheme://sessionId/contentId + sizeHint?: number + mimeType?: string +} +``` + +Clients fetch content separately via `fetchContent(uri)`. This keeps the state tree small and serializable. + +## Actions + +Actions are the sole mutation mechanism for subscribable state. They form a discriminated union keyed by `type`. Every action is wrapped in an `ActionEnvelope` for sequencing and origin tracking. + +### Action envelope + +``` +ActionEnvelope { + action: Action + serverSeq: number // monotonic, assigned by server + origin: { clientId: string, clientSeq: number } | undefined // undefined = server-originated +} +``` + +### Root actions + +These mutate the root state. **All root actions are server-only** — clients observe them but cannot produce them. + +| Type | Payload | When | +|---|---|---| +| `root/agentsChanged` | `AgentInfo[]` | Available agent backends or their models changed | + +### Session actions + +All scoped to a session URI. Some are server-only (produced by the agent backend), others can be dispatched directly by clients. + +When a client dispatches an action, the server applies it to the state and also reacts to it as a side effect (e.g., `session/turnStarted` triggers agent processing, `session/turnCancelled` aborts it). This avoids a separate command→action translation layer for the common interactive cases. + +| Type | Payload | Client-dispatchable? | When | +|---|---|---|---| +| `session/ready` | — | No | Session backend initialized successfully | +| `session/creationFailed` | `ErrorInfo` | No | Session backend failed to initialize | +| `session/turnStarted` | `turnId, UserMessage` | Yes | User sent a message; server starts processing | +| `session/delta` | `turnId, content` | No | Streaming text chunk from assistant | +| `session/responsePart` | `turnId, ResponsePart` | No | Structured content appended | +| `session/toolStart` | `turnId, ToolCallState` | No | Tool execution began | +| `session/toolComplete` | `turnId, toolCallId, ToolCallResult` | No | Tool execution finished | +| `session/permissionRequest` | `turnId, PermissionRequest` | No | Permission needed from user | +| `session/permissionResolved` | `turnId, requestId, approved` | Yes | Permission granted or denied | +| `session/turnComplete` | `turnId` | No | Turn finished (assistant idle) | +| `session/turnCancelled` | `turnId` | Yes | Turn was aborted; server stops processing | +| `session/error` | `turnId, ErrorInfo` | No | Error during turn processing | +| `session/titleChanged` | `title` | No | Session title updated | +| `session/usage` | `turnId, UsageInfo` | No | Token usage report | +| `session/reasoning` | `turnId, content` | No | Reasoning/thinking text | +| `session/modelChanged` | `model` | Yes | Model changed for this session | + +### Notifications + +Notifications are ephemeral broadcasts that are **not** part of the state tree. They are not processed by reducers and are not replayed on reconnect. + +| Type | Payload | When | +|---|---|---| +| `notify/sessionAdded` | `SessionSummary` | A new session was created | +| `notify/sessionRemoved` | session `URI` | A session was disposed | + +Clients use notifications to maintain a local session list cache. On reconnect, clients should re-fetch via `listSessions()` rather than relying on replayed notifications. + +## Commands and client-dispatched actions + +Clients interact with the server in two ways: + +1. **Dispatching actions** — the client sends an action directly (e.g., `session/turnStarted`, `session/turnCancelled`). The server applies it to state and reacts with side effects. These are write-ahead: the client applies them optimistically. +2. **Sending commands** — imperative RPCs for operations that don't map to a single state action (session creation, fetching data, etc.). + +### Client-dispatched actions + +| Action | Server-side effect | +|---|---| +| `session/turnStarted` | Begins agent processing for the new turn | +| `session/permissionResolved` | Unblocks the pending tool execution | +| `session/turnCancelled` | Aborts the in-progress turn | + +### Commands + +| Command | Effect | +|---|---| +| `createSession(uri, config)` | Server creates session, client subscribes to URI | +| `disposeSession(session)` | Server disposes session, broadcasts `sessionRemoved` notification | +| `listSessions(filter?)` | Returns `SessionSummary[]` | +| `fetchContent(uri)` | Returns content bytes | +| `fetchTurns(session, range)` | Returns historical turns | + +### Session creation flow + +1. Client picks a session URI (e.g. `copilot:/`) +2. Client sends `createSession(uri, config)` command +3. Client sends `subscribe(uri)` (can be batched with the command) +4. Server creates the session in state with `lifecycle: 'creating'` and sends the subscription snapshot +5. Server asynchronously initializes the agent backend +6. On success: server dispatches `session/ready` action +7. On failure: server dispatches `session/creationFailed` action with error details +8. Server broadcasts `notify/sessionAdded` to all clients + +## Client-server protocol + +The protocol uses **JSON-RPC 2.0** framing over the transport (WebSocket, MessagePort, etc.). + +### Message categories + +- **Client → Server notifications** (fire-and-forget): `initialize`, `reconnect`, `unsubscribe`, `dispatchAction` +- **Client → Server requests** (expect a correlated response): `subscribe`, `createSession`, `disposeSession`, `listSessions`, `fetchTurns`, `fetchContent` +- **Server → Client notifications** (pushed): `serverHello`, `reconnectResponse`, `action`, `notification` +- **Server → Client responses** (correlated to requests by `id`): success result or JSON-RPC error + +### Connection handshake + +``` +1. Client → Server: { "jsonrpc": "2.0", "method": "initialize", "params": { protocolVersion, clientId, initialSubscriptions? } } +2. Server → Client: { "jsonrpc": "2.0", "method": "serverHello", "params": { protocolVersion, serverSeq, snapshots[] } } +``` + +`initialSubscriptions` allows the client to subscribe to root state (and any previously-open sessions on reconnect) in the same round-trip as the handshake. The server responds with snapshots for each. + +### URI subscription + +`subscribe` is a JSON-RPC **request** — the client receives the snapshot as the response result: + +``` +Client → Server: { "jsonrpc": "2.0", "id": 1, "method": "subscribe", "params": { "resource": "copilot:/session-1" } } +Server → Client: { "jsonrpc": "2.0", "id": 1, "result": { "resource": ..., "state": ..., "fromSeq": 5 } } +``` + +After subscribing, the client receives all actions scoped to that URI with `serverSeq > fromSeq`. Multiple concurrent subscriptions are supported. + +`unsubscribe` is a notification (no response needed): + +``` +Client → Server: { "jsonrpc": "2.0", "method": "unsubscribe", "params": { "resource": "copilot:/session-1" } } +``` + +### Action delivery + +The server broadcasts action envelopes as JSON-RPC notifications: + +``` +Server → Client: { "jsonrpc": "2.0", "method": "action", "params": { "envelope": { action, serverSeq, origin } } } +``` + +- Root actions go to all clients subscribed to root state. +- Session actions go to all clients subscribed to that session's URI. + +Protocol notifications (sessionAdded/sessionRemoved) are broadcast similarly: + +``` +Server → Client: { "jsonrpc": "2.0", "method": "notification", "params": { "notification": { type, ... } } } +``` + +### Commands as JSON-RPC requests + +Commands are JSON-RPC requests. The server returns a result or a JSON-RPC error: + +``` +Client → Server: { "jsonrpc": "2.0", "id": 2, "method": "createSession", "params": { session, provider?, model? } } +Server → Client: { "jsonrpc": "2.0", "id": 2, "result": null } +``` + +On failure: + +``` +Server → Client: { "jsonrpc": "2.0", "id": 2, "error": { "code": -32603, "message": "No agent for provider" } } +``` + +### Client-dispatched actions + +Actions are sent as notifications (fire-and-forget, write-ahead): + +``` +Client → Server: { "jsonrpc": "2.0", "method": "dispatchAction", "params": { clientSeq, action } } +``` + +### Reconnection + +``` +Client → Server: { "jsonrpc": "2.0", "method": "reconnect", "params": { clientId, lastSeenServerSeq, subscriptions } } +``` + +Server replays actions since `lastSeenServerSeq` from a bounded replay buffer. If the gap exceeds the buffer, sends fresh snapshots via a `reconnectResponse` notification. Notifications are **not** replayed — the client should re-fetch the session list. + +## Write-ahead reconciliation + +### Client-side state + +Each client maintains per-subscription: +- `confirmedState` — last fully server-acknowledged state +- `pendingActions[]` — optimistically applied but not yet echoed by server +- `optimisticState` — `confirmedState` with `pendingActions` replayed on top (computed, not stored) + +### Reconciliation algorithm + +When the client receives an `ActionEnvelope` from the server: + +1. **Own action echoed**: `origin.clientId === myId` and matches head of `pendingActions` → pop from pending, apply to `confirmedState` +2. **Foreign action**: different origin → apply to `confirmedState`, rebase remaining `pendingActions` +3. **Rejected action**: server echoed with `rejected: true` → remove from pending (optimistic effect reverted) +4. Recompute `optimisticState` from `confirmedState` + remaining `pendingActions` + +### Why rebasing is simple + +Most session actions are **append-only** (add turn, append delta, add tool call). Pending actions still apply cleanly to an updated confirmed state because they operate on independent data (the turn the client created still exists; the content it appended is additive). The rare true conflict (two clients abort the same turn) is resolved by server-wins semantics. + +## Versioning + +### Protocol version + +Two constants define the version window: +- `PROTOCOL_VERSION` — the current version that new code speaks. +- `MIN_PROTOCOL_VERSION` — the oldest version we maintain compatibility with. + +Bump `PROTOCOL_VERSION` when: +- A new feature area requires capability negotiation (e.g., client must know server supports it before sending commands) +- Behavioral semantics of existing actions change + +Adding **optional** fields to existing action/state types does NOT require a bump. Adding **required** fields or removing/renaming fields **is a compile error** (see below). + +``` +Version history: + 1 — Initial: core session lifecycle, streaming, tools, permissions +``` + +### Version type snapshots + +Each protocol version has a type file (`versions/v1.ts`, `versions/v2.ts`, etc.) that captures the wire format shape of every state type and action type in that version. + +The **latest** version file is the editable "tip" — it can be modified alongside the living types in `sessionState.ts` / `sessionActions.ts`. The compiler enforces that all changes are backwards-compatible. When `PROTOCOL_VERSION` is bumped, the previous version file becomes truly frozen and a new tip is created. + +The version registry (`versions/versionRegistry.ts`) performs **bidirectional assignability checks** between the version types and the living types: + +```typescript +// AssertCompatible requires BOTH directions: +// Current extends Frozen → can't remove fields or change field types +// Frozen extends Current → can't add required fields +// The only allowed evolution is adding optional fields. +type AssertCompatible = Frozen extends Current ? true : never; + +type _check = AssertCompatible; +``` + +| Change to living type | Also update tip? | Compile result | +|---|---|---| +| Add optional field | Yes, add it to tip too | ✅ Passes | +| Add optional field | No, only in living type | ✅ Passes (tip is a subset) | +| Remove a field | — | ❌ `Current extends Frozen` fails | +| Change a field's type | — | ❌ `Current extends Frozen` fails | +| Add required field | — | ❌ `Frozen extends Current` fails | + +### Exhaustive action→version map + +The registry also maintains an exhaustive runtime map: + +```typescript +export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { + 'root/agentsChanged': 1, + 'session/turnStarted': 1, + // ...every action type must have an entry +}; +``` + +The index signature `[K in IStateAction['type']]` means adding a new action to the `IStateAction` union without adding it to this map is a compile error. The developer is forced to pick a version number. + +The server uses this for one-line filtering — no if/else chains: + +```typescript +function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { + return ACTION_INTRODUCED_IN[action.type] <= clientVersion; +} +``` + +### Capabilities + +The protocol version maps to a `ProtocolCapabilities` interface for higher-level feature gating: + +```typescript +interface ProtocolCapabilities { + // v1 — always present + readonly sessions: true; + readonly tools: true; + readonly permissions: true; + // v2+ + readonly reasoning?: true; +} +``` + +### Forward compatibility + +A newer client connecting to an older server: +1. During handshake, the client learns the server's protocol version. +2. The client derives `ProtocolCapabilities` from the server version. +3. Command factories check capabilities before dispatching; if unsupported, the client degrades gracefully. +4. The server only sends action types known to the client's declared version (via `isActionKnownToVersion`). +5. As a safety net, clients silently ignore actions with unrecognized `type` values. + +### Raising the minimum version + +When `MIN_PROTOCOL_VERSION` is raised from N to N+1: +1. Delete `versions/vN.ts`. +2. Remove the vN compatibility checks from `versions/versionRegistry.ts`. +3. The compiler surfaces any dead code that only existed for vN compatibility. +4. Clean up that dead code. + +### Backward compatibility + +We do not guarantee backward compatibility (older clients connecting to newer servers). Clients should update before the server. + +### Adding a new protocol version (cookbook) + +1. Bump `PROTOCOL_VERSION` in `versions/versionRegistry.ts`. +2. Create `versions/v{N}.ts` — freeze the current types (copy from v{N-1} and add your new types). +3. Add your new action types to the living union in `sessionActions.ts`. +4. Add entries to `ACTION_INTRODUCED_IN` with version N (compiler forces this). +5. Add `AssertCompatible` checks for the new types in `versionRegistry.ts`. +6. Add reducer cases for the new actions (in new functions if desired). +7. Add capability fields to `ProtocolCapabilities` if needed. + +## Reducers + +State is mutated by pure reducer functions that take `(state, action) → newState`. The same reducer code runs on both server and client, which is what makes write-ahead possible: the client can locally predict the result of its own action using the same logic the server will run. + +``` +rootReducer(state: RootState, action: RootAction): RootState +sessionReducer(state: SessionState, action: SessionAction): SessionState +``` + +Reducers are pure (no side effects, no I/O). Server-side effects (e.g. forwarding a `sendMessage` command to the Copilot SDK) are handled by a separate dispatch layer, not in the reducer. + +## File layout + +``` +src/vs/platform/agent/common/state/ +├── sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) +├── sessionActions.ts # Action + notification discriminated unions, ActionEnvelope +├── sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) +├── sessionProtocol.ts # JSON-RPC message types, request params/results, type guards +├── sessionCapabilities.ts # Re-exports version constants + ProtocolCapabilities +├── sessionClientState.ts # Client-side state manager (confirmed + pending + reconciliation) +└── versions/ + ├── v1.ts # v1 wire format types (tip — editable, compiler-enforced compat) + └── versionRegistry.ts # Compile-time compat checks + runtime action→version map +``` + +## Relationship to existing IPC contract + +The existing `IAgentProgressEvent` union in `agentService.ts` captures raw streaming events from the Copilot SDK. The new action types in `sessionActions.ts` are a higher-level abstraction: they represent state transitions rather than SDK events. + +In the server process, the mapping is: +- `IAgentDeltaEvent` → `session/delta` action +- `IAgentToolStartEvent` → `session/toolStart` action +- `IAgentIdleEvent` → `session/turnComplete` action +- etc. + +The existing `IAgentService` RPC interface remains unchanged. The new protocol layer sits on top: the sessions process uses `IAgentService` internally to talk to agent backends, and produces actions for connected clients. diff --git a/src/vs/platform/agentHost/sessions.md b/src/vs/platform/agentHost/sessions.md new file mode 100644 index 00000000000..b200662497a --- /dev/null +++ b/src/vs/platform/agentHost/sessions.md @@ -0,0 +1,62 @@ +## Chat sessions / background agent architecture + +> **Keep this document in sync with the code.** If you change how session types are registered, modify the extension point, or update the agent-host's registration pattern, update this document as part of the same change. + +There are **three layers** that connect to form a chat session type (like "Background Agent" / "Copilot CLI"): + +### Layer 1: `chatSessions` Extension Point (package.json) + +In package.json, the extension contributes to the `"chatSessions"` extension point. Each entry declares a session **type** (used as a URI scheme), a **name** (used as a chat participant name like `@cli`), display metadata, capabilities, slash commands, and a `when` clause for conditional availability. + +### Layer 2: VS Code Platform -- Extension Point + Service + +On the VS Code side: + +- chatSessions.contribution.ts -- Registers the `chatSessions` extension point via `ExtensionsRegistry.registerExtensionPoint`. When extensions contribute to it, the `ChatSessionsService` processes each contribution: it sets up context keys, icons, welcome messages, commands, and -- if `canDelegate` is true -- also **registers a dynamic chat agent**. + +- chatSessionsService.ts -- The `IChatSessionsService` interface manages two kinds of providers: + - **`IChatSessionItemController`** -- Lists available sessions + - **`IChatSessionContentProvider`** -- Provides session content (history + request handler) when you open a specific session + +- agentSessions.ts -- The `AgentSessionProviders` enum maps well-known types to their string identifiers: + - `Local` = `'local'` + - `Background` = `'copilotcli'` + - `Cloud` = `'copilot-cloud-agent'` + - `Claude` = `'claude-code'` + - `Codex` = `'openai-codex'` + - `Growth` = `'copilot-growth'` + - `AgentHostCopilot` = `'agent-host-copilot'` + +### Layer 3: Extension Side Registration + +Each session type registers three things via the proposed API: + +1. **`vscode.chat.registerChatSessionItemProvider(type, provider)`** -- Provides the list of sessions +2. **`vscode.chat.createChatParticipant(type, handler)`** -- Creates the chat participant +3. **`vscode.chat.registerChatSessionContentProvider(type, contentProvider, chatParticipant)`** -- Binds content provider to participant + +### Agent Host: Internal (Non-Extension) Registration + +The agent-host session types (`agent-host-copilot`) bypass the extension point entirely. A single `AgentHostContribution` discovers available agents from the agent host process via `listAgents()` and dynamically registers each one: + +**For each `IAgentDescriptor` returned by `listAgents()`:** +1. Chat session contribution via `IChatSessionsService.registerChatSessionContribution()` +2. Session item controller via `IChatSessionsService.registerChatSessionItemController()` +3. Session content provider via `IChatSessionsService.registerChatSessionContentProvider()` +4. Language model provider via `ILanguageModelsService.registerLanguageModelProvider()` +5. Auth token push (only if `descriptor.requiresAuth` is true) + +All use the same generic `AgentHostSessionHandler` class, configured with the descriptor's metadata. + +### All Entry Points + +| # | Entry Point | File | +|---|-------------|------| +| 1 | **package.json `chatSessions` contribution** | package.json -- declares type, name, capabilities | +| 2 | **Extension point handler** | chatSessions.contribution.ts -- processes contributions | +| 3 | **Service interface** | chatSessionsService.ts -- `IChatSessionsService` | +| 4 | **Proposed API** | vscode.proposed.chatSessionsProvider.d.ts | +| 5 | **Agent session provider enum** | agentSessions.ts -- `AgentSessionProviders` | +| 6 | **Agent Host contribution** | agentHost/agentHostChatContribution.ts -- `AgentHostContribution` (discovers + registers dynamically) | +| 7 | **Agent Host process** | src/vs/platform/agent/ -- utility process, SDK integration | +| 8 | **Desktop registration** | electron-browser/chat.contribution.ts -- registers `AgentHostContribution` | diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts new file mode 100644 index 00000000000..05525a89c5a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agentService.js'; + +suite('AgentSession namespace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uri creates a URI with provider as scheme and id as path', () => { + const session = AgentSession.uri('copilot', 'abc-123'); + assert.strictEqual(session.scheme, 'copilot'); + assert.strictEqual(session.path, '/abc-123'); + }); + + test('id extracts the raw session ID from a session URI', () => { + const session = URI.from({ scheme: 'copilot', path: '/my-session-42' }); + assert.strictEqual(AgentSession.id(session), 'my-session-42'); + }); + + test('uri and id are inverse operations', () => { + const rawId = 'test-session-xyz'; + const session = AgentSession.uri('copilot', rawId); + assert.strictEqual(AgentSession.id(session), rawId); + }); + + test('provider extracts copilot from a copilot-scheme URI', () => { + const session = AgentSession.uri('copilot', 'sess-1'); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); + + test('provider returns undefined for an unknown scheme', () => { + const session = URI.from({ scheme: 'agent-host-copilot', path: '/sess-1' }); + assert.strictEqual(AgentSession.provider(session), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts new file mode 100644 index 00000000000..a152f4a454a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { + IAgentDeltaEvent, + IAgentErrorEvent, + IAgentIdleEvent, + IAgentMessageEvent, + IAgentPermissionRequestEvent, + IAgentReasoningEvent, + IAgentTitleChangedEvent, + IAgentToolCompleteEvent, + IAgentToolStartEvent, + IAgentUsageEvent, +} from '../../common/agentService.js'; +import type { + IDeltaAction, + IPermissionRequestAction, + IReasoningAction, + ISessionErrorAction, + ITitleChangedAction, + IToolCompleteAction, + IToolStartAction, + ITurnCompleteAction, + IUsageAction, +} from '../../common/state/sessionActions.js'; +import { ToolCallStatus } from '../../common/state/sessionState.js'; +import { mapProgressEventToAction } from '../../node/agentEventMapper.js'; + +suite('AgentEventMapper', () => { + + const session = URI.from({ scheme: 'copilot', path: '/test-session' }); + const turnId = 'turn-1'; + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('delta event maps to session/delta action', () => { + const event: IAgentDeltaEvent = { + session, + type: 'delta', + messageId: 'msg-1', + content: 'hello world', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/delta'); + const delta = action as IDeltaAction; + assert.strictEqual(delta.content, 'hello world'); + assert.strictEqual(delta.session.toString(), session.toString()); + assert.strictEqual(delta.turnId, turnId); + }); + + test('tool_start event maps to session/toolStart action', () => { + const event: IAgentToolStartEvent = { + session, + type: 'tool_start', + toolCallId: 'tc-1', + toolName: 'readFile', + displayName: 'Read File', + invocationMessage: 'Reading file...', + toolInput: '/src/foo.ts', + toolKind: 'terminal', + language: 'shellscript', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/toolStart'); + const toolCall = (action as IToolStartAction).toolCall; + assert.strictEqual(toolCall.toolCallId, 'tc-1'); + assert.strictEqual(toolCall.toolName, 'readFile'); + assert.strictEqual(toolCall.displayName, 'Read File'); + assert.strictEqual(toolCall.invocationMessage, 'Reading file...'); + assert.strictEqual(toolCall.toolInput, '/src/foo.ts'); + assert.strictEqual(toolCall.toolKind, 'terminal'); + assert.strictEqual(toolCall.language, 'shellscript'); + assert.strictEqual(toolCall.status, ToolCallStatus.Running); + }); + + test('tool_complete event maps to session/toolComplete action', () => { + const event: IAgentToolCompleteEvent = { + session, + type: 'tool_complete', + toolCallId: 'tc-1', + success: true, + pastTenseMessage: 'Read file successfully', + toolOutput: 'file contents here', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/toolComplete'); + const complete = action as IToolCompleteAction; + assert.strictEqual(complete.toolCallId, 'tc-1'); + assert.strictEqual(complete.result.success, true); + assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully'); + assert.strictEqual(complete.result.toolOutput, 'file contents here'); + }); + + test('idle event maps to session/turnComplete action', () => { + const event: IAgentIdleEvent = { + session, + type: 'idle', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/turnComplete'); + const turnComplete = action as ITurnCompleteAction; + assert.strictEqual(turnComplete.session.toString(), session.toString()); + assert.strictEqual(turnComplete.turnId, turnId); + }); + + test('error event maps to session/error action', () => { + const event: IAgentErrorEvent = { + session, + type: 'error', + errorType: 'runtime', + message: 'Something went wrong', + stack: 'Error: Something went wrong\n at foo.ts:1', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/error'); + const errorAction = action as ISessionErrorAction; + assert.strictEqual(errorAction.error.errorType, 'runtime'); + assert.strictEqual(errorAction.error.message, 'Something went wrong'); + assert.strictEqual(errorAction.error.stack, 'Error: Something went wrong\n at foo.ts:1'); + }); + + test('usage event maps to session/usage action', () => { + const event: IAgentUsageEvent = { + session, + type: 'usage', + inputTokens: 100, + outputTokens: 50, + model: 'gpt-4', + cacheReadTokens: 25, + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/usage'); + const usageAction = action as IUsageAction; + assert.strictEqual(usageAction.usage.inputTokens, 100); + assert.strictEqual(usageAction.usage.outputTokens, 50); + assert.strictEqual(usageAction.usage.model, 'gpt-4'); + assert.strictEqual(usageAction.usage.cacheReadTokens, 25); + }); + + test('title_changed event maps to session/titleChanged action', () => { + const event: IAgentTitleChangedEvent = { + session, + type: 'title_changed', + title: 'New Title', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/titleChanged'); + assert.strictEqual((action as ITitleChangedAction).title, 'New Title'); + }); + + test('permission_request event maps to session/permissionRequest action', () => { + const event: IAgentPermissionRequestEvent = { + session, + type: 'permission_request', + requestId: 'perm-1', + permissionKind: 'shell', + toolCallId: 'tc-2', + fullCommandText: 'rm -rf /', + intention: 'Delete all files', + rawRequest: '{}', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/permissionRequest'); + const req = (action as IPermissionRequestAction).request; + assert.strictEqual(req.requestId, 'perm-1'); + assert.strictEqual(req.permissionKind, 'shell'); + assert.strictEqual(req.toolCallId, 'tc-2'); + assert.strictEqual(req.fullCommandText, 'rm -rf /'); + assert.strictEqual(req.intention, 'Delete all files'); + }); + + test('reasoning event maps to session/reasoning action', () => { + const event: IAgentReasoningEvent = { + session, + type: 'reasoning', + content: 'Let me think about this...', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.ok(action); + assert.strictEqual(action.type, 'session/reasoning'); + const reasoning = action as IReasoningAction; + assert.strictEqual(reasoning.content, 'Let me think about this...'); + assert.strictEqual(reasoning.turnId, turnId); + }); + + test('message event returns undefined', () => { + const event: IAgentMessageEvent = { + session, + type: 'message', + role: 'assistant', + messageId: 'msg-1', + content: 'Some full message', + }; + + const action = mapProgressEventToAction(event, session, turnId); + assert.strictEqual(action, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts new file mode 100644 index 00000000000..80150b428a1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession } from '../../common/agentService.js'; +import { IActionEnvelope } from '../../common/state/sessionActions.js'; +import { AgentService } from '../../node/agentService.js'; +import { MockAgent } from './mockAgent.js'; + +suite('AgentService (node dispatcher)', () => { + + const disposables = new DisposableStore(); + let service: AgentService; + let copilotAgent: MockAgent; + + setup(() => { + service = disposables.add(new AgentService(new NullLogService())); + copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Provider registration ------------------------------------------ + + suite('registerProvider', () => { + + test('registers a provider successfully', () => { + service.registerProvider(copilotAgent); + // No throw - success + }); + + test('throws on duplicate provider registration', () => { + service.registerProvider(copilotAgent); + const duplicate = new MockAgent('copilot'); + disposables.add(toDisposable(() => duplicate.dispose())); + assert.throws(() => service.registerProvider(duplicate), /already registered/); + }); + + test('maps progress events to protocol actions via onDidAction', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + + // Start a turn so there's an active turn to map events to + service.dispatchAction( + { type: 'session/turnStarted', session, turnId: 'turn-1', userMessage: { text: 'hello' } }, + 'test-client', 1, + ); + + const envelopes: IActionEnvelope[] = []; + disposables.add(service.onDidAction(e => envelopes.push(e))); + + copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); + assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + }); + }); + + // ---- listAgents ----------------------------------------------------- + + suite('listAgents', () => { + + test('returns descriptors from all registered providers', async () => { + service.registerProvider(copilotAgent); + + const agents = await service.listAgents(); + assert.strictEqual(agents.length, 1); + assert.ok(agents.some(a => a.provider === 'copilot')); + }); + + test('returns empty array when no providers are registered', async () => { + const agents = await service.listAgents(); + assert.strictEqual(agents.length, 0); + }); + }); + + // ---- createSession -------------------------------------------------- + + suite('createSession', () => { + + test('creates session via specified provider', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); + + test('uses default provider when none specified', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession(); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); + + test('throws when no providers are registered at all', async () => { + await assert.rejects(() => service.createSession(), /No agent provider/); + }); + }); + + // ---- disposeSession ------------------------------------------------- + + suite('disposeSession', () => { + + test('dispatches to the correct provider and cleans up tracking', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + await service.disposeSession(session); + + assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1); + }); + + test('is a no-op for unknown sessions', async () => { + service.registerProvider(copilotAgent); + const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); + + // Should not throw + await service.disposeSession(unknownSession); + }); + }); + + // ---- setAuthToken --------------------------------------------------- + + suite('setAuthToken', () => { + + test('broadcasts token to all registered providers', async () => { + service.registerProvider(copilotAgent); + + await service.setAuthToken('my-token'); + + assert.strictEqual(copilotAgent.setAuthTokenCalls.length, 1); + assert.strictEqual(copilotAgent.setAuthTokenCalls[0], 'my-token'); + }); + }); + + // ---- listSessions / listModels -------------------------------------- + + suite('aggregation', () => { + + test('listSessions aggregates sessions from all providers', async () => { + service.registerProvider(copilotAgent); + + await service.createSession({ provider: 'copilot' }); + + const sessions = await service.listSessions(); + assert.strictEqual(sessions.length, 1); + }); + + test('refreshModels publishes models in root state via agentsChanged', async () => { + service.registerProvider(copilotAgent); + + const envelopes: IActionEnvelope[] = []; + disposables.add(service.onDidAction(e => envelopes.push(e))); + + service.refreshModels(); + + // Model fetch is async inside AgentSideEffects — wait for it + await new Promise(r => setTimeout(r, 50)); + + const agentsChanged = envelopes.find(e => e.action.type === 'root/agentsChanged'); + assert.ok(agentsChanged); + }); + }); + + // ---- shutdown ------------------------------------------------------- + + suite('shutdown', () => { + + test('shuts down all providers', async () => { + let copilotShutdown = false; + copilotAgent.shutdown = async () => { copilotShutdown = true; }; + + service.registerProvider(copilotAgent); + + await service.shutdown(); + assert.ok(copilotShutdown); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts new file mode 100644 index 00000000000..38676453875 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -0,0 +1,289 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession, IAgent } from '../../common/agentService.js'; +import { IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { SessionStatus } from '../../common/state/sessionState.js'; +import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; +import { MockAgent } from './mockAgent.js'; + +// ---- Tests ------------------------------------------------------------------ + +suite('AgentSideEffects', () => { + + const disposables = new DisposableStore(); + let stateManager: SessionStateManager; + let agent: MockAgent; + let sideEffects: AgentSideEffects; + let agentList: ReturnType>; + + const sessionUri = AgentSession.uri('mock', 'session-1'); + + function setupSession(): void { + stateManager.createSession({ + resource: sessionUri, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + } + + function startTurn(turnId: string): void { + stateManager.dispatchClientAction( + { type: 'session/turnStarted', session: sessionUri, turnId, userMessage: { text: 'hello' } }, + { clientId: 'test', clientSeq: 1 }, + ); + } + + setup(() => { + agent = new MockAgent(); + disposables.add(toDisposable(() => agent.dispose())); + stateManager = disposables.add(new SessionStateManager(new NullLogService())); + agentList = observableValue('agents', [agent]); + sideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => agent, + agents: agentList, + }, new NullLogService())); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- handleAction: session/turnStarted ------------------------------ + + suite('handleAction — session/turnStarted', () => { + + test('calls sendMessage on the agent', async () => { + setupSession(); + const action: ISessionAction = { + type: 'session/turnStarted', + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello world' }, + }; + sideEffects.handleAction(action); + + // sendMessage is async but fire-and-forget; wait a tick + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: sessionUri, prompt: 'hello world' }]); + }); + + test('dispatches session/error when no agent is found', async () => { + setupSession(); + const emptyAgents = observableValue('agents', []); + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: emptyAgents, + }, new NullLogService())); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + noAgentSideEffects.handleAction({ + type: 'session/turnStarted', + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + const errorAction = envelopes.find(e => e.action.type === 'session/error'); + assert.ok(errorAction, 'should dispatch session/error'); + }); + }); + + // ---- handleAction: session/turnCancelled ---------------------------- + + suite('handleAction — session/turnCancelled', () => { + + test('calls abortSession on the agent', async () => { + setupSession(); + sideEffects.handleAction({ + type: 'session/turnCancelled', + session: sessionUri, + turnId: 'turn-1', + }); + + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.abortSessionCalls, [sessionUri]); + }); + }); + + // ---- handleAction: session/permissionResolved ----------------------- + + suite('handleAction — session/permissionResolved', () => { + + test('routes permission response to the correct agent', () => { + setupSession(); + startTurn('turn-1'); + + // Simulate a permission_request progress event to populate the pending map + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ + session: sessionUri, + type: 'permission_request', + requestId: 'perm-1', + permissionKind: 'write', + path: 'file.ts', + rawRequest: '{}', + }); + + // Now resolve it + sideEffects.handleAction({ + type: 'session/permissionResolved', + session: sessionUri, + turnId: 'turn-1', + requestId: 'perm-1', + approved: true, + }); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [{ requestId: 'perm-1', approved: true }]); + }); + }); + + // ---- handleAction: session/modelChanged ----------------------------- + + suite('handleAction — session/modelChanged', () => { + + test('calls changeModel on the agent', async () => { + setupSession(); + sideEffects.handleAction({ + type: 'session/modelChanged', + session: sessionUri, + model: 'gpt-5', + }); + + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.changeModelCalls, [{ session: sessionUri, model: 'gpt-5' }]); + }); + }); + + // ---- registerProgressListener --------------------------------------- + + suite('registerProgressListener', () => { + + test('maps agent progress events to state actions', () => { + setupSession(); + startTurn('turn-1'); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' }); + + assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + }); + + test('returns a disposable that stops listening', () => { + setupSession(); + startTurn('turn-1'); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + const listener = sideEffects.registerProgressListener(agent); + + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' }); + assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + + listener.dispose(); + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' }); + assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + }); + }); + + // ---- handleCreateSession -------------------------------------------- + + suite('handleCreateSession', () => { + + test('creates a session and dispatches session/ready', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + await sideEffects.handleCreateSession({ session: sessionUri, provider: 'mock' }); + + const ready = envelopes.find(e => e.action.type === 'session/ready'); + assert.ok(ready, 'should dispatch session/ready'); + }); + + test('throws when no provider is specified', async () => { + await assert.rejects( + () => sideEffects.handleCreateSession({ session: sessionUri }), + /No provider specified/, + ); + }); + + test('throws when no agent matches provider', async () => { + const emptyAgents = observableValue('agents', []); + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: emptyAgents, + }, new NullLogService())); + + await assert.rejects( + () => noAgentSideEffects.handleCreateSession({ session: sessionUri, provider: 'nonexistent' }), + /No agent registered/, + ); + }); + }); + + // ---- handleDisposeSession ------------------------------------------- + + suite('handleDisposeSession', () => { + + test('disposes the session on the agent and removes state', async () => { + setupSession(); + + sideEffects.handleDisposeSession(sessionUri); + + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(agent.disposeSessionCalls.length, 1); + assert.strictEqual(stateManager.getSessionState(sessionUri), undefined); + }); + }); + + // ---- handleListSessions --------------------------------------------- + + suite('handleListSessions', () => { + + test('aggregates sessions from all agents', async () => { + await agent.createSession(); + const sessions = await sideEffects.handleListSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].provider, 'mock'); + assert.strictEqual(sessions[0].title, 'Session'); + }); + }); + + // ---- agents observable -------------------------------------------------- + + suite('agents observable', () => { + + test('dispatches root/agentsChanged when observable changes', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agentList.set([agent], undefined); + + // Model fetch is async — wait for it + await new Promise(r => setTimeout(r, 50)); + + const action = envelopes.find(e => e.action.type === 'root/agentsChanged'); + assert.ok(action, 'should dispatch root/agentsChanged'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts new file mode 100644 index 00000000000..6ddf3ac28c3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; + +/** + * General-purpose mock agent for unit tests. Tracks all method calls + * for assertion and exposes {@link fireProgress} to inject progress events. + */ +export class MockAgent implements IAgent { + private readonly _onDidSessionProgress = new Emitter(); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _sessions = new Map(); + private _nextId = 1; + + readonly setAuthTokenCalls: string[] = []; + readonly sendMessageCalls: { session: URI; prompt: string }[] = []; + readonly disposeSessionCalls: URI[] = []; + readonly abortSessionCalls: URI[] = []; + readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; + readonly changeModelCalls: { session: URI; model: string }[] = []; + + constructor(readonly id: AgentProvider = 'mock') { } + + getDescriptor(): IAgentDescriptor { + return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; + } + + async listModels(): Promise { + return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + } + + async listSessions(): Promise { + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now() })); + } + + async createSession(_config?: IAgentCreateSessionConfig): Promise { + const rawId = `${this.id}-session-${this._nextId++}`; + const session = AgentSession.uri(this.id, rawId); + this._sessions.set(rawId, session); + return session; + } + + async sendMessage(session: URI, prompt: string): Promise { + this.sendMessageCalls.push({ session, prompt }); + } + + async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + return []; + } + + async disposeSession(session: URI): Promise { + this.disposeSessionCalls.push(session); + this._sessions.delete(AgentSession.id(session)); + } + + async abortSession(session: URI): Promise { + this.abortSessionCalls.push(session); + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + this.respondToPermissionCalls.push({ requestId, approved }); + } + + async changeModel(session: URI, model: string): Promise { + this.changeModelCalls.push({ session, model }); + } + + async setAuthToken(token: string): Promise { + this.setAuthTokenCalls.push(token); + } + + async shutdown(): Promise { } + + fireProgress(event: IAgentProgressEvent): void { + this._onDidSessionProgress.fire(event); + } + + dispose(): void { + this._onDidSessionProgress.dispose(); + } +} + +export class ScriptedMockAgent implements IAgent { + readonly id: AgentProvider = 'mock'; + + private readonly _onDidSessionProgress = new Emitter(); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _sessions = new Map(); + private _nextId = 1; + + // Track pending permission requests + private readonly _pendingPermissions = new Map void>(); + // Track pending abort callbacks for slow responses + private readonly _pendingAborts = new Map void>(); + + getDescriptor(): IAgentDescriptor { + return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; + } + + async listModels(): Promise { + return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + } + + async listSessions(): Promise { + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now() })); + } + + async createSession(_config?: IAgentCreateSessionConfig): Promise { + const rawId = `mock-session-${this._nextId++}`; + const session = AgentSession.uri('mock', rawId); + this._sessions.set(rawId, session); + return session; + } + + async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise { + switch (prompt) { + case 'hello': + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Hello, world!' }, + { type: 'idle', session }, + ]); + break; + + case 'use-tool': + this._fireSequence(session, [ + { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'echo_tool', displayName: 'Echo Tool', invocationMessage: 'Running echo tool...' }, + { type: 'tool_complete', session, toolCallId: 'tc-1', success: true, pastTenseMessage: 'Ran echo tool', toolOutput: 'echoed' }, + { type: 'delta', session, messageId: 'msg-1', content: 'Tool done.' }, + { type: 'idle', session }, + ]); + break; + + case 'error': + this._fireSequence(session, [ + { type: 'error', session, errorType: 'test_error', message: 'Something went wrong' }, + ]); + break; + + case 'permission': { + // Fire permission_request, then wait for respondToPermissionRequest + const permEvent: IAgentProgressEvent = { + type: 'permission_request', + session, + requestId: 'perm-1', + permissionKind: 'shell', + fullCommandText: 'echo test', + intention: 'Run a test command', + rawRequest: JSON.stringify({ permissionKind: 'shell', fullCommandText: 'echo test', intention: 'Run a test command' }), + }; + setTimeout(() => this._onDidSessionProgress.fire(permEvent), 10); + this._pendingPermissions.set('perm-1', (approved) => { + if (approved) { + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Allowed.' }, + { type: 'idle', session }, + ]); + } + }); + break; + } + + case 'with-usage': + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Usage response.' }, + { type: 'usage', session, inputTokens: 100, outputTokens: 50, model: 'mock-model' }, + { type: 'idle', session }, + ]); + break; + + case 'slow': { + // Slow response for cancel testing — fires delta after a long delay + const timer = setTimeout(() => { + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Slow response.' }, + { type: 'idle', session }, + ]); + }, 5000); + this._pendingAborts.set(session.toString(), () => clearTimeout(timer)); + break; + } + + default: + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Unknown prompt: ' + prompt }, + { type: 'idle', session }, + ]); + break; + } + } + + async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + return []; + } + + async disposeSession(session: URI): Promise { + this._sessions.delete(AgentSession.id(session)); + } + + async abortSession(session: URI): Promise { + const callback = this._pendingAborts.get(session.toString()); + if (callback) { + this._pendingAborts.delete(session.toString()); + callback(); + } + } + + async changeModel(_session: URI, _model: string): Promise { + // Mock agent doesn't track model state + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + const callback = this._pendingPermissions.get(requestId); + if (callback) { + this._pendingPermissions.delete(requestId); + callback(approved); + } + } + + async setAuthToken(_token: string): Promise { } + + async shutdown(): Promise { } + + dispose(): void { + this._onDidSessionProgress.dispose(); + } + + private _fireSequence(session: URI, events: IAgentProgressEvent[]): void { + let delay = 0; + for (const event of events) { + delay += 10; + setTimeout(() => this._onDidSessionProgress.fire(event), delay); + } + } +} diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts new file mode 100644 index 00000000000..fbde7d8980c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 } from '../../../log/common/log.js'; +import type { ISessionAction } from '../../common/state/sessionActions.js'; +import { isJsonRpcNotification, isJsonRpcResponse, type ICreateSessionParams, type IProtocolMessage, type IProtocolNotification, type IServerHelloParams, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; +import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; +import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { ProtocolServerHandler, type IProtocolSideEffectHandler } from '../../node/protocolServerHandler.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockProtocolTransport implements IProtocolTransport { + private readonly _onMessage = new Emitter(); + readonly onMessage = this._onMessage.event; + private readonly _onClose = new Emitter(); + readonly onClose = this._onClose.event; + + readonly sent: IProtocolMessage[] = []; + + send(message: IProtocolMessage): void { + this.sent.push(message); + } + + simulateMessage(msg: IProtocolMessage): void { + this._onMessage.fire(msg); + } + + simulateClose(): void { + this._onClose.fire(); + } + + dispose(): void { + this._onMessage.dispose(); + this._onClose.dispose(); + } +} + +class MockProtocolServer implements IProtocolServer { + private readonly _onConnection = new Emitter(); + readonly onConnection = this._onConnection.event; + readonly address = 'mock://test'; + + simulateConnection(transport: IProtocolTransport): void { + this._onConnection.fire(transport); + } + + dispose(): void { + this._onConnection.dispose(); + } +} + +class MockSideEffectHandler implements IProtocolSideEffectHandler { + readonly handledActions: ISessionAction[] = []; + handleAction(action: ISessionAction): void { + this.handledActions.push(action); + } + async handleCreateSession(_command: ICreateSessionParams): Promise { } + handleDisposeSession(_session: URI): void { } + async handleListSessions(): Promise { return []; } +} + +// ---- Helpers ---------------------------------------------------------------- + +function notification(method: string, params?: unknown): IProtocolMessage { + return { jsonrpc: '2.0', method, params } as IProtocolMessage; +} + +function request(id: number, method: string, params?: unknown): IProtocolMessage { + return { jsonrpc: '2.0', id, method, params } as IProtocolMessage; +} + +function findNotification(sent: IProtocolMessage[], method: string): IProtocolNotification | undefined { + return sent.find(isJsonRpcNotification) as IProtocolNotification | undefined; +} + +function findNotifications(sent: IProtocolMessage[], method: string): IProtocolNotification[] { + return sent.filter(isJsonRpcNotification) as IProtocolNotification[]; +} + +function findResponse(sent: IProtocolMessage[], id: number): IProtocolMessage | undefined { + return sent.find(isJsonRpcResponse) as IProtocolMessage | undefined; +} + +// ---- Tests ------------------------------------------------------------------ + +suite('ProtocolServerHandler', () => { + + let disposables: DisposableStore; + let stateManager: SessionStateManager; + let server: MockProtocolServer; + let sideEffects: MockSideEffectHandler; + + const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }); + + function makeSessionSummary(resource?: URI): ISessionSummary { + return { + resource: resource ?? sessionUri, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + } + + function connectClient(clientId: string, initialSubscriptions?: readonly URI[]): MockProtocolTransport { + const transport = new MockProtocolTransport(); + server.simulateConnection(transport); + transport.simulateMessage(notification('initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId, + initialSubscriptions, + })); + return transport; + } + + setup(() => { + disposables = new DisposableStore(); + stateManager = disposables.add(new SessionStateManager(new NullLogService())); + server = disposables.add(new MockProtocolServer()); + sideEffects = new MockSideEffectHandler(); + disposables.add(new ProtocolServerHandler( + stateManager, + server, + sideEffects, + new NullLogService(), + )); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('handshake sends serverHello notification', () => { + const transport = connectClient('client-1'); + + const hello = findNotification(transport.sent, 'serverHello'); + assert.ok(hello, 'should have sent serverHello'); + const params = hello.params as IServerHelloParams; + assert.strictEqual(params.protocolVersion, PROTOCOL_VERSION); + assert.strictEqual(params.serverSeq, stateManager.serverSeq); + }); + + test('handshake with initialSubscriptions returns snapshots', () => { + stateManager.createSession(makeSessionSummary()); + + const transport = connectClient('client-1', [sessionUri]); + + const hello = findNotification(transport.sent, 'serverHello'); + assert.ok(hello); + const params = hello.params as IServerHelloParams; + assert.strictEqual(params.snapshots.length, 1); + assert.strictEqual(params.snapshots[0].resource.toString(), sessionUri.toString()); + }); + + test('subscribe request returns snapshot', async () => { + stateManager.createSession(makeSessionSummary()); + + const transport = connectClient('client-1'); + transport.sent.length = 0; + + transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri })); + + // Wait for async response + await new Promise(resolve => setTimeout(resolve, 10)); + + const resp = findResponse(transport.sent, 1); + assert.ok(resp, 'should have sent response'); + const snapshot = (resp as { result: IStateSnapshot }).result; + assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); + }); + + test('client action is dispatched and echoed', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + const transport = connectClient('client-1', [sessionUri]); + transport.sent.length = 0; + + transport.simulateMessage(notification('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/turnStarted', + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }, + })); + + const actionMsgs = findNotifications(transport.sent, 'action'); + const turnStarted = actionMsgs.find(m => { + const params = m.params as { envelope: { action: { type: string } } }; + return params.envelope.action.type === 'session/turnStarted'; + }); + assert.ok(turnStarted, 'should have echoed turnStarted'); + const envelope = (turnStarted!.params as { envelope: { origin: { clientId: string; clientSeq: number } } }).envelope; + assert.strictEqual(envelope.origin.clientId, 'client-1'); + assert.strictEqual(envelope.origin.clientSeq, 1); + }); + + test('actions are scoped to subscribed sessions', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + const transportA = connectClient('client-a', [sessionUri]); + const transportB = connectClient('client-b'); + + transportA.sent.length = 0; + transportB.sent.length = 0; + + stateManager.dispatchServerAction({ + type: 'session/titleChanged', + session: sessionUri, + title: 'New Title', + }); + + assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1); + assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0); + }); + + test('notifications are broadcast to all clients', () => { + const transportA = connectClient('client-a'); + const transportB = connectClient('client-b'); + + transportA.sent.length = 0; + transportB.sent.length = 0; + + stateManager.createSession(makeSessionSummary()); + + assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1); + assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1); + }); + + test('reconnect replays missed actions', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + const transport1 = connectClient('client-r', [sessionUri]); + const hello = findNotification(transport1.sent, 'serverHello'); + const helloSeq = (hello!.params as IServerHelloParams).serverSeq; + transport1.simulateClose(); + + stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title A' }); + stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title B' }); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(notification('reconnect', { + clientId: 'client-r', + lastSeenServerSeq: helloSeq, + subscriptions: [sessionUri], + })); + + const replayed = findNotifications(transport2.sent, 'action'); + assert.strictEqual(replayed.length, 2); + }); + + test('reconnect sends fresh snapshots when gap too large', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + const transport1 = connectClient('client-g', [sessionUri]); + transport1.simulateClose(); + + for (let i = 0; i < 1100; i++) { + stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: `Title ${i}` }); + } + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(notification('reconnect', { + clientId: 'client-g', + lastSeenServerSeq: 0, + subscriptions: [sessionUri], + })); + + const reconnectResp = findNotification(transport2.sent, 'reconnectResponse'); + assert.ok(reconnectResp, 'should receive a reconnectResponse'); + const params = reconnectResp!.params as { snapshots: IStateSnapshot[] }; + assert.ok(params.snapshots.length > 0, 'should contain snapshots'); + }); + + test('client disconnect cleans up', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + const transport = connectClient('client-d', [sessionUri]); + transport.sent.length = 0; + + transport.simulateClose(); + + stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'After Disconnect' }); + + assert.strictEqual(transport.sent.length, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts new file mode 100644 index 00000000000..b8f49c502cb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts @@ -0,0 +1,663 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ChildProcess, fork } from 'child_process'; +import { fileURLToPath } from 'url'; +import { WebSocket } from 'ws'; +import { URI } from '../../../../base/common/uri.js'; +import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; +import { + isJsonRpcNotification, + isJsonRpcResponse, + type IActionBroadcastParams, + type IFetchTurnsResult, + type IJsonRpcErrorResponse, + type IJsonRpcSuccessResponse, + type IListSessionsResult, + type INotificationBroadcastParams, + type IProtocolMessage, + type IProtocolNotification, + type IServerHelloParams, + type IStateSnapshot, +} from '../../common/state/sessionProtocol.js'; +import type { IDeltaAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js'; +import type { ISessionState } from '../../common/state/sessionState.js'; + +// ---- JSON serialization helpers (mirror webSocketTransport.ts) -------------- + +function uriReplacer(_key: string, value: unknown): unknown { + if (value instanceof URI) { + return value.toJSON(); + } + if (value instanceof Map) { + return { $type: 'Map', entries: [...value.entries()] }; + } + return value; +} + +function uriReviver(_key: string, value: unknown): unknown { + if (value && typeof value === 'object') { + const obj = value as Record; + if (obj.$mid === 1) { + return URI.revive(value as URI); + } + if (obj.$type === 'Map' && Array.isArray(obj.entries)) { + return new Map(obj.entries as [unknown, unknown][]); + } + } + return value; +} + +// ---- JSON-RPC test client --------------------------------------------------- + +interface IPendingCall { + resolve: (result: unknown) => void; + reject: (err: Error) => void; +} + +class TestProtocolClient { + private readonly _ws: WebSocket; + private _nextId = 1; + private readonly _pendingCalls = new Map(); + private readonly _notifications: IProtocolNotification[] = []; + private readonly _notifWaiters: { predicate: (n: IProtocolNotification) => boolean; resolve: (n: IProtocolNotification) => void; reject: (err: Error) => void }[] = []; + + constructor(port: number) { + this._ws = new WebSocket(`ws://127.0.0.1:${port}`); + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this._ws.on('open', () => { + this._ws.on('message', (data: Buffer | string) => { + const text = typeof data === 'string' ? data : data.toString('utf-8'); + const msg = JSON.parse(text, uriReviver); + this._handleMessage(msg); + }); + resolve(); + }); + this._ws.on('error', reject); + }); + } + + private _handleMessage(msg: IProtocolMessage): void { + if (isJsonRpcResponse(msg)) { + // JSON-RPC response — resolve pending call + const pending = this._pendingCalls.get(msg.id); + if (pending) { + this._pendingCalls.delete(msg.id); + const errResp = msg as IJsonRpcErrorResponse; + if (errResp.error) { + pending.reject(new Error(errResp.error.message)); + } else { + pending.resolve((msg as IJsonRpcSuccessResponse).result); + } + } + } else if (isJsonRpcNotification(msg)) { + // JSON-RPC notification from server + const notif = msg; + // Check waiters first + for (let i = this._notifWaiters.length - 1; i >= 0; i--) { + if (this._notifWaiters[i].predicate(notif)) { + const waiter = this._notifWaiters.splice(i, 1)[0]; + waiter.resolve(notif); + } + } + this._notifications.push(notif); + } + } + + /** Send a JSON-RPC notification (fire-and-forget). */ + notify(method: string, params?: unknown): void { + const msg: IProtocolMessage = { jsonrpc: '2.0', method, params }; + this._ws.send(JSON.stringify(msg, uriReplacer)); + } + + /** Send a JSON-RPC request and await the response. */ + call(method: string, params?: unknown, timeoutMs = 5000): Promise { + const id = this._nextId++; + const msg: IProtocolMessage = { jsonrpc: '2.0', id, method, params }; + this._ws.send(JSON.stringify(msg, uriReplacer)); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pendingCalls.delete(id); + reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`)); + }, timeoutMs); + + this._pendingCalls.set(id, { + resolve: result => { clearTimeout(timer); resolve(result as T); }, + reject: err => { clearTimeout(timer); reject(err); }, + }); + }); + } + + /** Wait for a server notification matching a predicate. */ + waitForNotification(predicate: (n: IProtocolNotification) => boolean, timeoutMs = 5000): Promise { + const existing = this._notifications.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); + if (idx >= 0) { + this._notifWaiters.splice(idx, 1); + } + reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); + }, timeoutMs); + + this._notifWaiters.push({ + predicate, + resolve: n => { clearTimeout(timer); resolve(n); }, + reject, + }); + }); + } + + /** Return all received notifications matching a predicate. */ + receivedNotifications(predicate?: (n: IProtocolNotification) => boolean): IProtocolNotification[] { + return predicate ? this._notifications.filter(predicate) : [...this._notifications]; + } + + close(): void { + for (const w of this._notifWaiters) { + w.reject(new Error('Client closed')); + } + this._notifWaiters.length = 0; + for (const [, p] of this._pendingCalls) { + p.reject(new Error('Client closed')); + } + this._pendingCalls.clear(); + this._ws.close(); + } + + clearReceived(): void { + this._notifications.length = 0; + } +} + +// ---- Server process lifecycle ----------------------------------------------- + +async function startServer(): Promise<{ process: ChildProcess; port: number }> { + return new Promise((resolve, reject) => { + const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); + const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0'], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + const timeout = setTimeout(() => { + child.kill(); + reject(new Error('Server startup timed out')); + }, 10_000); + + child.stdout!.on('data', (data: Buffer) => { + const text = data.toString(); + const match = text.match(/READY:(\d+)/); + if (match) { + clearTimeout(timeout); + resolve({ process: child, port: parseInt(match[1], 10) }); + } + }); + + child.stderr!.on('data', (data: Buffer) => { + console.error('[TestServer]', data.toString()); + }); + + child.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + + child.on('exit', code => { + clearTimeout(timeout); + reject(new Error(`Server exited prematurely with code ${code}`)); + }); + }); +} + +// ---- Helpers ---------------------------------------------------------------- + +let sessionCounter = 0; + +function nextSessionUri(): URI { + return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }); +} + +function isActionNotification(n: IProtocolNotification, actionType: string): boolean { + if (n.method !== 'action') { + return false; + } + const params = n.params as IActionBroadcastParams; + return params.envelope.action.type === actionType; +} + +function getActionParams(n: IProtocolNotification): IActionBroadcastParams { + return n.params as IActionBroadcastParams; +} + +/** Perform handshake, create a session, subscribe, and return its URI. */ +async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise { + c.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); + await c.waitForNotification(n => n.method === 'serverHello'); + + await c.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const notif = await c.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + + await c.call('subscribe', { resource: realSessionUri }); + c.clearReceived(); + + return realSessionUri; +} + +function dispatchTurnStarted(c: TestProtocolClient, session: URI, turnId: string, text: string, clientSeq: number): void { + c.notify('dispatchAction', { + clientSeq, + action: { + type: 'session/turnStarted', + session, + turnId, + userMessage: { text }, + }, + }); +} + +// ---- Test suite ------------------------------------------------------------- + +suite('Protocol WebSocket E2E', function () { + + let server: { process: ChildProcess; port: number }; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + // 1. Handshake + test('handshake returns serverHello with protocol version', async function () { + this.timeout(5_000); + + client.notify('initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId: 'test-handshake', + initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' })], + }); + + const hello = await client.waitForNotification(n => n.method === 'serverHello'); + const params = hello.params as IServerHelloParams; + assert.strictEqual(params.protocolVersion, PROTOCOL_VERSION); + assert.ok(params.serverSeq >= 0); + assert.ok(params.snapshots.length >= 1, 'should have root state snapshot'); + }); + + // 2. Create session + test('create session triggers sessionAdded notification', async function () { + this.timeout(10_000); + + client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' }); + await client.waitForNotification(n => n.method === 'serverHello'); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + assert.strictEqual(notification.summary.resource.scheme, 'mock'); + assert.strictEqual(notification.summary.provider, 'mock'); + }); + + // 3. Send message and receive response + test('send message and receive delta + turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-send-message'); + dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1); + + const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta')); + const deltaAction = getActionParams(delta).envelope.action; + assert.strictEqual(deltaAction.type, 'session/delta'); + if (deltaAction.type === 'session/delta') { + assert.strictEqual(deltaAction.content, 'Hello, world!'); + } + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 4. Tool invocation lifecycle + test('tool invocation: toolStart → toolComplete → delta → turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); + dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); + + await client.waitForNotification(n => isActionNotification(n, 'session/toolStart')); + const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolComplete')); + const tcAction = getActionParams(toolComplete).envelope.action; + if (tcAction.type === 'session/toolComplete') { + assert.strictEqual(tcAction.result.success, true); + } + await client.waitForNotification(n => isActionNotification(n, 'session/delta')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 5. Error handling + test('error prompt triggers session/error', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-error'); + dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1); + + const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error')); + const errorAction = getActionParams(errorNotif).envelope.action; + if (errorAction.type === 'session/error') { + assert.strictEqual(errorAction.error.message, 'Something went wrong'); + } + }); + + // 6. Permission flow + test('permission request → resolve → response', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-permission'); + dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1); + + await client.waitForNotification(n => isActionNotification(n, 'session/permissionRequest')); + + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/permissionResolved', + session: sessionUri, + turnId: 'turn-perm', + requestId: 'perm-1', + approved: true, + }, + }); + + const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta')); + const content = (getActionParams(delta).envelope.action as IDeltaAction).content; + assert.strictEqual(content, 'Allowed.'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 7. Session list + test('listSessions returns sessions', async function () { + this.timeout(10_000); + + client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' }); + await client.waitForNotification(n => n.method === 'serverHello'); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + + const result = await client.call('listSessions'); + assert.ok(Array.isArray(result.sessions)); + assert.ok(result.sessions.length >= 1, 'should have at least one session'); + }); + + // 8. Reconnect + test('reconnect replays missed actions', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-reconnect'); + dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const allActions = client.receivedNotifications(n => n.method === 'action'); + assert.ok(allActions.length > 0); + const missedFromSeq = getActionParams(allActions[0]).envelope.serverSeq - 1; + + client.close(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + client2.notify('reconnect', { + clientId: 'test-reconnect', + lastSeenServerSeq: missedFromSeq, + subscriptions: [sessionUri], + }); + + await new Promise(resolve => setTimeout(resolve, 500)); + + const replayed = client2.receivedNotifications(); + assert.ok(replayed.length > 0, 'should receive replayed actions or reconnect response'); + const hasActions = replayed.some(n => n.method === 'action'); + const hasReconnect = replayed.some(n => n.method === 'reconnectResponse'); + assert.ok(hasActions || hasReconnect); + + client2.close(); + }); + + // ---- Gap tests: functionality bugs ---------------------------------------- + + test('usage info is captured on completed turn', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-usage'); + dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1); + + const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage')); + const usageAction = getActionParams(usageNotif).envelope.action as IUsageAction; + assert.strictEqual(usageAction.usage.inputTokens, 100); + assert.strictEqual(usageAction.usage.outputTokens, 50); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + const turn = state.turns[state.turns.length - 1]; + assert.ok(turn.usage); + assert.strictEqual(turn.usage!.inputTokens, 100); + assert.strictEqual(turn.usage!.outputTokens, 50); + }); + + test('modifiedAt updates on turn completion', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); + + const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); + const initialModifiedAt = (initialSnapshot.state as ISessionState).summary.modifiedAt; + + await new Promise(resolve => setTimeout(resolve, 50)); + + dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); + const updatedModifiedAt = (updatedSnapshot.state as ISessionState).summary.modifiedAt; + assert.ok(updatedModifiedAt >= initialModifiedAt); + }); + + test('createSession with invalid provider does not crash server', async function () { + this.timeout(10_000); + + client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' }); + await client.waitForNotification(n => n.method === 'serverHello'); + + // This should return a JSON-RPC error + let gotError = false; + try { + await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should have received an error for invalid provider'); + + // Server should still be functional + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.ok(notif); + }); + + test('fetchTurns returns completed turn history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const result = await client.call('fetchTurns', { session: sessionUri, startTurn: 0, count: 10 }); + assert.ok(result.turns.length >= 2); + assert.ok(result.totalTurns >= 2); + }); + + // ---- Gap tests: coverage --------------------------------------------------- + + test('dispose session sends sessionRemoved notification', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-dispose'); + await client.call('disposeSession', { session: sessionUri }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + assert.strictEqual(removed.session.toString(), sessionUri.toString()); + }); + + test('cancel turn stops in-progress processing', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cancel'); + dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); + + client.notify('dispatchAction', { + clientSeq: 2, + action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); + }); + + test('multiple sequential turns accumulate in history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns'); + + dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.state as ISessionState; + assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); + assert.strictEqual(state.turns[0].id, 'turn-m1'); + assert.strictEqual(state.turns[1].id, 'turn-m2'); + }); + + test('two clients on same session both see actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + client2.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' }); + await client2.waitForNotification(n => n.method === 'serverHello'); + await client2.call('subscribe', { resource: sessionUri }); + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1); + + const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/delta')); + const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/delta')); + assert.ok(d1); + assert.ok(d2); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('unsubscribe stops receiving session actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe'); + client.notify('unsubscribe', { resource: sessionUri }); + await new Promise(resolve => setTimeout(resolve, 100)); + client.clearReceived(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + client2.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' }); + await client2.waitForNotification(n => n.method === 'serverHello'); + await client2.call('subscribe', { resource: sessionUri }); + + dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + await new Promise(resolve => setTimeout(resolve, 300)); + const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/')); + assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions'); + + client2.close(); + }); + + test('change model within session updates state', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-change-model'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { type: 'session/modelChanged', session: sessionUri, model: 'new-mock-model' }, + }); + + const modelChanged = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); + const action = getActionParams(modelChanged).envelope.action; + assert.strictEqual(action.type, 'session/modelChanged'); + if (action.type === 'session/modelChanged') { + assert.strictEqual((action as { model: string }).model, 'new-mock-model'); + } + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.state as ISessionState; + assert.strictEqual(state.summary.model, 'new-mock-model'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts new file mode 100644 index 00000000000..8bec27d0996 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IActionEnvelope, INotification } from '../../common/state/sessionActions.js'; +import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, type ISessionState } from '../../common/state/sessionState.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; + +suite('SessionStateManager', () => { + + let disposables: DisposableStore; + let manager: SessionStateManager; + const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }); + + function makeSessionSummary(resource?: URI): ISessionSummary { + return { + resource: resource ?? sessionUri, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + } + + setup(() => { + disposables = new DisposableStore(); + manager = disposables.add(new SessionStateManager(new NullLogService())); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('createSession creates initial state with lifecycle Creating', () => { + const state = manager.createSession(makeSessionSummary()); + assert.strictEqual(state.lifecycle, SessionLifecycle.Creating); + assert.strictEqual(state.turns.length, 0); + assert.strictEqual(state.activeTurn, undefined); + assert.strictEqual(state.summary.resource.toString(), sessionUri.toString()); + }); + + test('getSnapshot returns undefined for unknown session', () => { + const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }); + const snapshot = manager.getSnapshot(unknown); + assert.strictEqual(snapshot, undefined); + }); + + test('getSnapshot returns root snapshot', () => { + const snapshot = manager.getSnapshot(ROOT_STATE_URI); + assert.ok(snapshot); + assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); + assert.deepStrictEqual(snapshot.state, { agents: [] }); + }); + + test('getSnapshot returns session snapshot after creation', () => { + manager.createSession(makeSessionSummary()); + const snapshot = manager.getSnapshot(sessionUri); + assert.ok(snapshot); + assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); + assert.strictEqual((snapshot.state as ISessionState).lifecycle, SessionLifecycle.Creating); + }); + + test('dispatchServerAction applies action and emits envelope', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ + type: 'session/ready', + session: sessionUri, + }); + + const state = manager.getSessionState(sessionUri); + assert.ok(state); + assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); + + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(envelopes[0].action.type, 'session/ready'); + assert.strictEqual(envelopes[0].serverSeq, 1); + assert.strictEqual(envelopes[0].origin, undefined); + }); + + test('serverSeq increments monotonically', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Updated' }); + + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(envelopes[0].serverSeq, 1); + assert.strictEqual(envelopes[1].serverSeq, 2); + assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq); + }); + + test('dispatchClientAction includes origin in envelope', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const origin = { clientId: 'renderer-1', clientSeq: 42 }; + manager.dispatchClientAction( + { type: 'session/ready', session: sessionUri }, + origin, + ); + + assert.strictEqual(envelopes.length, 1); + assert.deepStrictEqual(envelopes[0].origin, origin); + }); + + test('removeSession clears state and emits notification', () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.removeSession(sessionUri); + + assert.strictEqual(manager.getSessionState(sessionUri), undefined); + assert.strictEqual(manager.getSnapshot(sessionUri), undefined); + assert.strictEqual(notifications.length, 1); + assert.strictEqual(notifications[0].type, 'notify/sessionRemoved'); + }); + + test('createSession emits sessionAdded notification', () => { + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.createSession(makeSessionSummary()); + + assert.strictEqual(notifications.length, 1); + assert.strictEqual(notifications[0].type, 'notify/sessionAdded'); + }); + + test('getActiveTurnId returns active turn id after turnStarted', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + + assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); + + manager.dispatchServerAction({ + type: 'session/turnStarted', + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1'); + }); +}); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 72938572635..7af7bce71bc 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -86,6 +86,8 @@ export interface NativeParsedArgs { 'inspect-brk-search'?: string; 'inspect-ptyhost'?: string; 'inspect-brk-ptyhost'?: string; + 'inspect-agenthost'?: string; + 'inspect-brk-agenthost'?: string; 'inspect-sharedprocess'?: string; 'inspect-brk-sharedprocess'?: string; 'disable-extensions'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 9068c9ad77a..9a2575a40f4 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -158,6 +158,8 @@ export const OPTIONS: OptionDescriptions> = { 'debugRenderer': { type: 'boolean' }, 'inspect-ptyhost': { type: 'string', allowEmptyValue: true }, 'inspect-brk-ptyhost': { type: 'string', allowEmptyValue: true }, + 'inspect-agenthost': { type: 'string', allowEmptyValue: true }, + 'inspect-brk-agenthost': { type: 'string', allowEmptyValue: true }, 'inspect-search': { type: 'string', deprecates: ['debugSearch'], allowEmptyValue: true }, 'inspect-brk-search': { type: 'string', deprecates: ['debugBrkSearch'], allowEmptyValue: true }, 'inspect-sharedprocess': { type: 'string', allowEmptyValue: true }, diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index ae9e7e1d477..1bb9d708407 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -25,6 +25,10 @@ export function parsePtyHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): return parseDebugParams(args['inspect-ptyhost'], args['inspect-brk-ptyhost'], 5877, isBuilt, args.extensionEnvironment); } +export function parseAgentHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams { + return parseDebugParams(args['inspect-agenthost'], args['inspect-brk-agenthost'], 5878, isBuilt, args.extensionEnvironment); +} + export function parseSharedProcessDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams { return parseDebugParams(args['inspect-sharedprocess'], args['inspect-brk-sharedprocess'], 5879, isBuilt, args.extensionEnvironment); } diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index 1ef575e8637..f3d848457a6 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -77,6 +77,9 @@ import { RemoteExtensionsScannerChannel, RemoteExtensionsScannerService } from ' import { RemoteExtensionsScannerChannelName } from '../../platform/remote/common/remoteExtensionsScanner.js'; import { RemoteUserDataProfilesServiceChannel } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { NodePtyHostStarter } from '../../platform/terminal/node/nodePtyHostStarter.js'; +import { NodeAgentHostStarter } from '../../platform/agentHost/node/nodeAgentHostStarter.js'; +import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; +import { AgentHostEnabledSettingId } from '../../platform/agentHost/common/agentService.js'; import { CSSDevelopmentService, ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryLogAppender.js'; @@ -233,6 +236,11 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const ptyHostService = instantiationService.createInstance(PtyHostService, ptyHostStarter); services.set(IPtyService, ptyHostService); + if (configurationService.getValue(AgentHostEnabledSettingId)) { + const agentHostStarter = instantiationService.createInstance(NodeAgentHostStarter); + disposables.add(instantiationService.createInstance(AgentHostProcessManager, agentHostStarter)); + } + services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService)); services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService)); services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService)); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index ecf9e9b782f..8d022018d2d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -120,6 +120,8 @@ function getTargetLabel(provider: AgentSessionProviders): string { return 'Codex'; case AgentSessionProviders.Growth: return 'Growth'; + case AgentSessionProviders.AgentHostCopilot: + return 'Agent Host - Copilot'; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts new file mode 100644 index 00000000000..cebdcabb4e5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -0,0 +1,260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IAgentHostService, AgentHostEnabledSettingId, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; +import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; +import { Registry } from '../../../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { AgentHostLanguageModelProvider } from './agentHostLanguageModelProvider.js'; +import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; +import { AgentHostSessionListController } from './agentHostSessionListController.js'; + +export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; +export { AgentHostSessionListController } from './agentHostSessionListController.js'; + +/** + * Discovers available agents from the agent host process and dynamically + * registers each one as a chat session type with its own session handler, + * list controller, and language model provider. + * + * Gated on the `chat.agentHost.enabled` setting. + */ +export class AgentHostContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostContribution'; + + private static readonly _outputChannelId = 'agentHostIpc'; + + private _outputChannel: IOutputChannel | undefined; + private _isChannelRegistered = false; + private _clientState: SessionClientState | undefined; + private readonly _agentRegistrations = new Map(); + /** Model providers keyed by agent provider, for pushing model updates. */ + private readonly _modelProviders = new Map(); + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @ILogService private readonly _logService: ILogService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IOutputService private readonly _outputService: IOutputService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + if (!configurationService.getValue(AgentHostEnabledSettingId)) { + return; + } + + this._setupIpcLogging(); + + // Shared client state for protocol reconciliation + this._clientState = this._register(new SessionClientState(this._agentHostService.clientId)); + + // Forward action envelopes from the host to client state + this._register(this._agentHostService.onDidAction(envelope => { + // Only root actions are relevant here; session actions are + // handled by individual session handlers. + if (!isSessionAction(envelope.action)) { + this._clientState!.receiveEnvelope(envelope); + } + })); + + // Forward notifications to client state + this._register(this._agentHostService.onDidNotification(n => { + this._clientState!.receiveNotification(n); + })); + + // React to root state changes (agent discovery / removal) + this._register(this._clientState.onDidChangeRootState(rootState => { + this._handleRootStateChange(rootState); + })); + + this._initializeAndSubscribe(); + } + + // ---- IPC output channel (trace-level only) ------------------------------ + + private _setupIpcLogging(): void { + this._updateOutputChannel(); + this._register(this._logService.onDidChangeLogLevel(() => this._updateOutputChannel())); + + // Subscribe to action / notification streams for IPC logging + this._register(this._agentHostService.onDidAction(e => { + this._traceIpc('event', 'onDidAction', e); + })); + this._register(this._agentHostService.onDidNotification(e => { + this._traceIpc('event', 'onDidNotification', e); + })); + } + + private _updateOutputChannel(): void { + const isTrace = this._logService.getLevel() === LogLevel.Trace; + const registry = Registry.as(Extensions.OutputChannels); + + if (isTrace && !this._isChannelRegistered) { + registry.registerChannel({ + id: AgentHostContribution._outputChannelId, + label: 'Agent Host IPC', + log: false, + languageId: 'log', + }); + this._isChannelRegistered = true; + this._outputChannel = undefined; // force re-fetch + } else if (!isTrace && this._isChannelRegistered) { + registry.removeChannel(AgentHostContribution._outputChannelId); + this._isChannelRegistered = false; + this._outputChannel = undefined; + } + } + + private _traceIpc(direction: 'call' | 'result' | 'event', method: string, data?: unknown): void { + if (this._logService.getLevel() !== LogLevel.Trace) { + return; + } + + if (!this._outputChannel) { + this._outputChannel = this._outputService.getChannel(AgentHostContribution._outputChannelId); + if (!this._outputChannel) { + return; + } + } + + const timestamp = new Date().toISOString(); + let payload: string; + try { + payload = data !== undefined ? JSON.stringify(data, (_key, value) => { + if (value && typeof value === 'object' && (value as { $mid?: unknown }).$mid !== undefined && (value as { scheme?: unknown }).scheme !== undefined) { + return URI.revive(value).toString(); + } + return value; + }, 2) : ''; + } catch { + payload = String(data); + } + + const arrow = direction === 'call' ? '>>' : direction === 'result' ? '<<' : '**'; + this._outputChannel.append(`[${timestamp}] [trace] ${arrow} ${method}${payload ? `\n${payload}` : ''}\n`); + } + + private async _initializeAndSubscribe(): Promise { + try { + const snapshot = await this._agentHostService.subscribe(ROOT_STATE_URI); + if (this._store.isDisposed) { + return; + } + // Feed snapshot into client state — fires onDidChangeRootState + this._clientState!.handleSnapshot(ROOT_STATE_URI, snapshot.state, snapshot.fromSeq); + } catch (err) { + this._logService.error('[AgentHost] Failed to subscribe to root state', err); + } + } + + private _handleRootStateChange(rootState: IRootState): void { + const incoming = new Set(rootState.agents.map(a => a.provider)); + + // Remove agents that are no longer present + for (const [provider, store] of this._agentRegistrations) { + if (!incoming.has(provider)) { + store.dispose(); + this._agentRegistrations.delete(provider); + this._modelProviders.delete(provider); + } + } + + // Register new agents and push model updates to existing ones + for (const agent of rootState.agents) { + if (!this._agentRegistrations.has(agent.provider)) { + this._registerAgent(agent); + } else { + // Push updated models to existing model provider + const modelProvider = this._modelProviders.get(agent.provider); + modelProvider?.updateModels(agent.models); + } + } + } + + private _registerAgent(agent: IAgentInfo): void { + const store = new DisposableStore(); + this._agentRegistrations.set(agent.provider, store); + this._register(store); + const sessionType = `agent-host-${agent.provider}`; + const agentId = sessionType; + const vendor = sessionType; + + // Chat session contribution + store.add(this._chatSessionsService.registerChatSessionContribution({ + type: sessionType, + name: agentId, + displayName: agent.displayName, + description: agent.description, + canDelegate: true, + requiresCustomModels: true, + })); + + // Session list controller + const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider)); + store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); + + // Session handler + const sessionHandler = store.add(this._instantiationService.createInstance(AgentHostSessionHandler, { + provider: agent.provider, + agentId, + sessionType, + fullName: agent.displayName, + description: agent.description, + })); + store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); + + // Language model provider + const vendorDescriptor = { vendor, displayName: agent.displayName, configuration: undefined, managementCommand: undefined, when: undefined }; + this._languageModelsService.deltaLanguageModelChatProviderDescriptors([vendorDescriptor], []); + store.add(toDisposable(() => this._languageModelsService.deltaLanguageModelChatProviderDescriptors([], [vendorDescriptor]))); + const modelProvider = store.add(new AgentHostLanguageModelProvider(sessionType, vendor)); + modelProvider.updateModels(agent.models); + this._modelProviders.set(agent.provider, modelProvider); + store.add(toDisposable(() => this._modelProviders.delete(agent.provider))); + store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); + + // Push auth token and refresh models from server + this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => + this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + store.add(this._authenticationService.onDidChangeSessions(() => + this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + } + + private async _pushAuthToken(): Promise { + try { + const account = await this._defaultAccountService.getDefaultAccount(); + if (!account) { + return; + } + + const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); + const session = sessions.find(s => s.id === account.sessionId); + if (session) { + await this._agentHostService.setAuthToken(session.accessToken); + } + } catch { + // best-effort + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts new file mode 100644 index 00000000000..546dd68513b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { ISessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ILanguageModelChatProvider, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; + +/** + * Exposes models available from the agent host process as selectable + * language models in the chat model picker. Models are provided from + * root state (via {@link IAgentInfo.models}) rather than via RPC. + */ +export class AgentHostLanguageModelProvider extends Disposable implements ILanguageModelChatProvider { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _models: readonly ISessionModelInfo[] = []; + + constructor( + private readonly _sessionType: string, + private readonly _vendor: string, + ) { + super(); + } + + /** + * Called by {@link AgentHostContribution} when models change in root state. + */ + updateModels(models: readonly ISessionModelInfo[]): void { + this._models = models; + this._onDidChange.fire(); + } + + async provideLanguageModelChatInfo(_options: unknown, _token: CancellationToken): Promise { + return this._models + .filter(m => m.policyState !== 'disabled') + .map(m => ({ + identifier: `${this._vendor}:${m.id}`, + metadata: { + extension: new ExtensionIdentifier('vscode.agent-host'), + name: m.name, + id: m.id, + vendor: this._vendor, + version: '1.0', + family: m.id, + maxInputTokens: m.maxContextWindow ?? 0, + maxOutputTokens: 0, + isDefaultForLocation: {}, + isUserSelectable: true, + modelPickerCategory: undefined, + targetChatSessionType: this._sessionType, + capabilities: { + vision: m.supportsVision ?? false, + toolCalling: true, + agentMode: true, + }, + }, + })); + } + + async sendChatRequest(): Promise { + throw new Error('Agent-host models do not support direct chat requests'); + } + + async provideTokenCount(): Promise { + return 0; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts new file mode 100644 index 00000000000..bb5d4f5b485 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAgentHostService, IAgentAttachment, AgentProvider, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; +import { ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; +import { getAgentHostIcon } from '../agentSessions.js'; +import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from './stateToProgressAdapter.js'; + +// ============================================================================= +// AgentHostSessionHandler — renderer-side handler for a single agent host +// chat session type. Bridges the protocol state layer with the chat UI: +// subscribes to session state, derives IChatProgress[] from immutable state +// changes, and dispatches client actions (turnStarted, permissionResolved, +// turnCancelled) back to the server. +// ============================================================================= + +// ============================================================================= +// Chat session +// ============================================================================= + +class AgentHostChatSession extends Disposable implements IChatSession { + readonly progressObs = observableValue('agentHostProgress', []); + readonly isCompleteObs = observableValue('agentHostComplete', true); + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + readonly requestHandler: IChatSession['requestHandler']; + readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; + + constructor( + readonly sessionResource: URI, + readonly history: readonly IChatSessionHistoryItem[], + private readonly _sendRequest: (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => Promise, + onDispose: () => void, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(toDisposable(() => this._onWillDispose.fire())); + this._register(toDisposable(onDispose)); + + this.requestHandler = async (request, progress, _history, cancellationToken) => { + this._logService.info('[AgentHost] requestHandler called'); + this.isCompleteObs.set(false, undefined); + await this._sendRequest(request, progress, cancellationToken); + this.isCompleteObs.set(true, undefined); + }; + + this.interruptActiveResponseCallback = history.length > 0 ? undefined : async () => { + return true; + }; + } +} + +// ============================================================================= +// Session handler +// ============================================================================= + +export interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; + readonly agentId: string; + readonly sessionType: string; + readonly fullName: string; + readonly description: string; +} + +export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider { + + private readonly _activeSessions = new Map(); + /** Maps UI resource keys to resolved backend session URIs. */ + private readonly _sessionToBackend = new Map(); + private readonly _config: IAgentHostSessionHandlerConfig; + + /** Client state manager shared across all sessions for this handler. */ + private readonly _clientState: SessionClientState; + + constructor( + config: IAgentHostSessionHandlerConfig, + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IChatAgentService private readonly _chatAgentService: IChatAgentService, + @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._config = config; + + // Create shared client state manager for this handler instance + this._clientState = this._register(new SessionClientState(this._agentHostService.clientId)); + + // Forward action envelopes from IPC to client state + this._register(this._agentHostService.onDidAction(envelope => { + if (isSessionAction(envelope.action)) { + this._clientState.receiveEnvelope(envelope); + } + })); + + this._registerAgent(); + } + + async provideChatSessionContent(sessionResource: URI, _token: CancellationToken): Promise { + const resourceKey = sessionResource.path.substring(1); + + // For untitled (new) sessions, defer backend session creation until the + // first request arrives so the user-selected model is available. + // For existing sessions we resolve immediately to load history. + let resolvedSession: URI | undefined; + const isUntitled = resourceKey.startsWith('untitled-'); + const history: IChatSessionHistoryItem[] = []; + if (!isUntitled) { + resolvedSession = this._resolveSessionUri(sessionResource); + this._sessionToBackend.set(resourceKey, resolvedSession); + try { + const snapshot = await this._agentHostService.subscribe(resolvedSession); + this._clientState.handleSnapshot(resolvedSession, snapshot.state, snapshot.fromSeq); + const sessionState = this._clientState.getSessionState(resolvedSession); + if (sessionState) { + history.push(...turnsToHistory(sessionState.turns, this._config.agentId)); + } + } catch (err) { + this._logService.warn(`[AgentHost] Failed to subscribe to existing session: ${resolvedSession.toString()}`, err); + } + } + const session = this._instantiationService.createInstance( + AgentHostChatSession, + sessionResource, + history, + async (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => { + const backendSession = resolvedSession ?? await this._createAndSubscribe(sessionResource, request.userSelectedModelId); + resolvedSession = backendSession; + this._sessionToBackend.set(resourceKey, backendSession); + return this._handleTurn(backendSession, request, progress, token); + }, + () => { + this._activeSessions.delete(resourceKey); + this._sessionToBackend.delete(resourceKey); + if (resolvedSession) { + this._clientState.unsubscribe(resolvedSession); + this._agentHostService.unsubscribe(resolvedSession); + this._agentHostService.disposeSession(resolvedSession); + } + }, + ); + this._activeSessions.set(resourceKey, session); + return session; + } + + // ---- Agent registration ------------------------------------------------- + + private _registerAgent(): void { + const agentData: IChatAgentData = { + id: this._config.agentId, + name: this._config.agentId, + fullName: this._config.fullName, + description: this._config.description, + extensionId: new ExtensionIdentifier('vscode.agent-host'), + extensionVersion: undefined, + extensionPublisherId: 'vscode', + extensionDisplayName: 'Agent Host', + isDefault: false, + isDynamic: true, + isCore: true, + metadata: { themeIcon: getAgentHostIcon(this._productService) }, + slashCommands: [], + locations: [ChatAgentLocation.Chat], + modes: [ChatModeKind.Agent], + disambiguation: [], + }; + + const agentImpl: IChatAgentImplementation = { + invoke: async (request, progress, _history, cancellationToken) => { + return this._invokeAgent(request, progress, cancellationToken); + }, + }; + + this._register(this._chatAgentService.registerDynamicAgent(agentData, agentImpl)); + } + + private async _invokeAgent( + request: IChatAgentRequest, + progress: (parts: IChatProgress[]) => void, + cancellationToken: CancellationToken, + ): Promise { + this._logService.info(`[AgentHost] _invokeAgent called for resource: ${request.sessionResource.toString()}`); + + // Resolve or create backend session + const resourceKey = request.sessionResource.path.substring(1); + let resolvedSession = this._sessionToBackend.get(resourceKey); + if (!resolvedSession) { + resolvedSession = await this._createAndSubscribe(request.sessionResource, request.userSelectedModelId); + this._sessionToBackend.set(resourceKey, resolvedSession); + } + + await this._handleTurn(resolvedSession, request, progress, cancellationToken); + + const activeSession = this._activeSessions.get(resourceKey); + if (activeSession) { + activeSession.isCompleteObs.set(true, undefined); + } + + return {}; + } + + // ---- Turn handling (state-driven) --------------------------------------- + + private async _handleTurn( + session: URI, + request: IChatAgentRequest, + progress: (parts: IChatProgress[]) => void, + cancellationToken: CancellationToken, + ): Promise { + if (cancellationToken.isCancellationRequested) { + return; + } + + const turnId = generateUuid(); + const attachments = this._convertVariablesToAttachments(request); + const messageAttachments: IMessageAttachment[] = attachments.map(a => ({ + type: a.type, + path: a.path, + displayName: a.displayName, + })); + + // If the user selected a different model since the session was created + // (or since the last turn), dispatch a model change action first so the + // agent backend picks up the new model before processing the turn. + const rawModelId = this._extractRawModelId(request.userSelectedModelId); + if (rawModelId) { + const currentModel = this._clientState.getSessionState(session)?.summary.model; + if (currentModel !== rawModelId) { + const modelAction = { + type: 'session/modelChanged' as const, + session, + model: rawModelId, + }; + const modelSeq = this._clientState.applyOptimistic(modelAction); + this._agentHostService.dispatchAction(modelAction, this._clientState.clientId, modelSeq); + } + } + + // Dispatch session/turnStarted — the server will call sendMessage on + // the provider as a side effect. + const turnAction = { + type: 'session/turnStarted' as const, + session, + turnId, + userMessage: { + text: request.message, + attachments: messageAttachments.length > 0 ? messageAttachments : undefined, + }, + }; + const clientSeq = this._clientState.applyOptimistic(turnAction); + this._agentHostService.dispatchAction(turnAction, this._clientState.clientId, clientSeq); + + // Track live ChatToolInvocation/permission objects for this turn + const activeToolInvocations = new Map(); + const activePermissions = new Map(); + + // Track last-emitted lengths to compute deltas from immutable state + let lastStreamedTextLen = 0; + let lastReasoningLen = 0; + + const turnDisposables = new DisposableStore(); + + let resolveDone: () => void; + const done = new Promise(resolve => { resolveDone = resolve; }); + + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + // Finalize any outstanding tool invocations + for (const [, invocation] of activeToolInvocations) { + invocation.didExecuteTool(undefined); + } + activeToolInvocations.clear(); + turnDisposables.dispose(); + resolveDone(); + }; + + // Listen to state changes and translate to IChatProgress[] + turnDisposables.add(this._clientState.onDidChangeSessionState(e => { + if (e.session.toString() !== session.toString() || cancellationToken.isCancellationRequested) { + return; + } + + const activeTurn = e.state.activeTurn; + + if (!activeTurn || activeTurn.id !== turnId) { + // Turn completed (activeTurn cleared by reducer). + // Check if the finalized turn ended with an error and emit it. + const lastTurn = e.state.turns[e.state.turns.length - 1]; + if (lastTurn?.id === turnId && lastTurn.state === TurnState.Error && lastTurn.error) { + progress([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`) }]); + } + if (!finished) { + finish(); + } + return; + } + + // Stream text deltas + if (activeTurn.streamingText.length > lastStreamedTextLen) { + const delta = activeTurn.streamingText.substring(lastStreamedTextLen); + lastStreamedTextLen = activeTurn.streamingText.length; + progress([{ kind: 'markdownContent', content: new MarkdownString(delta) }]); + } + + // Stream reasoning deltas + if (activeTurn.reasoning.length > lastReasoningLen) { + const delta = activeTurn.reasoning.substring(lastReasoningLen); + lastReasoningLen = activeTurn.reasoning.length; + progress([{ kind: 'thinking', value: delta }]); + } + + // Handle tool calls — create/finalize ChatToolInvocations + for (const [toolCallId, tc] of activeTurn.toolCalls) { + const existing = activeToolInvocations.get(toolCallId); + if (!existing) { + if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingPermission) { + const invocation = toolCallStateToInvocation(tc); + activeToolInvocations.set(toolCallId, invocation); + progress([invocation]); + } + } else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Failed) { + activeToolInvocations.delete(toolCallId); + finalizeToolInvocation(existing, tc); + } + } + + // Handle permission requests + for (const [requestId, perm] of activeTurn.pendingPermissions) { + if (activePermissions.has(requestId)) { + continue; + } + const confirmInvocation = permissionToConfirmation(perm); + activePermissions.set(requestId, confirmInvocation); + progress([confirmInvocation]); + + IChatToolInvocation.awaitConfirmation(confirmInvocation, cancellationToken).then(reason => { + const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; + this._logService.info(`[AgentHost] Permission response: requestId=${requestId}, approved=${approved}`); + const resolveAction = { + type: 'session/permissionResolved' as const, + session, + turnId, + requestId, + approved, + }; + const seq = this._clientState.applyOptimistic(resolveAction); + this._agentHostService.dispatchAction(resolveAction, this._clientState.clientId, seq); + if (approved) { + confirmInvocation.didExecuteTool(undefined); + } else { + confirmInvocation.didExecuteTool({ content: [], toolResultError: 'User denied' }); + } + }).catch(err => { + this._logService.warn(`[AgentHost] Permission confirmation failed for requestId=${requestId}`, err); + }); + } + })); + + turnDisposables.add(cancellationToken.onCancellationRequested(() => { + this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`); + const cancelAction = { + type: 'session/turnCancelled' as const, + session, + turnId, + }; + const seq = this._clientState.applyOptimistic(cancelAction); + this._agentHostService.dispatchAction(cancelAction, this._clientState.clientId, seq); + finish(); + })); + + await done; + } + + // ---- Session resolution ------------------------------------------------- + + /** Maps a UI session resource to a backend provider URI. */ + private _resolveSessionUri(sessionResource: URI): URI { + const rawId = sessionResource.path.substring(1); + return AgentSession.uri(this._config.provider, rawId); + } + + /** Creates a new backend session and subscribes to its state. */ + private async _createAndSubscribe(sessionResource: URI, modelId?: string): Promise { + const rawModelId = this._extractRawModelId(modelId); + const workspaceFolder = this._workspaceContextService.getWorkspace().folders[0]; + + this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`); + const session = await this._agentHostService.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory: workspaceFolder?.uri.fsPath, + }); + this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); + + // Subscribe to the new session's state + try { + const snapshot = await this._agentHostService.subscribe(session); + this._clientState.handleSnapshot(session, snapshot.state, snapshot.fromSeq); + } catch (err) { + this._logService.error(`[AgentHost] Failed to subscribe to new session: ${session.toString()}`, err); + } + + return session; + } + + /** + * Extracts the raw model id from a language-model service identifier. + * E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514". + */ + private _extractRawModelId(languageModelIdentifier: string | undefined): string | undefined { + if (!languageModelIdentifier) { + return undefined; + } + const prefix = this._config.sessionType + ':'; + if (languageModelIdentifier.startsWith(prefix)) { + return languageModelIdentifier.substring(prefix.length); + } + return languageModelIdentifier; + } + + private _convertVariablesToAttachments(request: IChatAgentRequest): IAgentAttachment[] { + const attachments: IAgentAttachment[] = []; + for (const v of request.variables.variables) { + if (v.kind === 'file') { + const uri = v.value instanceof URI ? v.value : undefined; + if (uri?.scheme === 'file') { + attachments.push({ type: 'file', path: uri.fsPath, displayName: v.name }); + } + } else if (v.kind === 'directory') { + const uri = v.value instanceof URI ? v.value : undefined; + if (uri?.scheme === 'file') { + attachments.push({ type: 'directory', path: uri.fsPath, displayName: v.name }); + } + } else if (v.kind === 'implicit' && v.isSelection) { + const uri = v.uri; + if (uri?.scheme === 'file') { + attachments.push({ type: 'selection', path: uri.fsPath, displayName: v.name }); + } + } + } + if (attachments.length > 0) { + this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${request.variables.variables.length} variables`); + } + return attachments; + } + + // ---- Lifecycle ---------------------------------------------------------- + + override dispose(): void { + for (const [, session] of this._activeSessions) { + session.dispose(); + } + this._activeSessions.clear(); + this._sessionToBackend.clear(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts new file mode 100644 index 00000000000..24415065211 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IAgentHostService, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; +import { getAgentHostIcon } from '../agentSessions.js'; + +/** + * Provides session list items for the chat sessions sidebar by querying + * active sessions from the agent host process. Listens to protocol + * notifications for incremental updates. + */ +export class AgentHostSessionListController extends Disposable implements IChatSessionItemController { + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + + private _items: IChatSessionItem[] = []; + + constructor( + private readonly _sessionType: string, + private readonly _provider: string, + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IProductService private readonly _productService: IProductService, + ) { + super(); + + // React to protocol notifications for session list changes + this._register(this._agentHostService.onDidNotification(n => { + if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { + const rawId = AgentSession.id(n.summary.resource); + const item: IChatSessionItem = { + resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), + label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, + iconPath: getAgentHostIcon(this._productService), + status: ChatSessionStatus.Completed, + timing: { + created: n.summary.createdAt, + lastRequestStarted: n.summary.modifiedAt, + lastRequestEnded: n.summary.modifiedAt, + }, + }; + this._items.push(item); + this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); + } else if (n.type === 'notify/sessionRemoved') { + const removedId = AgentSession.id(n.session); + const idx = this._items.findIndex(item => item.resource.path === `/${removedId}`); + if (idx >= 0) { + const [removed] = this._items.splice(idx, 1); + this._onDidChangeChatSessionItems.fire({ removed: [removed.resource] }); + } + } + })); + + // Refresh on turnComplete actions for metadata updates (title, timing) + this._register(this._agentHostService.onDidAction(e => { + if (e.action.type === 'session/turnComplete' && isSessionAction(e.action) && AgentSession.provider(e.action.session) === this._provider) { + const cts = new CancellationTokenSource(); + this.refresh(cts.token).finally(() => cts.dispose()); + } + })); + } + + get items(): readonly IChatSessionItem[] { + return this._items; + } + + async refresh(_token: CancellationToken): Promise { + try { + const sessions = await this._agentHostService.listSessions(); + const filtered = sessions.filter(s => AgentSession.provider(s.session) === this._provider); + const rawId = (s: typeof filtered[0]) => AgentSession.id(s.session); + this._items = filtered.map(s => ({ + resource: URI.from({ scheme: this._sessionType, path: `/${rawId(s)}` }), + label: s.summary ?? `Session ${rawId(s).substring(0, 8)}`, + iconPath: getAgentHostIcon(this._productService), + status: ChatSessionStatus.Completed, + timing: { + created: s.startTime, + lastRequestStarted: s.modifiedTime, + lastRequestEnded: s.modifiedTime, + }, + })); + } catch { + this._items = []; + } + this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts new file mode 100644 index 00000000000..fcdb959660b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; +import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; +import { type IPreparedToolInvocation, type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; + +/** + * Converts completed turns from the protocol state into session history items. + */ +export function turnsToHistory(turns: readonly ITurn[], participantId: string): IChatSessionHistoryItem[] { + const history: IChatSessionHistoryItem[] = []; + for (const turn of turns) { + // Request + history.push({ type: 'request', prompt: turn.userMessage.text, participant: participantId }); + + // Response parts + const parts: IChatProgress[] = []; + + // Assistant response text + if (turn.responseText) { + parts.push({ kind: 'markdownContent', content: new MarkdownString(turn.responseText) }); + } + + // Completed tool calls + for (const tc of turn.toolCalls) { + parts.push(completedToolCallToSerialized(tc)); + } + + // Error message for failed turns + if (turn.state === TurnState.Error && turn.error) { + parts.push({ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${turn.error.errorType}) ${turn.error.message}`) }); + } + + history.push({ type: 'response', parts, participant: participantId }); + } + return history; +} + +/** + * Converts a completed tool call from the protocol state into a serialized + * tool invocation suitable for history replay. + */ +function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized { + const isTerminal = tc.toolKind === 'terminal'; + + let toolSpecificData: IChatTerminalToolInvocationData | undefined; + if (isTerminal && tc.toolInput) { + toolSpecificData = { + kind: 'terminal', + commandLine: { original: tc.toolInput }, + language: tc.language ?? 'shellscript', + terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined, + terminalCommandState: { exitCode: tc.success ? 0 : 1 }, + }; + } + + return { + kind: 'toolInvocationSerialized', + toolCallId: tc.toolCallId, + toolId: tc.toolName, + source: ToolDataSource.Internal, + invocationMessage: new MarkdownString(tc.invocationMessage), + originMessage: undefined, + pastTenseMessage: isTerminal ? undefined : new MarkdownString(tc.pastTenseMessage), + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + isComplete: true, + presentation: undefined, + toolSpecificData, + }; +} + +/** + * Creates a live {@link ChatToolInvocation} from the protocol's tool-call + * state. Used during active turns to represent running tool calls in the UI. + */ +export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocation { + const toolData: IToolData = { + id: tc.toolName, + source: ToolDataSource.Internal, + displayName: tc.displayName, + modelDescription: tc.toolName, + }; + + let parameters: unknown; + if (tc.toolArguments) { + try { parameters = JSON.parse(tc.toolArguments); } catch { /* malformed JSON */ } + } + + const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, undefined, parameters); + invocation.invocationMessage = new MarkdownString(tc.invocationMessage); + + if (tc.toolKind === 'terminal' && tc.toolInput) { + invocation.toolSpecificData = { + kind: 'terminal', + commandLine: { original: tc.toolInput }, + language: tc.language ?? 'shellscript', + } satisfies IChatTerminalToolInvocationData; + } + + return invocation; +} + +/** + * Creates a {@link ChatToolInvocation} with confirmation messages from a + * protocol permission request. The resulting invocation starts in the + * waiting-for-confirmation state. + */ +export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvocation { + let title: string; + let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined; + + switch (perm.permissionKind) { + case 'shell': { + title = perm.intention ?? 'Run command'; + toolSpecificData = perm.fullCommandText ? { + kind: 'terminal', + commandLine: { original: perm.fullCommandText }, + language: 'shellscript', + } : undefined; + break; + } + case 'write': { + title = perm.path ? `Edit ${perm.path}` : 'Edit file'; + let rawInput: unknown; + try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path }; } catch { rawInput = { path: perm.path }; } + toolSpecificData = { kind: 'input', rawInput }; + break; + } + case 'mcp': { + const toolTitle = perm.toolName ?? 'MCP Tool'; + title = perm.serverName ? `${perm.serverName}: ${toolTitle}` : toolTitle; + let rawInput: unknown; + try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { serverName: perm.serverName, toolName: perm.toolName }; } catch { rawInput = { serverName: perm.serverName, toolName: perm.toolName }; } + toolSpecificData = { kind: 'input', rawInput }; + break; + } + case 'read': { + title = perm.intention ?? 'Read file'; + let rawInput: unknown; + try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path, intention: perm.intention }; } catch { rawInput = { path: perm.path, intention: perm.intention }; } + toolSpecificData = { kind: 'input', rawInput }; + break; + } + default: { + title = 'Permission request'; + let rawInput: unknown; + try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : {}; } catch { rawInput = {}; } + toolSpecificData = { kind: 'input', rawInput }; + break; + } + } + + const confirmationMessages: IToolConfirmationMessages = { + title: new MarkdownString(title), + message: new MarkdownString(''), + }; + + const toolData: IToolData = { + id: `permission_${perm.permissionKind}`, + source: ToolDataSource.Internal, + displayName: title, + modelDescription: '', + }; + + const preparedInvocation: IPreparedToolInvocation = { + invocationMessage: new MarkdownString(title), + confirmationMessages, + presentation: ToolInvocationPresentation.HiddenAfterComplete, + toolSpecificData, + }; + + return new ChatToolInvocation(preparedInvocation, toolData, perm.requestId, undefined, undefined); +} + +/** + * Updates a live {@link ChatToolInvocation} with completion data from the + * protocol's tool-call state, transitioning it to the completed state. + */ +export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void { + if (invocation.toolSpecificData?.kind === 'terminal') { + const terminalData = invocation.toolSpecificData as IChatTerminalToolInvocationData; + invocation.toolSpecificData = { + ...terminalData, + terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined, + terminalCommandState: { exitCode: tc.status === ToolCallStatus.Completed ? 0 : 1 }, + }; + } else if (tc.pastTenseMessage) { + invocation.pastTenseMessage = new MarkdownString(tc.pastTenseMessage); + } + + const isFailure = tc.status === ToolCallStatus.Failed; + invocation.didExecuteTool(isFailure ? { content: [], toolResultError: tc.error?.message } : undefined); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index f7662aa6914..eaac1ae2b66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -11,6 +11,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; export enum AgentSessionProviders { Local = 'local', @@ -19,6 +20,7 @@ export enum AgentSessionProviders { Claude = 'claude-code', Codex = 'openai-codex', Growth = 'copilot-growth', + AgentHostCopilot = 'agent-host-copilot', } export function isBuiltInAgentSessionProvider(provider: string): boolean { @@ -36,6 +38,7 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.AgentHostCopilot: return type; default: return undefined; @@ -56,6 +59,8 @@ export function getAgentSessionProviderName(provider: AgentSessionProviders): st return 'Codex'; case AgentSessionProviders.Growth: return 'Growth'; + case AgentSessionProviders.AgentHostCopilot: + return 'Agent Host - Copilot'; } } @@ -73,14 +78,24 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th return Codicon.claude; case AgentSessionProviders.Growth: return Codicon.lightbulb; + case AgentSessionProviders.AgentHostCopilot: + return Codicon.vscodeInsiders; // default; use getAgentHostIcon() for quality-aware icon } } +/** + * Returns the VS Code or VS Code Insiders icon depending on product quality. + */ +export function getAgentHostIcon(productService: IProductService): ThemeIcon { + return productService.quality === 'stable' ? Codicon.vscode : Codicon.vscodeInsiders; +} + export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders): boolean { switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: case AgentSessionProviders.Cloud: + case AgentSessionProviders.AgentHostCopilot: return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: @@ -98,6 +113,7 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: case AgentSessionProviders.Growth: + case AgentSessionProviders.AgentHostCopilot: return false; } } @@ -116,6 +132,8 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); case AgentSessionProviders.Growth: return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); + case AgentSessionProviders.AgentHostCopilot: + return 'Run a Copilot SDK agent in a dedicated process.'; } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ae0171f8dbb..f0b262723a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -8,6 +8,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/com import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; +import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -708,6 +709,13 @@ configurationRegistry.registerConfiguration({ } } }, + [AgentHostEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), + default: false, + tags: ['experimental'], + included: product.quality !== 'stable', + }, [ChatConfiguration.PlanAgentDefaultModel]: { type: 'string', description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index ee72554e7de..99164830155 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -8,7 +8,7 @@ import { AsyncIterableProducer, raceCancellationError } from '../../../../../bas import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; -import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import * as resources from '../../../../../base/common/resources.js'; @@ -269,7 +269,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _itemControllers = new Map }>(); - private readonly _contributions: Map = new Map(); + private readonly _contributions: Map = new Map(); private readonly _contributionDisposables = this._register(new DisposableMap()); private readonly _contentProviders: Map = new Map(); @@ -632,7 +632,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ newlyDisabledChatSessionTypes.add(contribution.type); } else if (!isCurrentlyRegistered && shouldBeRegistered) { // Enable the contribution by registering it - this._enableContribution(contribution, extension); + if (extension) { + this._enableContribution(contribution, extension); + } newlyEnabledChatSessionTypes.add(contribution.type); } } @@ -748,14 +750,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this.resolveChatSessionContribution(entry.extension, entry.contribution); } - private resolveChatSessionContribution(ext: IRelaxedExtensionDescription, contribution: IChatSessionsExtensionPoint) { + private resolveChatSessionContribution(ext: IRelaxedExtensionDescription | undefined, contribution: IChatSessionsExtensionPoint) { return { ...contribution, icon: this.resolveIconForCurrentColorTheme(this.getContributionIcon(ext, contribution)), }; } - private getContributionIcon(ext: IRelaxedExtensionDescription, contribution: IChatSessionsExtensionPoint): ThemeIcon | { light: URI; dark: URI } | undefined { + private getContributionIcon(ext: IRelaxedExtensionDescription | undefined, contribution: IChatSessionsExtensionPoint): ThemeIcon | { light: URI; dark: URI } | undefined { if (!contribution.icon) { return undefined; } @@ -765,8 +767,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ : ThemeIcon.fromId(contribution.icon); } return { - dark: resources.joinPath(ext.extensionLocation, contribution.icon.dark), - light: resources.joinPath(ext.extensionLocation, contribution.icon.light) + dark: ext ? resources.joinPath(ext.extensionLocation, contribution.icon.dark) : URI.parse(contribution.icon.dark), + light: ext ? resources.joinPath(ext.extensionLocation, contribution.icon.light) : URI.parse(contribution.icon.light) }; } @@ -785,6 +787,20 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } + registerChatSessionContribution(contribution: IChatSessionsExtensionPoint): IDisposable { + if (this._contributions.has(contribution.type)) { + return { dispose: () => { } }; + } + + this._contributions.set(contribution.type, { contribution, extension: undefined }); + this._onDidChangeAvailability.fire(); + + return toDisposable(() => { + this._contributions.delete(contribution.type); + this._onDidChangeAvailability.fire(); + }); + } + async activateChatSessionItemProvider(chatViewType: string): Promise { await this.doActivateChatSessionItemController(chatViewType); } @@ -1343,4 +1359,3 @@ export function getResourceForNewChatSession(options: NewChatSessionOpenOptions) function isAgentSessionProviderType(type: string): boolean { return Object.values(AgentSessionProviders).includes(type as AgentSessionProviders); } - diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 7f2b2a277aa..b25529e6051 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -244,6 +244,12 @@ export interface IChatSessionsService { getChatSessionContribution(chatSessionType: string): ResolvedChatSessionsExtensionPoint | undefined; getAllChatSessionContributions(): ResolvedChatSessionsExtensionPoint[]; + /** + * Programmatically register a chat session contribution (for internal session types + * that don't go through the extension point). + */ + registerChatSessionContribution(contribution: IChatSessionsExtensionPoint): IDisposable; + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable; activateChatSessionItemProvider(chatSessionType: string): Promise; diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index e8b51822059..1cce786090a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -4,27 +4,33 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { autorun } from '../../../../base/common/observable.js'; import { resolve } from '../../../../base/common/path.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { localize } from '../../../../nls.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ViewContainerLocation } from '../../../common/views.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; +import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; +import { ChatEditorInput } from '../browser/widgetHosts/editor/chatEditorInput.js'; +import { ChatViewPane } from '../browser/widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; import { isSessionInProgressStatus } from '../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; @@ -234,3 +240,31 @@ registerWorkbenchContribution2(NativeBuiltinToolsContribution.ID, NativeBuiltinT registerWorkbenchContribution2(ChatCommandLineHandler.ID, ChatCommandLineHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSuspendThrottlingHandler.ID, ChatSuspendThrottlingHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLifecycleHandler.ID, ChatLifecycleHandler, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); + +// Register command for opening a new Agent Host session from the session type picker +CommandsRegistry.registerCommand( + `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.AgentHostCopilot}`, + async (accessor, chatSessionPosition: string) => { + const viewsService = accessor.get(IViewsService); + const resource = URI.from({ + scheme: AgentSessionProviders.AgentHostCopilot, + path: `/untitled-${generateUuid()}`, + }); + + if (chatSessionPosition === 'editor') { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ + resource, + options: { + override: ChatEditorInput.EditorID, + pinned: true, + }, + }); + } else { + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(resource); + view.focus(); + } + } +); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts new file mode 100644 index 00000000000..18b40f560e7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -0,0 +1,1411 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { timeout } from '../../../../../../base/common/async.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import type { IActionEnvelope, INotification, IPermissionResolvedAction, ISessionAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { SessionLifecycle, SessionStatus, ToolCallStatus, TurnState, createSessionState, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IOutputService } from '../../../../../services/output/common/output.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { AgentHostContribution, AgentHostSessionListController, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; + +// ---- Mock agent host service ------------------------------------------------ + +class MockAgentHostService extends mock() { + declare readonly _serviceBrand: undefined; + + private readonly _onDidAction = new Emitter(); + override readonly onDidAction = this._onDidAction.event; + private readonly _onDidNotification = new Emitter(); + override readonly onDidNotification = this._onDidNotification.event; + override readonly onAgentHostExit = Event.None; + override readonly onAgentHostStart = Event.None; + + private _nextId = 1; + private readonly _sessions = new Map(); + public createSessionCalls: IAgentCreateSessionConfig[] = []; + public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; + + override async setAuthToken(_token: string): Promise { } + + override async listSessions(): Promise { + return [...this._sessions.values()]; + } + + override async listAgents() { + return this.agents; + } + + override async refreshModels(): Promise { } + + override async createSession(config?: IAgentCreateSessionConfig): Promise { + if (config) { + this.createSessionCalls.push(config); + } + const id = `sdk-session-${this._nextId++}`; + const session = AgentSession.uri('copilot', id); + this._sessions.set(id, { session, startTime: Date.now(), modifiedTime: Date.now() }); + return session; + } + + override async disposeSession(_session: URI): Promise { } + override async shutdown(): Promise { } + override async restartAgentHost(): Promise { } + + // Protocol methods + public override readonly clientId = 'test-window-1'; + public dispatchedActions: { action: ISessionAction; clientId: string; clientSeq: number }[] = []; + public sessionStates = new Map(); + override async subscribe(resource: URI): Promise { + const existingState = this.sessionStates.get(resource.toString()); + if (existingState) { + return { resource, state: existingState, fromSeq: 0 }; + } + // Root state subscription + if (resource.scheme === 'agenthost') { + return { + resource, + state: { + agents: this.agents.map(a => ({ provider: a.provider, displayName: a.displayName, description: a.description, models: [] })), + }, + fromSeq: 0, + }; + } + const summary: ISessionSummary = { + resource, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + return { + resource, + state: { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }, + fromSeq: 0, + }; + } + override unsubscribe(_resource: URI): void { } + override dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this.dispatchedActions.push({ action, clientId, clientSeq }); + } + + // Test helpers + fireAction(envelope: IActionEnvelope): void { + this._onDidAction.fire(envelope); + } + + addSession(meta: IAgentSessionMetadata): void { + this._sessions.set(AgentSession.id(meta.session), meta); + } + + dispose(): void { + this._onDidAction.dispose(); + this._onDidNotification.dispose(); + } +} + +// ---- Minimal service mocks -------------------------------------------------- + +class MockChatAgentService extends mock() { + declare readonly _serviceBrand: undefined; + + registeredAgents = new Map(); + + override registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation) { + this.registeredAgents.set(data.id, { data, impl: agentImpl }); + return toDisposable(() => this.registeredAgents.delete(data.id)); + } +} + +// ---- Helpers ---------------------------------------------------------------- + +function createTestServices(disposables: DisposableStore) { + const instantiationService = disposables.add(new TestInstantiationService()); + + const agentHostService = new MockAgentHostService(); + disposables.add(toDisposable(() => agentHostService.dispose())); + + const chatAgentService = new MockChatAgentService(); + + instantiationService.stub(IAgentHostService, agentHostService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IProductService, { quality: 'insider' }); + instantiationService.stub(IChatAgentService, chatAgentService); + instantiationService.stub(IChatSessionsService, { + registerChatSessionItemController: () => toDisposable(() => { }), + registerChatSessionContentProvider: () => toDisposable(() => { }), + registerChatSessionContribution: () => toDisposable(() => { }), + }); + instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); + instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None }); + instantiationService.stub(ILanguageModelsService, { + deltaLanguageModelChatProviderDescriptors: () => { }, + registerLanguageModelProvider: () => toDisposable(() => { }), + }); + instantiationService.stub(IConfigurationService, { getValue: () => true }); + instantiationService.stub(IOutputService, { getChannel: () => undefined }); + instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null }); + + return { instantiationService, agentHostService, chatAgentService }; +} + +function createContribution(disposables: DisposableStore) { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + + const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot')); + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'Copilot SDK agent running in a dedicated process', + })); + const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); + + return { contribution, listController, sessionHandler, agentHostService, chatAgentService }; +} + +function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string }> = {}): IChatAgentRequest { + return upcastPartial({ + sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }), + requestId: 'req-1', + agentId: 'agent-host-copilot', + message: overrides.message ?? 'Hello', + variables: overrides.variables ?? { variables: [] }, + location: ChatAgentLocation.Chat, + userSelectedModelId: overrides.userSelectedModelId, + }); +} + +/** Extract the text value from a string or IMarkdownString. */ +function textOf(value: string | IMarkdownString | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + return typeof value === 'string' ? value : value.value; +} + +/** + * Start a turn through the state-driven flow. Creates a chat session, + * starts the requestHandler (non-blocking), and waits for the first action + * to be dispatched. Returns helpers to fire server action envelopes. + */ +async function startTurn( + sessionHandler: AgentHostSessionHandler, + agentHostService: MockAgentHostService, + ds: DisposableStore, + overrides?: Partial<{ + message: string; + sessionResource: URI; + variables: IChatAgentRequest['variables']; + userSelectedModelId: string; + cancellationToken: CancellationToken; + }>, +) { + const sessionResource = overrides?.sessionResource ?? URI.from({ scheme: 'agent-host-copilot', path: '/untitled-turntest' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + ds.add(toDisposable(() => chatSession.dispose())); + + const collected: IChatProgress[][] = []; + const seq = { v: 1 }; + + const turnPromise = chatSession.requestHandler!( + makeRequest({ + message: overrides?.message ?? 'Hello', + sessionResource, + variables: overrides?.variables, + userSelectedModelId: overrides?.userSelectedModelId, + }), + (parts) => collected.push(parts), + [], + overrides?.cancellationToken ?? CancellationToken.None, + ); + + await timeout(10); + + const lastDispatch = agentHostService.dispatchedActions[agentHostService.dispatchedActions.length - 1]; + const session = (lastDispatch?.action as ITurnStartedAction)?.session; + const turnId = (lastDispatch?.action as ITurnStartedAction)?.turnId; + + const fire = (action: ISessionAction) => { + agentHostService.fireAction({ action, serverSeq: seq.v++, origin: undefined }); + }; + + // Echo the turnStarted action to clear the pending write-ahead entry. + // Without this, the optimistic state replay would re-add activeTurn after + // the server's turnComplete clears it, preventing the turn from finishing. + if (lastDispatch) { + agentHostService.fireAction({ + action: lastDispatch.action, + serverSeq: seq.v++, + origin: { clientId: agentHostService.clientId, clientSeq: lastDispatch.clientSeq }, + }); + } + + return { turnPromise, collected, chatSession, session, turnId, fire }; +} + +suite('AgentHostChatContribution', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Registration --------------------------------------------------- + + suite('registration', () => { + + test('registers agent', () => { + const { chatAgentService } = createContribution(disposables); + + assert.ok(chatAgentService.registeredAgents.has('agent-host-copilot')); + }); + }); + + // ---- Session list (IChatSessionItemController) ---------------------- + + suite('session list', () => { + + test('refresh populates items from agent host', async () => { + const { listController, agentHostService } = createContribution(disposables); + + agentHostService.addSession({ session: AgentSession.uri('copilot', 'aaa'), startTime: 1000, modifiedTime: 2000, summary: 'My session' }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'bbb'), startTime: 3000, modifiedTime: 4000 }); + + await listController.refresh(CancellationToken.None); + + assert.strictEqual(listController.items.length, 2); + assert.strictEqual(listController.items[0].label, 'My session'); + assert.strictEqual(listController.items[1].label, 'Session bbb'); + assert.strictEqual(listController.items[0].resource.scheme, 'agent-host-copilot'); + assert.strictEqual(listController.items[0].resource.path, '/aaa'); + }); + + test('refresh fires onDidChangeChatSessionItems', async () => { + const { listController, agentHostService } = createContribution(disposables); + + let fired = false; + disposables.add(listController.onDidChangeChatSessionItems(() => { fired = true; })); + + agentHostService.addSession({ session: AgentSession.uri('copilot', 'x'), startTime: 1000, modifiedTime: 2000 }); + await listController.refresh(CancellationToken.None); + + assert.ok(fired); + }); + + test('refresh handles error gracefully', async () => { + const { listController, agentHostService } = createContribution(disposables); + + agentHostService.listSessions = async () => { throw new Error('fail'); }; + + await listController.refresh(CancellationToken.None); + + assert.strictEqual(listController.items.length, 0); + }); + }); + + // ---- Session ID resolution in _invokeAgent -------------------------- + + suite('session ID resolution', () => { + + test('creates new SDK session for untitled resource', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { message: 'Hello' }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + assert.strictEqual(agentHostService.dispatchedActions[0].action.type, 'session/turnStarted'); + assert.strictEqual((agentHostService.dispatchedActions[0].action as ITurnStartedAction).userMessage.text, 'Hello'); + assert.ok(AgentSession.id(session).startsWith('sdk-session-')); + }); + + test('reuses SDK session for same resource on second message', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const resource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-reuse' }); + const chatSession = await sessionHandler.provideChatSessionContent(resource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + // First turn + const turn1Promise = chatSession.requestHandler!( + makeRequest({ message: 'First', sessionResource: resource }), + () => { }, [], CancellationToken.None, + ); + await timeout(10); + const dispatch1 = agentHostService.dispatchedActions[0]; + const action1 = dispatch1.action as ITurnStartedAction; + // Echo the turnStarted to clear pending write-ahead + agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action1.session, turnId: action1.turnId } as ISessionAction, serverSeq: 2, origin: undefined }); + await turn1Promise; + + // Second turn + const turn2Promise = chatSession.requestHandler!( + makeRequest({ message: 'Second', sessionResource: resource }), + () => { }, [], CancellationToken.None, + ); + await timeout(10); + const dispatch2 = agentHostService.dispatchedActions[1]; + const action2 = dispatch2.action as ITurnStartedAction; + agentHostService.fireAction({ action: dispatch2.action, serverSeq: 3, origin: { clientId: agentHostService.clientId, clientSeq: dispatch2.clientSeq } }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session: action2.session, turnId: action2.turnId } as ISessionAction, serverSeq: 4, origin: undefined }); + await turn2Promise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 2); + assert.strictEqual( + (agentHostService.dispatchedActions[0].action as ITurnStartedAction).session.toString(), + (agentHostService.dispatchedActions[1].action as ITurnStartedAction).session.toString(), + ); + }); + + test('uses sessionId from agent-host scheme resource', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'Hi', + sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/existing-session-42' }), + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(AgentSession.id(session), 'existing-session-42'); + }); + + test('agent-host scheme with untitled path creates new session via mapping', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'Hi', + sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/untitled-abc123' }), + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + // Should create a new SDK session, not use "untitled-abc123" literally + assert.ok(AgentSession.id(session).startsWith('sdk-session-')); + }); + test('passes raw model id extracted from language model identifier', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'Hi', + userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.createSessionCalls.length, 1); + assert.strictEqual(agentHostService.createSessionCalls[0].model, 'claude-sonnet-4-20250514'); + }); + + test('passes model id as-is when no vendor prefix', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'Hi', + userSelectedModelId: 'gpt-4o', + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.createSessionCalls.length, 1); + assert.strictEqual(agentHostService.createSessionCalls[0].model, 'gpt-4o'); + }); + + test('does not create backend session eagerly for untitled sessions', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-deferred' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + // No backend session should have been created yet + assert.strictEqual(agentHostService.createSessionCalls.length, 0); + }); + }); + + // ---- Progress event → chat progress conversion ---------------------- + + suite('progress routing', () => { + + test('delta events become markdownContent progress', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ type: 'session/delta', session, turnId, content: 'hello ' } as ISessionAction); + fire({ type: 'session/delta', session, turnId, content: 'world' } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 2); + assert.strictEqual(collected[0][0].kind, 'markdownContent'); + assert.strictEqual((collected[0][0] as IChatMarkdownContent).content.value, 'hello '); + assert.strictEqual(collected[1][0].kind, 'markdownContent'); + assert.strictEqual((collected[1][0] as IChatMarkdownContent).content.value, 'world'); + }); + + test('tool_start events become toolInvocation progress', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', status: ToolCallStatus.Running }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + assert.strictEqual(collected[0][0].kind, 'toolInvocation'); + }); + + test('tool_complete event transitions toolInvocation to completed', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-2', + result: { success: true, pastTenseMessage: 'Ran Bash command' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + const invocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(invocation.kind, 'toolInvocation'); + assert.strictEqual(invocation.toolCallId, 'tc-2'); + assert.strictEqual(IChatToolInvocation.isComplete(invocation), true); + }); + + test('tool_complete with failure sets error state', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-3', + result: { success: false, pastTenseMessage: '"Bash" failed', toolOutput: 'command not found', error: { message: 'command not found' } }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + const invocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(invocation.kind, 'toolInvocation'); + assert.strictEqual(IChatToolInvocation.isComplete(invocation), true); + }); + + test('malformed toolArguments does not throw', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running, toolArguments: '{not valid json' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + assert.strictEqual(collected[0][0].kind, 'toolInvocation'); + }); + + test('outstanding tool invocations are completed on idle', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + // tool_start without tool_complete + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + const invocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(invocation.kind, 'toolInvocation'); + assert.strictEqual(IChatToolInvocation.isComplete(invocation), true); + }); + + test('events from other sessions are ignored', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + // Delta from a different session — will be ignored (session not subscribed) + agentHostService.fireAction({ + action: { type: 'session/delta', session: AgentSession.uri('copilot', 'other-session'), turnId, content: 'wrong' } as ISessionAction, + serverSeq: 100, + origin: undefined, + }); + fire({ type: 'session/delta', session, turnId, content: 'right' } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + assert.strictEqual(collected.length, 1); + assert.strictEqual((collected[0][0] as IChatMarkdownContent).content.value, 'right'); + }); + }); + + // ---- Cancellation ----------------------------------------------------- + + suite('cancellation', () => { + + test('cancellation resolves the agent invoke', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + const { turnPromise } = await startTurn(sessionHandler, agentHostService, disposables, { + cancellationToken: cts.token, + }); + + cts.cancel(); + await turnPromise; + + assert.ok(agentHostService.dispatchedActions.some(a => a.action.type === 'session/turnCancelled')); + }); + + test('cancellation force-completes outstanding tool invocations', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + cancellationToken: cts.token, + }); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { toolCallId: 'tc-cancel', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, + } as ISessionAction); + + cts.cancel(); + await turnPromise; + + assert.strictEqual(collected.length, 1); + const invocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(invocation.kind, 'toolInvocation'); + assert.strictEqual(IChatToolInvocation.isComplete(invocation), true); + }); + + test('cancellation calls abortSession on the agent host service', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + const { turnPromise } = await startTurn(sessionHandler, agentHostService, disposables, { + cancellationToken: cts.token, + }); + + cts.cancel(); + await turnPromise; + + // Cancellation now dispatches session/turnCancelled action + assert.ok(agentHostService.dispatchedActions.some(a => a.action.type === 'session/turnCancelled')); + }); + }); + + // ---- Error events ------------------------------------------------------- + + suite('error events', () => { + + test('error event renders error message and finishes the request', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId } = await startTurn(sessionHandler, agentHostService, disposables); + + agentHostService.fireAction({ + action: { + type: 'session/error', + session, + turnId, + error: { errorType: 'test_error', message: 'Something went wrong' }, + } as ISessionAction, + serverSeq: 99, + origin: undefined, + }); + + await turnPromise; + + // Should have received the error message and the request should have finished + assert.ok(collected.length >= 1); + const errorPart = collected.flat().find(p => p.kind === 'markdownContent' && (p as IChatMarkdownContent).content.value.includes('Something went wrong')); + assert.ok(errorPart, 'Should have found a markdownContent part containing the error message'); + }); + }); + + // ---- Permission requests ----------------------------------------------- + + suite('permission requests', () => { + + test('permission_request event shows confirmation and responds when confirmed', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + // Simulate a permission request + fire({ + type: 'session/permissionRequest', session, turnId, + request: { requestId: 'perm-1', permissionKind: 'shell', fullCommandText: 'echo hello', rawRequest: '{}' }, + } as ISessionAction); + + await timeout(10); + + // The permission request should have produced a ChatToolInvocation in WaitingForConfirmation state + assert.ok(collected.length >= 1, 'Should have received permission confirmation progress'); + const permInvocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(permInvocation.kind, 'toolInvocation'); + + // Confirm the permission + IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); + + await timeout(10); + + // The handler should have dispatched session/permissionResolved + assert.ok(agentHostService.dispatchedActions.some( + a => a.action.type === 'session/permissionResolved' && (a.action as IPermissionResolvedAction).requestId === 'perm-1' && (a.action as IPermissionResolvedAction).approved === true + )); + + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + }); + + test('permission_request denied when user skips', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/permissionRequest', session, turnId, + request: { requestId: 'perm-2', permissionKind: 'write', path: '/tmp/test.txt', rawRequest: '{}' }, + } as ISessionAction); + + await timeout(10); + + const permInvocation = collected[0][0] as IChatToolInvocation; + // Deny the permission + IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.Denied }); + + await timeout(10); + + assert.ok(agentHostService.dispatchedActions.some( + a => a.action.type === 'session/permissionResolved' && (a.action as IPermissionResolvedAction).requestId === 'perm-2' && (a.action as IPermissionResolvedAction).approved === false + )); + + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + }); + + test('shell permission shows terminal-style confirmation data', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/permissionRequest', session, turnId, + request: { requestId: 'perm-shell', permissionKind: 'shell', fullCommandText: 'echo hello', intention: 'Print greeting', rawRequest: '{}' }, + } as ISessionAction); + + await timeout(10); + const permInvocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(permInvocation.toolSpecificData?.kind, 'terminal'); + const termData = permInvocation.toolSpecificData as IChatTerminalToolInvocationData; + assert.strictEqual(termData.commandLine.original, 'echo hello'); + + IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); + await timeout(10); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + }); + + test('read permission shows input-style confirmation data', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/permissionRequest', session, turnId, + request: { requestId: 'perm-read', permissionKind: 'read', path: '/workspace/file.ts', intention: 'Read file contents', rawRequest: '{"kind":"read","path":"/workspace/file.ts"}' }, + } as ISessionAction); + + await timeout(10); + const permInvocation = collected[0][0] as IChatToolInvocation; + assert.strictEqual(permInvocation.toolSpecificData?.kind, 'input'); + + IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); + await timeout(10); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + }); + }); + + // ---- History loading --------------------------------------------------- + + suite('history loading', () => { + + test('loads user and assistant messages into history', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const sessionUri = AgentSession.uri('copilot', 'sess-1'); + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ resource: sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), + lifecycle: SessionLifecycle.Ready, + turns: [{ + id: 'turn-1', + userMessage: { text: 'What is 2+2?' }, + responseText: '4', + responseParts: [], + toolCalls: [], + usage: undefined, + state: TurnState.Complete, + }], + }); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/sess-1' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + assert.strictEqual(session.history.length, 2); + + const request = session.history[0]; + assert.strictEqual(request.type, 'request'); + if (request.type === 'request') { + assert.strictEqual(request.prompt, 'What is 2+2?'); + } + + const response = session.history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type === 'response') { + assert.strictEqual(response.parts.length, 1); + assert.strictEqual((response.parts[0] as IChatMarkdownContent).content.value, '4'); + } + }); + + test('untitled sessions have empty history', async () => { + const { sessionHandler } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-xyz' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + assert.strictEqual(session.history.length, 0); + }); + }); + + // ---- Tool invocation rendering ----------------------------------------- + + suite('tool invocation rendering', () => { + + test('bash tool renders as terminal command block with output', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { + toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', + toolKind: 'terminal', status: ToolCallStatus.Running, + toolArguments: JSON.stringify({ command: 'echo hello' }), + }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-shell', + result: { success: true, pastTenseMessage: 'Ran `echo hello`', toolOutput: 'hello\n' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + const invocation = collected[0][0] as IChatToolInvocation; + const termData = invocation.toolSpecificData as IChatTerminalToolInvocationData; + assert.deepStrictEqual({ + kind: invocation.kind, + invocationMessage: textOf(invocation.invocationMessage), + pastTenseMessage: textOf(invocation.pastTenseMessage), + dataKind: termData.kind, + commandLine: termData.commandLine.original, + language: termData.language, + outputText: termData.terminalCommandOutput?.text, + exitCode: termData.terminalCommandState?.exitCode, + }, { + kind: 'toolInvocation', + invocationMessage: 'Running `echo hello`', + pastTenseMessage: undefined, + dataKind: 'terminal', + commandLine: 'echo hello', + language: 'shellscript', + outputText: 'hello\n', + exitCode: 0, + }); + }); + + test('bash tool failure sets exit code 1 and error output', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { + toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', + toolKind: 'terminal', status: ToolCallStatus.Running, + toolArguments: JSON.stringify({ command: 'bad_cmd' }), + }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-fail', + result: { success: false, pastTenseMessage: '"Bash" failed', toolOutput: 'command not found: bad_cmd', error: { message: 'command not found: bad_cmd' } }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + const invocation = collected[0][0] as IChatToolInvocation; + const termData = invocation.toolSpecificData as IChatTerminalToolInvocationData; + assert.deepStrictEqual({ + pastTenseMessage: invocation.pastTenseMessage, + outputText: termData.terminalCommandOutput?.text, + exitCode: termData.terminalCommandState?.exitCode, + }, { + pastTenseMessage: undefined, + outputText: 'command not found: bad_cmd', + exitCode: 1, + }); + }); + + test('generic tool has invocation message and no toolSpecificData', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { + toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool', + invocationMessage: 'Using "custom_tool"', status: ToolCallStatus.Running, + toolArguments: JSON.stringify({ input: 'data' }), + }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-gen', + result: { success: true, pastTenseMessage: 'Used "custom_tool"' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + const invocation = collected[0][0] as IChatToolInvocation; + assert.deepStrictEqual({ + invocationMessage: textOf(invocation.invocationMessage), + pastTenseMessage: textOf(invocation.pastTenseMessage), + toolSpecificData: invocation.toolSpecificData, + }, { + invocationMessage: 'Using "custom_tool"', + pastTenseMessage: 'Used "custom_tool"', + toolSpecificData: undefined, + }); + }); + + test('bash tool without arguments has no terminal data', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { + toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running Bash command', toolKind: 'terminal', + status: ToolCallStatus.Running, + }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-noargs', + result: { success: true, pastTenseMessage: 'Ran Bash command' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + const invocation = collected[0][0] as IChatToolInvocation; + assert.deepStrictEqual({ + invocationMessage: textOf(invocation.invocationMessage), + pastTenseMessage: textOf(invocation.pastTenseMessage), + toolSpecificData: invocation.toolSpecificData, + }, { + invocationMessage: 'Running Bash command', + pastTenseMessage: 'Ran Bash command', + toolSpecificData: undefined, + }); + }); + + test('view tool shows file path in messages', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + + fire({ + type: 'session/toolStart', session, turnId, + toolCall: { + toolCallId: 'tc-view', toolName: 'view', displayName: 'View File', + invocationMessage: 'Reading /tmp/test.txt', status: ToolCallStatus.Running, + toolArguments: JSON.stringify({ file_path: '/tmp/test.txt' }), + }, + } as ISessionAction); + fire({ + type: 'session/toolComplete', session, turnId, toolCallId: 'tc-view', + result: { success: true, pastTenseMessage: 'Read /tmp/test.txt' }, + } as ISessionAction); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + + await turnPromise; + + const invocation = collected[0][0] as IChatToolInvocation; + assert.deepStrictEqual({ + invocationMessage: textOf(invocation.invocationMessage), + pastTenseMessage: textOf(invocation.pastTenseMessage), + }, { + invocationMessage: 'Reading /tmp/test.txt', + pastTenseMessage: 'Read /tmp/test.txt', + }); + }); + }); + + // ---- History with tool events ---------------------------------------- + + suite('history with tool events', () => { + + test('tool_start and tool_complete appear as toolInvocationSerialized in history', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'tool-hist'); + + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ resource: sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), + lifecycle: SessionLifecycle.Ready, + turns: [{ + id: 'turn-1', + userMessage: { text: 'run ls' }, + state: TurnState.Complete, + responseParts: [], + usage: undefined, + toolCalls: [{ + toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running `ls`', toolInput: 'ls', toolKind: 'terminal' as const, + success: true, pastTenseMessage: 'Ran `ls`', toolOutput: 'file1\nfile2', + }], + responseText: '', + }], + } as ISessionState); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/tool-hist' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + // request, response + assert.strictEqual(chatSession.history.length, 2); + + const response = chatSession.history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type === 'response') { + assert.strictEqual(response.parts.length, 1); + const toolPart = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(toolPart.kind, 'toolInvocationSerialized'); + assert.strictEqual(toolPart.toolCallId, 'tc-1'); + assert.strictEqual(toolPart.isComplete, true); + // Terminal tool has output and exit code + assert.strictEqual(toolPart.toolSpecificData?.kind, 'terminal'); + const termData = toolPart.toolSpecificData as IChatTerminalToolInvocationData; + assert.strictEqual(termData.terminalCommandOutput?.text, 'file1\nfile2'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + } + }); + + test('orphaned tool_start is marked complete in history', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'orphan-tool'); + + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ resource: sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), + lifecycle: SessionLifecycle.Ready, + turns: [{ + id: 'turn-1', + userMessage: { text: 'do something' }, + state: TurnState.Complete, + responseParts: [], + responseText: '', + usage: undefined, + toolCalls: [{ toolCallId: 'tc-orphan', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', success: false, pastTenseMessage: 'Reading file' }], + }], + } as ISessionState); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/orphan-tool' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.history.length, 2); + const response = chatSession.history[1]; + if (response.type === 'response') { + const toolPart = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(toolPart.kind, 'toolInvocationSerialized'); + assert.strictEqual(toolPart.isComplete, true); + } + }); + + test('non-terminal tool_complete sets pastTenseMessage in history', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'generic-tool'); + + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ resource: sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), + lifecycle: SessionLifecycle.Ready, + turns: [{ + id: 'turn-1', + userMessage: { text: 'search' }, + state: TurnState.Complete, + responseParts: [], + usage: undefined, + responseText: '', + toolCalls: [{ toolCallId: 'tc-g', toolName: 'grep', displayName: 'Grep', invocationMessage: 'Searching...', success: true, pastTenseMessage: 'Searched for pattern' }], + }], + } as ISessionState); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/generic-tool' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + const response = chatSession.history[1]; + if (response.type === 'response') { + const toolPart = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(textOf(toolPart.pastTenseMessage), 'Searched for pattern'); + assert.strictEqual(toolPart.toolSpecificData, undefined); + } + }); + + test('empty session produces empty history', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const sessionUri = AgentSession.uri('copilot', 'empty-sess'); + agentHostService.sessionStates.set(sessionUri.toString(), { + ...createSessionState({ resource: sessionUri, provider: 'copilot', title: 'Test', status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now() }), + lifecycle: SessionLifecycle.Ready, + turns: [], + } as ISessionState); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/empty-sess' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.history.length, 0); + }); + }); + + // ---- Server error handling ---------------------------------------------- + + suite('server error handling', () => { + + test('server-side error resolves the agent invoke without throwing', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId } = await startTurn(sessionHandler, agentHostService, disposables); + + // Simulate a server-side error (e.g. sendMessage failure on the server) + agentHostService.fireAction({ + action: { + type: 'session/error', + session, + turnId, + error: { errorType: 'connection_error', message: 'connection lost' }, + } as ISessionAction, + serverSeq: 99, + origin: undefined, + }); + + await turnPromise; + }); + }); + + // ---- Session list provider filtering -------------------------------- + + suite('session list provider filtering', () => { + + test('filters sessions to only the matching provider', async () => { + const { listController, agentHostService } = createContribution(disposables); + + // Add sessions from both providers (use a non-copilot scheme to test filtering) + agentHostService.addSession({ session: AgentSession.uri('copilot', 'cp-1'), startTime: 1000, modifiedTime: 2000 }); + agentHostService.addSession({ session: URI.from({ scheme: 'other-provider', path: '/cl-1' }), startTime: 1000, modifiedTime: 2000 }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'cp-2'), startTime: 3000, modifiedTime: 4000 }); + + await listController.refresh(CancellationToken.None); + + // The list controller is configured for 'copilot', so only copilot sessions + assert.strictEqual(listController.items.length, 2); + assert.ok(listController.items.every(item => item.resource.scheme === 'agent-host-copilot')); + }); + }); + + // ---- Language model provider ---------------------------------------- + + suite('language model provider', () => { + + test('maps models with correct metadata', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + provider.updateModels([ + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: true }, + ]); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.strictEqual(models.length, 1); + assert.strictEqual(models[0].identifier, 'agent-host-copilot:gpt-4o'); + assert.strictEqual(models[0].metadata.name, 'GPT-4o'); + assert.strictEqual(models[0].metadata.maxInputTokens, 128000); + assert.strictEqual(models[0].metadata.capabilities?.vision, true); + assert.strictEqual(models[0].metadata.targetChatSessionType, 'agent-host-copilot'); + }); + + test('filters out disabled models', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + provider.updateModels([ + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: false, policyState: 'enabled' }, + { provider: 'copilot', id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindow: 16000, supportsVision: false, policyState: 'disabled' }, + ]); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.strictEqual(models.length, 1); + assert.strictEqual(models[0].metadata.name, 'GPT-4o'); + }); + + test('returns empty when no models set', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.strictEqual(models.length, 0); + }); + + test('sendChatRequest throws', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + + await assert.rejects(() => provider.sendChatRequest(), /do not support direct chat requests/); + }); + }); + + // ---- Attachment context conversion -------------------------------------- + + suite('attachment context', () => { + + test('file variable with file:// URI becomes file attachment', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'check this file', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'test.ts', value: URI.file('/workspace/test.ts') }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'file', path: URI.file('/workspace/test.ts').fsPath, displayName: 'test.ts' }, + ]); + }); + + test('directory variable with file:// URI becomes directory attachment', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'check this dir', + variables: { + variables: [ + upcastPartial({ kind: 'directory', id: 'v-dir', name: 'src', value: URI.file('/workspace/src') }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'directory', path: URI.file('/workspace/src').fsPath, displayName: 'src' }, + ]); + }); + + test('implicit selection variable becomes selection attachment', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'explain this', + variables: { + variables: [ + upcastPartial({ kind: 'implicit', id: 'v-implicit', name: 'selection', isFile: true as const, isSelection: true, uri: URI.file('/workspace/foo.ts'), enabled: true, value: undefined }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'selection', path: URI.file('/workspace/foo.ts').fsPath, displayName: 'selection' }, + ]); + }); + + test('non-file URIs are skipped', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'untitled', value: URI.from({ scheme: 'untitled', path: '/foo' }) }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + // No attachments because it's not a file:// URI + assert.strictEqual(turnAction.userMessage.attachments, undefined); + }); + + test('tool variables are skipped', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'use tools', + variables: { + variables: [ + upcastPartial({ kind: 'tool', id: 'v-tool', name: 'myTool', value: { id: 'tool-1' } }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.userMessage.attachments, undefined); + }); + + test('mixed variables extracts only supported types', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'mixed', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'a.ts', value: URI.file('/workspace/a.ts') }), + upcastPartial({ kind: 'tool', id: 'v-tool', name: 'myTool', value: { id: 'tool-1' } }), + upcastPartial({ kind: 'directory', id: 'v-dir', name: 'lib', value: URI.file('/workspace/lib') }), + upcastPartial({ kind: 'file', id: 'v-file', name: 'remote.ts', value: URI.from({ scheme: 'vscode-remote', path: '/remote/file.ts' }) }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'file', path: URI.file('/workspace/a.ts').fsPath, displayName: 'a.ts' }, + { type: 'directory', path: URI.file('/workspace/lib').fsPath, displayName: 'lib' }, + ]); + }); + + test('no variables results in no attachments argument', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + message: 'Hello', + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + const turnAction = agentHostService.dispatchedActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.userMessage.attachments, undefined); + }); + }); + + // ---- AgentHostContribution discovery --------------------------------- + + suite('dynamic discovery', () => { + + test('setting gate prevents registration', async () => { + const { instantiationService } = createTestServices(disposables); + instantiationService.stub(IConfigurationService, { getValue: () => false }); + + const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); + // Contribution should exist but not have registered any agents + assert.ok(contribution); + // Let async work settle + await timeout(10); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts new file mode 100644 index 00000000000..c7e54d2622a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; +import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; + +// ---- Helper factories ------------------------------------------------------- + +function createToolCallState(overrides?: Partial): IToolCallState { + return { + toolCallId: 'tc-1', + toolName: 'test_tool', + displayName: 'Test Tool', + invocationMessage: 'Running test tool...', + status: ToolCallStatus.Running, + ...overrides, + }; +} + +function createCompletedToolCall(overrides?: Partial): ICompletedToolCall { + return { + toolCallId: 'tc-1', + toolName: 'test_tool', + displayName: 'Test Tool', + invocationMessage: 'Running test tool...', + success: true, + pastTenseMessage: 'Ran test tool', + ...overrides, + }; +} + +function createTurn(overrides?: Partial): ITurn { + return { + id: 'turn-1', + userMessage: { text: 'Hello' }, + responseText: '', + responseParts: [], + toolCalls: [], + usage: undefined, + state: TurnState.Complete, + ...overrides, + }; +} + +function createPermission(overrides?: Partial): IPermissionRequest { + return { + requestId: 'perm-1', + permissionKind: 'shell', + ...overrides, + }; +} + +// ---- Tests ------------------------------------------------------------------ + +suite('stateToProgressAdapter', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('turnsToHistory', () => { + + test('empty turns produces empty history', () => { + const result = turnsToHistory([], 'p'); + assert.deepStrictEqual(result, []); + }); + + test('single turn produces request + response pair', () => { + const turn = createTurn({ + userMessage: { text: 'Do something' }, + toolCalls: [createCompletedToolCall()], + }); + + const history = turnsToHistory([turn], 'participant-1'); + assert.strictEqual(history.length, 2); + + // Request + assert.strictEqual(history[0].type, 'request'); + assert.strictEqual(history[0].prompt, 'Do something'); + assert.strictEqual(history[0].participant, 'participant-1'); + + // Response + assert.strictEqual(history[1].type, 'response'); + assert.strictEqual(history[1].participant, 'participant-1'); + assert.strictEqual(history[1].parts.length, 1); + + const serialized = history[1].parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.kind, 'toolInvocationSerialized'); + assert.strictEqual(serialized.toolCallId, 'tc-1'); + assert.strictEqual(serialized.toolId, 'test_tool'); + assert.strictEqual(serialized.isComplete, true); + }); + + test('terminal tool call in history has correct terminal data', () => { + const turn = createTurn({ + toolCalls: [createCompletedToolCall({ + toolKind: 'terminal', + toolInput: 'echo hello', + language: 'shellscript', + toolOutput: 'hello', + success: true, + })], + }); + + const history = turnsToHistory([turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + + assert.ok(serialized.toolSpecificData); + assert.strictEqual(serialized.toolSpecificData.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; commandLine: { original: string }; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } }; + assert.strictEqual(termData.commandLine.original, 'echo hello'); + assert.strictEqual(termData.terminalCommandOutput.text, 'hello'); + assert.strictEqual(termData.terminalCommandState.exitCode, 0); + }); + + test('turn with responseText produces markdown content in history', () => { + const turn = createTurn({ + responseText: 'Hello world', + }); + + const history = turnsToHistory([turn], 'p'); + assert.strictEqual(history.length, 2); + + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + assert.strictEqual(response.parts.length, 1); + assert.strictEqual(response.parts[0].kind, 'markdownContent'); + assert.strictEqual((response.parts[0] as IChatMarkdownContent).content.value, 'Hello world'); + }); + + test('error turn produces error message in history', () => { + const turn = createTurn({ + state: TurnState.Error, + error: { errorType: 'test', message: 'boom' }, + }); + + const history = turnsToHistory([turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const errorPart = response.parts.find(p => p.kind === 'markdownContent' && (p as IChatMarkdownContent).content.value.includes('boom')); + assert.ok(errorPart, 'Should have a markdownContent part containing the error message'); + }); + + test('failed tool in history has exitCode 1', () => { + const turn = createTurn({ + toolCalls: [createCompletedToolCall({ + toolKind: 'terminal', + toolInput: 'bad-command', + toolOutput: 'error', + success: false, + })], + }); + + const history = turnsToHistory([turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + + assert.ok(serialized.toolSpecificData); + assert.strictEqual(serialized.toolSpecificData.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandState.exitCode, 1); + }); + }); + + suite('toolCallStateToInvocation', () => { + + test('creates ChatToolInvocation for running tool', () => { + const tc = createToolCallState({ + toolCallId: 'tc-42', + toolName: 'my_tool', + displayName: 'My Tool', + invocationMessage: 'Doing stuff', + status: ToolCallStatus.Running, + }); + + const invocation = toolCallStateToInvocation(tc); + assert.strictEqual(invocation.toolCallId, 'tc-42'); + assert.strictEqual(invocation.toolId, 'my_tool'); + assert.strictEqual(invocation.source, ToolDataSource.Internal); + }); + + test('sets terminal toolSpecificData', () => { + const tc = createToolCallState({ + toolKind: 'terminal', + toolInput: 'ls -la', + }); + + const invocation = toolCallStateToInvocation(tc); + assert.ok(invocation.toolSpecificData); + assert.strictEqual(invocation.toolSpecificData.kind, 'terminal'); + const termData = invocation.toolSpecificData as { kind: 'terminal'; commandLine: { original: string } }; + assert.strictEqual(termData.commandLine.original, 'ls -la'); + }); + + test('parses toolArguments as parameters', () => { + const tc = createToolCallState({ + toolArguments: '{"path":"test.ts"}', + }); + + const invocation = toolCallStateToInvocation(tc); + assert.deepStrictEqual(invocation.parameters, { path: 'test.ts' }); + }); + }); + + suite('permissionToConfirmation', () => { + + test('shell permission has terminal data', () => { + const perm = createPermission({ + permissionKind: 'shell', + fullCommandText: 'rm -rf /', + intention: 'Delete everything', + }); + + const invocation = permissionToConfirmation(perm); + assert.ok(invocation.toolSpecificData); + assert.strictEqual(invocation.toolSpecificData.kind, 'terminal'); + const termData = invocation.toolSpecificData as { kind: 'terminal'; commandLine: { original: string } }; + assert.strictEqual(termData.commandLine.original, 'rm -rf /'); + }); + + test('mcp permission uses server + tool name as title', () => { + const perm = createPermission({ + permissionKind: 'mcp', + serverName: 'My Server', + toolName: 'my_tool', + }); + + const invocation = permissionToConfirmation(perm); + const message = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage.value; + assert.ok(message.includes('My Server: my_tool')); + }); + + test('write permission has input data', () => { + const perm = createPermission({ + permissionKind: 'write', + path: '/test.ts', + rawRequest: '{"path":"/test.ts","content":"hello"}', + }); + + const invocation = permissionToConfirmation(perm); + assert.ok(invocation.toolSpecificData); + assert.strictEqual(invocation.toolSpecificData.kind, 'input'); + }); + }); + + suite('finalizeToolInvocation', () => { + + test('finalizes terminal tool with output and exit code', () => { + const tc = createToolCallState({ + toolKind: 'terminal', + toolInput: 'echo hi', + status: ToolCallStatus.Running, + }); + const invocation = toolCallStateToInvocation(tc); + + const completedTc = createToolCallState({ + toolKind: 'terminal', + toolInput: 'echo hi', + status: ToolCallStatus.Completed, + toolOutput: 'output text', + }); + + finalizeToolInvocation(invocation, completedTc); + + assert.ok(invocation.toolSpecificData); + assert.strictEqual(invocation.toolSpecificData.kind, 'terminal'); + const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandOutput.text, 'output text'); + assert.strictEqual(termData.terminalCommandState.exitCode, 0); + }); + + test('finalizes failed tool with error message', () => { + const tc = createToolCallState({ + status: ToolCallStatus.Running, + }); + const invocation = toolCallStateToInvocation(tc); + + const failedTc = createToolCallState({ + status: ToolCallStatus.Failed, + error: { message: 'timeout' }, + }); + + // Should not throw + finalizeToolInvocation(invocation, failedTc); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index bae11e420fb..a4424acc1c1 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,9 +9,9 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; +import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, IChatSessionItemsDelta } from '../../common/chatSessionsService.js'; +import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { @@ -229,4 +229,16 @@ export class MockChatSessionsService implements IChatSessionsService { registerSessionResourceAlias(_untitledResource: URI, _realResource: URI): void { // noop } + + registerChatSessionContribution(contribution: IChatSessionsExtensionPoint): IDisposable { + this.contributions.push(contribution); + return { + dispose: () => { + const idx = this.contributions.indexOf(contribution); + if (idx >= 0) { + this.contributions.splice(idx, 1); + } + } + }; + } } diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 68821267b86..6c887e147dd 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -91,6 +91,7 @@ import '../platform/userDataProfile/electron-browser/userDataProfileStorageServi import './services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; +import '../platform/agentHost/electron-browser/agentHostService.js'; import './services/browserView/electron-browser/playwrightWorkbenchService.js'; import './services/process/electron-browser/processService.js'; import './services/power/electron-browser/powerService.js';