From 98f15b55eaa9ec24b60cad2905d53f721ad67357 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 16 Mar 2026 15:33:31 -0700 Subject: [PATCH] Implement agentHost process (#296627) * agent host init * Agent host: Copilot SDK integration with chat UI * Agent host: direct MessagePort, logging, SDK wrapper, env fix * Refactoring and cleanup * Copilot-authored message: Agent-host tool rendering, protocol, and session fixes Tool invocation rendering: - Emit tool_start/tool_complete as ChatToolInvocation (not progressMessage) - Shell tools (bash/powershell) render as terminal command blocks with IChatTerminalToolInvocationData, output, and exit codes - Non-shell tools render via invocationMessage/pastTenseMessage (markdown) - Filter out report_intent (hidden internal tool) Agent-agnostic protocol: - IPC events carry display-ready fields (displayName, invocationMessage, pastTenseMessage, toolInput, toolOutput, toolKind, language) - All Copilot CLI-specific logic in copilotToolDisplay.ts with typed interfaces for known tools (CopilotToolName enum, parameter types) - Renderer never references specific SDK tool names Session fixes: - Resumed sessions show tool invocations in history (getSessionMessages now returns tool events alongside messages) - Fixed 'already has a pending request' on resumed sessions by conditionally providing interruptActiveResponseCallback - Fixed event filtering for resumed sessions (sessionId override in _trackSession) Documentation: - Split parity.md into design.md (decisions) and backlog.md (tasks) - Updated architecture.md, sessions.md with cross-references - Added maintenance notes to all docs * Copilot-authored message: Model picker, session class, DI and test cleanup * Cleanups * stuff * add diagram * Add claude agent * Clean up * Copy some build script changes from #295817 * Simplify * Update docs * Register agent-host via chatSessions contribution API, reduce peripheral diff * Cleanup * Don't ship stuff in stable * Dynamic agent discovery via listAgents() IPC Replace hardcoded per-provider contributions with a single AgentHostContribution that discovers available agents from the agent host process at startup. Each IAgent backend now exposes an IAgentDescriptor with display metadata and auth requirements. - Add IAgentDescriptor interface and listAgents() to IPC contract - CopilotAgent/ClaudeAgent return descriptors via getDescriptor() - Single AgentHostContribution discovers + registers dynamically - Remove agentHostConstants.ts (no more hardcoded session types) - AgentHostSessionListController/LMProvider take params instead - Rename AgentSessionProviders.AgentHost -> AgentHostCopilot - Update architecture.md, sessions.md, backlog.md (Written by Copilot) * Fix review findings: proxy, disposal, filtering, tests - Add listAgents() forwarding to AgentHostServiceClient - Guard async discovery against disposal race - Add provider field to IAgentModelInfo for per-provider filtering - Filter models and sessions by provider in LM provider and list controller - Update tests for new dynamic API and agent-host-copilot scheme (Written by Copilot) * Use DI for AgentHostLanguageModelProvider (Written by Copilot) * Strip @img/sharp native binaries from builds sharp is a transitive dependency of the Claude Agent SDK used for image processing. Its native .node binaries cause dpkg-shlibdeps errors during Debian packaging due to $ORIGIN RPATH references. Strip all @img/sharp-* platform packages since the agent host doesn't need image processing at runtime. (Written by Copilot) * Strip Claude SDK vendored ripgrep binaries The Claude Agent SDK bundles ripgrep binaries for all platforms under vendor/ripgrep/. Wrong-architecture binaries cause macOS Mach-O verification to fail. Strip them entirely via .moduleignore (VS Code has its own ripgrep) and add to verify-macho skip list. (Written by Copilot) * Add tests for AgentSession, AgentService dispatcher, and workbench agent host components (Written by Copilot) * Add trace logging, IPC output channel, tool permissions, and attachment context - Add Agent Host IPC output channel (only registered at trace log level) that logs all IPC method calls, results, and progress events with full JSON payloads - Add trace-level logging in AgentService dispatcher for all method calls - Add trace-level logging in session handler for all progress events and session resolution - Wire up onPermissionRequest handler on CopilotClient.createSession and resumeSession to auto-approve tool permission requests - Add IAgentAttachment type to IPC contract and thread attachments from chat variables (file, directory, selection) through sendMessage to the Copilot SDK (Written by Copilot) * Add tests for attachment context conversion and threading (Written by Copilot) * Add gap analysis docs for Copilot and Claude SDK implementations (Written by Copilot) * Sanitize env vars for Copilot CLI subprocess Strip VSCODE_*, ELECTRON_* (except ELECTRON_RUN_AS_NODE), NODE_OPTIONS, and other debug-related env vars that can interfere with the Node.js process the SDK spawns. Matches the env sanitization from the extension implementation. Also set useStdio and autoStart for proper CLI communication. (Written by Copilot) * Add error, usage, and title_changed event types to IPC contract Add IAgentErrorEvent, IAgentUsageEvent, and IAgentTitleChangedEvent to the progress event union. Wire up session.error and assistant.usage events from the Copilot SDK to fire as IPC events instead of only logging. Handle error events in the renderer session handler by rendering the error message. Usage and title_changed events are logged at trace level. (Written by Copilot) * Add abortSession IPC method for proper cancellation Add abortSession(session) to the IPC contract, implemented across AgentService, CopilotAgent (calls session.abort()), ClaudeAgent (no-op, uses AbortController), and the renderer proxy. Wire up cancellation in the session handler to call abortSession before finishing, so the SDK actually stops processing. (Written by Copilot) * Address reviewer feedback: error finishes request, Claude abort, tests - Error events now call finish() so the request doesn't hang if the SDK doesn't send idle after an error - ClaudeAgent.abortSession calls ClaudeSession.abort() which signals the AbortController and creates a new one for future turns - Add test: cancellation calls abortSession on the agent host service - Add test: error event renders message and finishes the request - Remove stale TODO in interruptActiveResponseCallback - Use timeout() helper instead of raw setTimeout in test - Update gap docs to reflect completed work (Written by Copilot) * Add permission request IPC round-trip (Written by Copilot) * Remove Claude agent from agent-host process Strip the Claude Agent SDK integration from the agent-host utility process to focus on the Copilot SDK path. - Delete src/vs/platform/agent/node/claude/ (claudeAgent, claudeSession, claudeToolDisplay) - Remove @anthropic-ai/claude-agent-sdk from package.json - Remove AgentHostClaude enum member and all switch cases - Remove Claude command registration in electron-browser chat.contribution - Clean up build scripts (.moduleignore, verify-macho, gulpfile.vscode) - Narrow AgentProvider type to just 'copilot' - Update tests and documentation (Written by Copilot) * Wire up permission confirmation UI with ChatToolInvocation (Written by Copilot) * Fix reviewer feedback: safe permission serialization, deny on abort/dispose (Written by Copilot) * Forward reasoning events as thinking blocks (Written by Copilot) * Pass workspace folder as workingDirectory to Copilot SDK (Written by Copilot) * Store and pass workingDirectory on session resume, update gap docs (Written by Copilot) * Fix permission rendering, session-scoped permissions, and test gaps (Written by Copilot) * Auto-approve read permissions inside workspace folder (Written by Copilot) * Move read auto-approve into CopilotAgent where permission policy belongs (Written by Copilot) * Update gap docs (Written by Copilot) * Use log language for IPC output channel, add trace prefix (Written by Copilot) * Add tool rendering gaps to docs (Written by Copilot) * Stringify URIs in IPC output channel for readability (Written by Copilot) * Fix IPC output channel: use log languageId with non-log channel for proper append + syntax highlighting (Written by Copilot) * Fix build errors: add URI import, fix test mock types (Written by Copilot) * Don't localize agent host provider strings (Written by Copilot) * Remove claude-agent-sdk from eslint allowed imports (Written by Copilot) * fix test * initial thoughts * Rename folder to agentHost * Fix paths * Fixes * Fixes for copilot * Fix moduleignore * first working protocol version align more closely with protocol json rpc and some gaps * cleanup * Fix copilot pty.node packaging * Fix test * prebuild packaging * Agenthost server fixes * Update monaco.d.ts * Update docs * Fixes * Build fix * Fix build issues * reduce duplication in side effecting code * fix model switching not working * reduce mock duplication * Build fixes * Copy vscode's node.pty * And ripgrep * And thsi * Ripgrep goes to non-SDK * Skip copy for stable build * Remove outdated script * Build fixes for asar * fix * Add some logging * Fix for windows * Fix * Logs * build: add glob diagnostic for copyCopilotNativeDeps * build: check both node_modules/ and .asar.unpacked/ for source binaries * Fix * Remove excalidraw --------- Co-authored-by: Connor Peet Co-authored-by: Connor Peet --- .vscode/launch.json | 10 + build/.moduleignore | 25 + build/buildfile.ts | 4 +- build/darwin/create-universal-app.ts | 57 +- build/darwin/verify-macho.ts | 10 + build/gulpfile.vscode.ts | 98 +- build/next/index.ts | 1 + build/npm/postinstall.ts | 13 + eslint.config.js | 2 + package-lock.json | 263 ++- package.json | 4 + scripts/code-agent-host.js | 79 + scripts/code-agent-host.sh | 31 + src/vs/code/electron-main/app.ts | 9 + src/vs/platform/agentHost/architecture.md | 224 +++ src/vs/platform/agentHost/common/agent.ts | 27 + .../platform/agentHost/common/agentService.ts | 399 +++++ .../platform/agentHost/common/state/AGENTS.md | 81 + .../agentHost/common/state/sessionActions.ts | 253 +++ .../common/state/sessionCapabilities.ts | 50 + .../common/state/sessionClientState.ts | 280 ++++ .../agentHost/common/state/sessionProtocol.ts | 180 +++ .../agentHost/common/state/sessionReducers.ts | 270 ++++ .../agentHost/common/state/sessionState.ts | 287 ++++ .../common/state/sessionTransport.ts | 42 + .../agentHost/common/state/versions/v1.ts | 309 ++++ .../common/state/versions/versionRegistry.ts | 259 +++ src/vs/platform/agentHost/design.md | 86 + .../electron-browser/agentHostService.ts | 121 ++ .../electron-main/electronAgentHostStarter.ts | 106 ++ .../agentHost/node/agentEventMapper.ts | 160 ++ .../platform/agentHost/node/agentHostMain.ts | 67 + .../agentHost/node/agentHostServerMain.ts | 169 ++ .../agentHost/node/agentHostService.ts | 80 + .../platform/agentHost/node/agentService.ts | 224 +++ .../agentHost/node/agentSideEffects.ts | 223 +++ .../agentHost/node/copilot/copilotAgent.ts | 693 ++++++++ .../node/copilot/copilotSessionWrapper.ts | 217 +++ .../node/copilot/copilotToolDisplay.ts | 286 ++++ .../agentHost/node/nodeAgentHostStarter.ts | 55 + .../agentHost/node/protocolServerHandler.ts | 334 ++++ .../agentHost/node/sessionStateManager.ts | 218 +++ .../agentHost/node/webSocketTransport.ts | 135 ++ src/vs/platform/agentHost/protocol.md | 511 ++++++ src/vs/platform/agentHost/sessions.md | 62 + .../test/common/agentService.test.ts | 41 + .../test/node/agentEventMapper.test.ts | 221 +++ .../agentHost/test/node/agentService.test.ts | 185 +++ .../test/node/agentSideEffects.test.ts | 289 ++++ .../platform/agentHost/test/node/mockAgent.ts | 241 +++ .../test/node/protocolServerHandler.test.ts | 308 ++++ .../node/protocolWebSocket.integrationTest.ts | 663 ++++++++ .../test/node/sessionStateManager.test.ts | 163 ++ src/vs/platform/environment/common/argv.ts | 2 + src/vs/platform/environment/node/argv.ts | 2 + .../environment/node/environmentService.ts | 4 + src/vs/server/node/serverServices.ts | 8 + .../chat/browser/sessionTargetPicker.ts | 2 + .../agentHost/agentHostChatContribution.ts | 260 +++ .../agentHostLanguageModelProvider.ts | 73 + .../agentHost/agentHostSessionHandler.ts | 486 ++++++ .../agentHostSessionListController.ts | 97 ++ .../agentHost/stateToProgressAdapter.ts | 199 +++ .../browser/agentSessions/agentSessions.ts | 18 + .../contrib/chat/browser/chat.contribution.ts | 8 + .../chatSessions/chatSessions.contribution.ts | 31 +- .../chat/common/chatSessionsService.ts | 6 + .../electron-browser/chat.contribution.ts | 38 +- .../agentHostChatContribution.test.ts | 1411 +++++++++++++++++ .../stateToProgressAdapter.test.ts | 298 ++++ .../test/common/mockChatSessionsService.ts | 16 +- src/vs/workbench/workbench.desktop.main.ts | 1 + 72 files changed, 12068 insertions(+), 17 deletions(-) create mode 100644 scripts/code-agent-host.js create mode 100755 scripts/code-agent-host.sh create mode 100644 src/vs/platform/agentHost/architecture.md create mode 100644 src/vs/platform/agentHost/common/agent.ts create mode 100644 src/vs/platform/agentHost/common/agentService.ts create mode 100644 src/vs/platform/agentHost/common/state/AGENTS.md create mode 100644 src/vs/platform/agentHost/common/state/sessionActions.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionCapabilities.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionClientState.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionProtocol.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionReducers.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionState.ts create mode 100644 src/vs/platform/agentHost/common/state/sessionTransport.ts create mode 100644 src/vs/platform/agentHost/common/state/versions/v1.ts create mode 100644 src/vs/platform/agentHost/common/state/versions/versionRegistry.ts create mode 100644 src/vs/platform/agentHost/design.md create mode 100644 src/vs/platform/agentHost/electron-browser/agentHostService.ts create mode 100644 src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts create mode 100644 src/vs/platform/agentHost/node/agentEventMapper.ts create mode 100644 src/vs/platform/agentHost/node/agentHostMain.ts create mode 100644 src/vs/platform/agentHost/node/agentHostServerMain.ts create mode 100644 src/vs/platform/agentHost/node/agentHostService.ts create mode 100644 src/vs/platform/agentHost/node/agentService.ts create mode 100644 src/vs/platform/agentHost/node/agentSideEffects.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotAgent.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts create mode 100644 src/vs/platform/agentHost/node/nodeAgentHostStarter.ts create mode 100644 src/vs/platform/agentHost/node/protocolServerHandler.ts create mode 100644 src/vs/platform/agentHost/node/sessionStateManager.ts create mode 100644 src/vs/platform/agentHost/node/webSocketTransport.ts create mode 100644 src/vs/platform/agentHost/protocol.md create mode 100644 src/vs/platform/agentHost/sessions.md create mode 100644 src/vs/platform/agentHost/test/common/agentService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentEventMapper.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentSideEffects.test.ts create mode 100644 src/vs/platform/agentHost/test/node/mockAgent.ts create mode 100644 src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionStateManager.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts 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';