diff --git a/package-lock.json b/package-lock.json index 1e925b15872..a5cb03deb59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot": "1.0.39", + "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1041,26 +1041,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", - "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz", + "integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.34", - "@github/copilot-darwin-x64": "1.0.34", - "@github/copilot-linux-arm64": "1.0.34", - "@github/copilot-linux-x64": "1.0.34", - "@github/copilot-win32-arm64": "1.0.34", - "@github/copilot-win32-x64": "1.0.34" + "@github/copilot-darwin-arm64": "1.0.39", + "@github/copilot-darwin-x64": "1.0.39", + "@github/copilot-linux-arm64": "1.0.39", + "@github/copilot-linux-x64": "1.0.39", + "@github/copilot-win32-arm64": "1.0.39", + "@github/copilot-win32-x64": "1.0.39" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", - "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz", + "integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==", "cpu": [ "arm64" ], @@ -1074,9 +1074,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", - "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz", + "integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==", "cpu": [ "x64" ], @@ -1090,9 +1090,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", - "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", + "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", "cpu": [ "arm64" ], @@ -1106,9 +1106,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", - "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", + "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", "cpu": [ "x64" ], @@ -1122,12 +1122,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.2.2.tgz", - "integrity": "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz", + "integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.21", + "@github/copilot": "^1.0.36-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1145,9 +1145,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", - "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz", + "integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==", "cpu": [ "arm64" ], @@ -1161,9 +1161,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", - "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz", + "integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index c68dc2b16df..be0b6399e74 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot": "1.0.39", + "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index c1055bdfd71..c49ffedfe7a 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,8 +9,8 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot": "1.0.39", + "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", @@ -81,26 +81,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", - "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz", + "integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.34", - "@github/copilot-darwin-x64": "1.0.34", - "@github/copilot-linux-arm64": "1.0.34", - "@github/copilot-linux-x64": "1.0.34", - "@github/copilot-win32-arm64": "1.0.34", - "@github/copilot-win32-x64": "1.0.34" + "@github/copilot-darwin-arm64": "1.0.39", + "@github/copilot-darwin-x64": "1.0.39", + "@github/copilot-linux-arm64": "1.0.39", + "@github/copilot-linux-x64": "1.0.39", + "@github/copilot-win32-arm64": "1.0.39", + "@github/copilot-win32-x64": "1.0.39" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", - "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz", + "integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==", "cpu": [ "arm64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", - "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz", + "integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==", "cpu": [ "x64" ], @@ -130,9 +130,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", - "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", + "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", "cpu": [ "arm64" ], @@ -146,9 +146,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", - "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", + "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", "cpu": [ "x64" ], @@ -162,12 +162,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.2.2.tgz", - "integrity": "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz", + "integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.21", + "@github/copilot": "^1.0.36-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -185,9 +185,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", - "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz", + "integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==", "cpu": [ "arm64" ], @@ -201,9 +201,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", - "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz", + "integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 5b971c7551f..1322f9e7895 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,8 +4,8 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@github/copilot": "1.0.39", + "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 33ebc3dacf5..1136c5fd147 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -260,6 +260,8 @@ function safeStringify(value: unknown): string { export type AutoApproveLevel = 'default' | 'autoApprove' | 'autopilot'; +export type SessionMode = 'interactive' | 'plan'; + export interface IPermissionsValue { readonly allow: readonly string[]; readonly deny: readonly string[]; @@ -319,6 +321,22 @@ export const platformSessionSchema = createSchema({ sessionMutable: true, }), [SessionConfigKey.Permissions]: permissionsProperty, + [SessionConfigKey.Mode]: schemaProperty({ + type: 'string', + title: localize('agentHost.sessionConfig.mode', "Agent Mode"), + description: localize('agentHost.sessionConfig.modeDescription', "How the agent should approach this turn"), + enum: ['interactive', 'plan'], + enumLabels: [ + localize('agentHost.sessionConfig.mode.interactive', "Interactive"), + localize('agentHost.sessionConfig.mode.plan', "Plan"), + ], + enumDescriptions: [ + localize('agentHost.sessionConfig.mode.interactiveDescription', "Ask for input and approval for each action"), + localize('agentHost.sessionConfig.mode.planDescription', "Generate a plan first, then choose how to execute it"), + ], + default: 'interactive', + sessionMutable: true, + }), }); /** diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 5ab2847f445..cea7febc212 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -265,7 +265,7 @@ export interface IAgentToolPendingConfirmationSignal { /** Protocol-shaped pending-confirmation state, dispatched verbatim into `SessionToolCallReady`. */ readonly state: ToolCallPendingConfirmationState; /** Host-only auto-approval kind (not part of the dispatched action). */ - readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool'; + readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory'; /** Host-only auto-approval path target (not part of the dispatched action). */ readonly permissionPath?: string; } diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index c3371bc42d6..33afd3210c5 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -26,6 +26,8 @@ export const enum SessionConfigKey { Branch = 'branch', /** `'branchNameHint'` — client-supplied hint used during worktree creation. */ BranchNameHint = 'branchNameHint', + /** `'mode'` — agent execution mode (interactive / plan). */ + Mode = 'mode', } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bae7868abbb..65b4a87ffce 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -9,7 +9,7 @@ import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; @@ -23,23 +23,23 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, AgentSignal, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; -import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationStatus, CustomizationRef, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn, type PolicyState } from '../../common/state/sessionState.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; +import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; +import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { createShellTools, ShellManager } from './copilotShellTools.js'; +import { ShellManager, createShellTools } from './copilotShellTools.js'; interface ICreatedWorktree { readonly repositoryRoot: URI; @@ -70,6 +70,40 @@ export interface ICopilotClient { readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; } +/** + * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's + * private `connection` field to wire plan mode. See {@link CopilotAgent._enablePlanModeOnClient}. + */ +interface IExitPlanModeConnection { + sendRequest(method: string, params: unknown): Promise; + onRequest(method: string, handler: (params: IExitPlanModeRequestParams) => Promise): { dispose(): void }; +} + +/** + * Payload of the CLI's `exitPlanMode.request` RPC. The CLI dispatches one + * per `exit_plan_mode` tool invocation when the session was created with + * `requestExitPlanMode: true`. + */ +export interface IExitPlanModeRequestParams { + readonly sessionId: string; + readonly summary: string; + readonly planContent: string; + readonly actions: readonly string[]; + readonly recommendedAction: string; +} + +/** + * Response for the CLI's `exitPlanMode.request` RPC. The CLI feeds this + * directly into `session.respondToExitPlanMode`, which resolves the + * pending tool call and (when approved) updates the SDK's `currentMode`. + */ +export interface IExitPlanModeResponse { + readonly approved: boolean; + readonly selectedAction?: string; + readonly autoApproveEdits?: boolean; + readonly feedback?: string; +} + /** * Corrected shape of {@link CopilotClient.listModels} entries. * @@ -199,6 +233,7 @@ export class CopilotAgent extends Disposable implements IAgent { @ISessionDataService private readonly _sessionDataService: ISessionDataService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, ) { super(); this._plugins = this._register(this._instantiationService.createInstance(PluginController)); @@ -282,6 +317,59 @@ export class CopilotAgent extends Disposable implements IAgent { await client?.stop(); } + /** + * Enables plan mode by injecting `requestExitPlanMode: true` into the + * payload of every `session.create` / `session.resume` JSON-RPC request, + * and registers a connection-level handler for the resulting + * `exitPlanMode.request` RPC the CLI sends back. + * + * The SDK (`@github/copilot-sdk@^0.3.0`) does not expose `onExitPlanMode` + * in its public {@link SessionConfig} surface, so both the wire flag and + * the response handler are wired through the SDK's private + * `MessageConnection`. Once the SDK adds first-class support, this shim + * should be removed. + */ + protected _enablePlanModeOnClient(client: ICopilotClient): void { + // `connection` is declared private on `CopilotClient` at the type + // level but is a plain field at runtime — see the SDK's compiled + // `dist/client.js`. + const connection = (client as unknown as { connection?: IExitPlanModeConnection }).connection; + if (!connection) { + this._logService.warn('[Copilot] Could not enable plan mode: client.connection is null'); + return; + } + if (typeof connection.sendRequest !== 'function') { + this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.sendRequest is ${typeof connection.sendRequest}`); + return; + } + if (typeof connection.onRequest !== 'function') { + this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.onRequest is ${typeof connection.onRequest}`); + return; + } + const originalSendRequest = connection.sendRequest.bind(connection); + connection.sendRequest = (method: string, params: unknown) => { + if ((method === 'session.create' || method === 'session.resume') && params && typeof params === 'object') { + return originalSendRequest(method, { ...params as Record, requestExitPlanMode: true }); + } + return originalSendRequest(method, params); + }; + + // Handle the inbound `exitPlanMode.request` RPC the CLI dispatches + // when the model invokes `exit_plan_mode`. Routing by `sessionId` + // hands the request off to the matching {@link CopilotAgentSession}, + // which surfaces it as a {@link SessionInputRequest} and resolves + // this promise with the user's choice. + const handlerDisposable = connection.onRequest('exitPlanMode.request', async (params: IExitPlanModeRequestParams): Promise => { + const session = this._sessions.get(params.sessionId); + if (!session) { + this._logService.warn(`[Copilot] exitPlanMode.request for unknown session ${params.sessionId}`); + return { approved: false }; + } + return session.handleExitPlanModeRequest(params); + }); + this._register(toDisposable(() => handlerDisposable.dispose())); + } + // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { @@ -333,7 +421,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); const client = this._createCopilotClient({ - githubToken: tokenAtStartup, + gitHubToken: tokenAtStartup, useLoggedInUser: false, useStdio: true, autoStart: true, @@ -346,6 +434,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new Error('Copilot authentication changed while the client was starting'); } this._logService.info('[Copilot] CopilotClient started successfully'); + this._enablePlanModeOnClient(client); this._client = client; this._clientStarting = undefined; return client; @@ -644,6 +733,7 @@ export class CopilotAgent extends Disposable implements IAgent { const values = sessionSchema.validateOrDefault(params.config, { [SessionConfigKey.Isolation]: isolationValue, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, + [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, // Permissions intentionally omitted — leave unset so auto-approval // falls through to the host-level `permissions` default, and only // materializes on the session once the user hits "Allow in this @@ -727,7 +817,8 @@ export class CopilotAgent extends Disposable implements IAgent { } try { - await entry.send(prompt, attachments, turnId); + const sdkMode = this._resolveSdkMode(session); + await entry.send(prompt, attachments, turnId, sdkMode); } catch (err) { const errCode = (err as { code?: number })?.code; const errMsg = err instanceof Error ? err.message : String(err); @@ -737,6 +828,35 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + /** + * Translates the AHP-side `(mode, autoApprove)` pair to the Copilot + * SDK's three-mode space (`interactive` / `plan` / `autopilot`): + * + * - `mode='plan'` → SDK `plan` (auto-approval is irrelevant; the + * agent host's existing session-state auto-approval logic handles + * `plan.md` writes). + * - `mode='interactive'` + `autoApprove='autopilot'` → SDK `autopilot` + * (the SDK auto-approves all tool calls). + * - `mode='interactive'` + any other autoApprove → SDK `interactive` + * (the agent host's own auto-approval logic continues to gate tool + * calls based on `autoApprove`). + * + * Returns `undefined` when no mode is configured for the session, so + * the SDK's current mode is left untouched. + */ + private _resolveSdkMode(session: URI): CopilotSdkMode | undefined { + const sessionKey = session.toString(); + const mode = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode); + if (mode === 'plan') { + return 'plan'; + } + if (mode === 'interactive') { + const autoApprove = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove); + return autoApprove === 'autopilot' ? 'autopilot' : 'interactive'; + } + return undefined; + } + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); @@ -962,6 +1082,12 @@ export class CopilotAgent extends Disposable implements IAgent { customAgents, skillDirectories: toSdkSkillDirectories(plugins.flatMap(p => p.skills)), tools: [...shellTools, ...callbacks.clientTools], + // Enable infinite sessions so the SDK provisions a workspace + // directory (containing `plan.md`, `checkpoints/`, `files/`). + // The workspace is required for plan mode to work — without + // it, `rpc.plan.read()` returns `path: null` and the SDK + // never emits `exit_plan_mode.requested`. + infiniteSessions: { enabled: true }, }; }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index d369fa4c2c0..c3105b0e375 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -12,17 +12,22 @@ import { join } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; import type { IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; +import { platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSignal, IAgentAttachment } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type URI as ProtocolURI, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -30,9 +35,50 @@ import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; +/** + * The full set of agent modes the Copilot SDK accepts. Wider than the + * {@link SessionMode} the AHP exposes — the SDK has a first-class + * `'autopilot'` mode while AHP models that as + * `mode='interactive', autoApprove='autopilot'`. The Copilot agent + * translates between the two views in {@link CopilotAgentSession.send} + * and the `session.mode_changed` listener. + */ +export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; + const COPILOT_HOME_DIRECTORY = '.copilot'; const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); +/** + * Display labels and descriptions for the SDK's `exit_plan_mode` action ids. + * Keys not present here fall back to the raw action id. + */ +function getPlanActionDescription(actionId: string): { label: string; description: string } | undefined { + switch (actionId) { + case 'autopilot': + return { + label: localize('agentHost.planReview.autopilot.label', "Implement with Autopilot"), + description: localize('agentHost.planReview.autopilot.description', "Auto-approve all tool calls and continue until done."), + }; + case 'autopilot_fleet': + return { + label: localize('agentHost.planReview.autopilotFleet.label', "Implement with Autopilot Fleet"), + description: localize('agentHost.planReview.autopilotFleet.description', "Auto-approve all tool calls, including fleet management actions, and continue until done."), + }; + case 'interactive': + return { + label: localize('agentHost.planReview.interactive.label', "Implement Plan"), + description: localize('agentHost.planReview.interactive.description', "Implement the plan, asking for input and approval for each action."), + }; + case 'exit_only': + return { + label: localize('agentHost.planReview.exitOnly.label', "Approve Plan Only"), + description: localize('agentHost.planReview.exitOnly.description', "Approve the plan without executing it. I will implement it myself."), + }; + default: + return undefined; + } +} + type UserInputHandler = NonNullable; type UserInputRequest = Parameters[0]; type UserInputResponse = Awaited>; @@ -109,6 +155,20 @@ export class CopilotAgentSession extends Disposable { private readonly _pendingPermissions = new Map>(); /** Pending user input requests awaiting a renderer-side answer. */ private readonly _pendingUserInputs = new Map }>; questionId: string }>(); + /** + * Pending plan-review requests originating from the CLI's + * `exitPlanMode.request` RPC. Tracked separately from + * {@link _pendingUserInputs} so the completion handler can resolve the + * RPC with a structured {@link IExitPlanModeResponse} (which the CLI + * forwards to `session.respondToExitPlanMode`) rather than feeding it + * back through the SDK's `ask_user` callback. + */ + private readonly _pendingPlanReviews = new Map; + }>(); /** File edit tracker for this session. */ private readonly _editTracker: FileEditTracker; /** Session database reference. */ @@ -117,6 +177,8 @@ export class CopilotAgentSession extends Disposable { private _turnId = ''; /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ + private _lastAppliedMode: CopilotSdkMode | undefined; /** Snapshot captured at session creation for refresh detection. */ private readonly _appliedSnapshot: IActiveClientSnapshot; @@ -152,6 +214,7 @@ export class CopilotAgentSession extends Disposable { @ISessionDataService sessionDataService: ISessionDataService, @IFileService private readonly _fileService: IFileService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, ) { super(); this.sessionId = options.rawSessionId; @@ -173,6 +236,7 @@ export class CopilotAgentSession extends Disposable { this._register(toDisposable(() => this._denyPendingPermissions())); this._register(toDisposable(() => this._shellManager?.dispose())); this._register(toDisposable(() => this._cancelPendingUserInputs())); + this._register(toDisposable(() => this._cancelPendingPlanReviews())); // When a shell tool associates a terminal with a tool call, fire a // tool_content_changed event so the UI can connect to the terminal @@ -395,7 +459,7 @@ export class CopilotAgentSession extends Disposable { // ---- session operations ------------------------------------------------- - async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise { + async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise { if (turnId) { this._turnId = turnId; } @@ -412,10 +476,29 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); } + await this.applyMode(mode); await this._wrapper.session.send({ prompt, attachments: sdkAttachments }); this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } + /** + * Pushes `mode` to the SDK via `rpc.mode.set` if it differs from the + * last applied value. Failures are logged and swallowed so that mode + * propagation does not block the turn. + */ + async applyMode(mode: CopilotSdkMode | undefined): Promise { + if (!mode || mode === this._lastAppliedMode) { + return; + } + try { + await this._wrapper.session.rpc.mode.set({ mode }); + this._lastAppliedMode = mode; + this._logService.info(`[Copilot:${this.sessionId}] rpc.mode.set succeeded: mode=${mode}`); + } catch (err) { + this._logService.error(err, `[Copilot:${this.sessionId}] rpc.mode.set failed: mode=${mode}`); + } + } + async sendSteering(steeringMessage: PendingMessage): Promise { this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); try { @@ -495,13 +578,13 @@ export class CopilotAgentSession extends Disposable { if (!toolCallId) { // TODO: handle permission requests without a toolCallId by creating a synthetic tool call this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); - return { kind: 'denied-interactively-by-user' }; + return { kind: 'reject' }; } const sessionResourcePath = this._getInternalSessionResourcePath(request); if (sessionResourcePath) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving internal session resource ${sessionResourcePath}`); - return { kind: 'approved' }; + return { kind: 'approve-once' }; } this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); @@ -524,7 +607,7 @@ export class CopilotAgentSession extends Disposable { // `pending-edit-content:` entry has been cleaned up. Bail without // firing tool_ready. if (!this._pendingPermissions.has(toolCallId)) { - return { kind: 'denied-interactively-by-user' }; + return { kind: 'reject' }; } // Fire a pending_confirmation signal to transition the tool to PendingConfirmation @@ -548,7 +631,7 @@ export class CopilotAgentSession extends Disposable { const approved = await deferred.p; this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`); - return { kind: approved ? 'approved' : 'denied-interactively-by-user' }; + return { kind: approved ? 'approve-once' : 'reject' }; } catch (error) { this._logService.error(error, `[Copilot:${this.sessionId}] Failed to handle permission request: kind=${request.kind}, toolCallId=${request.toolCallId ?? 'missing'}`); throw error; @@ -661,6 +744,14 @@ export class CopilotAgentSession extends Disposable { request: UserInputRequest, _invocation: { sessionId: string }, ): Promise { + const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot'; + if (isAutopilot) { + return { + answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', + wasFreeform: true, + }; + } + const questionPreview = request.question.substring(0, 100); try { const requestId = generateUuid(); @@ -726,6 +817,13 @@ export class CopilotAgentSession extends Disposable { } respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { + const pendingPlanReview = this._pendingPlanReviews.get(requestId); + if (pendingPlanReview) { + this._pendingPlanReviews.delete(requestId); + pendingPlanReview.deferred.complete(this._resolveExitPlanMode(pendingPlanReview, response, answers)); + return true; + } + const pending = this._pendingUserInputs.get(requestId); if (pending) { this._pendingUserInputs.delete(requestId); @@ -735,6 +833,87 @@ export class CopilotAgentSession extends Disposable { return false; } + /** + * Maps an `exit_plan_mode` input response back to an + * {@link IExitPlanModeResponse} that the CLI can feed into + * `session.respondToExitPlanMode`. Mapping rules: + * + * - Decline / Cancel / no answer → `{ approved: false }` (model gets a + * rejection result and stays in plan mode). + * - Accept + freeform feedback → `{ approved: false, feedback, selectedAction? }` + * (the SDK treats this as a revision request and re-emits + * `exit_plan_mode.requested` after revising the plan). + * - Accept + selected option → `{ approved: true, selectedAction, autoApproveEdits }` + * where `autoApproveEdits` is set for the autopilot variants. + * + * `selectedAction` is validated against the SDK's offered `actions`; an + * unknown value is treated as a decline so the SDK isn't fed a value it + * cannot handle. + */ + private _resolveExitPlanMode( + pending: { actions: readonly string[]; recommendedAction: string; questionId: string }, + response: SessionInputResponseKind, + answers?: Record, + ): IExitPlanModeResponse { + if (response !== SessionInputResponseKind.Accept) { + return { approved: false }; + } + const answer = answers?.[pending.questionId]; + if (!answer || answer.state === SessionInputAnswerState.Skipped) { + return { approved: false }; + } + const value = answer.value; + + // Determine the selected action and any freeform feedback. The + // `single-select` question may carry both (when the user picks an + // option AND types feedback), or just freeform text (when the + // user types instead of picking). Normalize to one shape. + let candidateAction: string | undefined; + let feedback: string | undefined; + if (value.kind === SessionInputAnswerValueKind.Selected) { + candidateAction = value.value; + const freeform = value.freeformValues?.find(s => s.trim().length > 0)?.trim(); + feedback = freeform; + } else if (value.kind === SessionInputAnswerValueKind.Text) { + feedback = value.value.trim() || undefined; + } else { + return { approved: false }; + } + + // Clamp `selectedAction` to the SDK's offered set. Anything else + // (including freeform text smuggled into the `value` field) falls + // back to the recommended action so we never feed the SDK a value + // it can't act on. + const selectedAction = candidateAction && pending.actions.includes(candidateAction) + ? candidateAction + : pending.actions.includes(pending.recommendedAction) + ? pending.recommendedAction + : undefined; + + // Freeform feedback => revision request. The SDK semantics are + // `approved: false` with a non-empty `feedback`; it will revise + // the plan and re-emit `exit_plan_mode.requested`. + if (feedback) { + return { + approved: false, + feedback, + ...(selectedAction ? { selectedAction } : {}), + }; + } + + // No selectable action and no feedback — nothing actionable. + if (!selectedAction) { + return { approved: false }; + } + + const isAutopilot = selectedAction === 'autopilot' || selectedAction === 'autopilot_fleet'; + return { + approved: true, + selectedAction, + ...(isAutopilot ? { autoApproveEdits: true } : {}), + }; + } + private async _handlePreToolUse(input: PreToolUseHookInput): Promise { try { if (isEditTool(input.toolName)) { @@ -1033,6 +1212,145 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); this._emitReasoningDelta(e.data.deltaContent); })); + + // Sync the AHP session config when the SDK's `currentMode` changes + // (e.g. after the model approves a plan, or after we set the mode + // before sending). The SDK has three modes (`interactive` / `plan` / + // `autopilot`); AHP only models `interactive` / `plan` and treats + // autopilot as `mode='interactive', autoApprove='autopilot'`, so we + // translate before writing. + this._register(wrapper.onSessionModeChanged(e => { + this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`); + const newMode = e.data.newMode; + if (newMode !== 'interactive' && newMode !== 'plan' && newMode !== 'autopilot') { + return; + } + this._lastAppliedMode = newMode; + this._syncAhpConfigFromSdkMode(newMode); + })); + } + + /** + * Translates the SDK's three-mode space (`interactive` / `plan` / + * `autopilot`) to AHP's two-axis model: + * + * - SDK `plan` → AHP `mode='plan'`. + * - SDK `interactive` → AHP `mode='interactive'`. + * - SDK `autopilot` → AHP `mode='interactive', autoApprove='autopilot'`. + * Autopilot is exposed in AHP as the highest auto-approval level on + * the orthogonal `autoApprove` axis, not as a mode value. + * + * Patches that already match the current AHP values are still + * dispatched (the reducer is a no-op in that case) but written values + * propagate to all subscribed clients via `session/configChanged`. + */ + private _syncAhpConfigFromSdkMode(sdkMode: CopilotSdkMode): void { + const sessionUri = this.sessionUri.toString(); + const patch: Record = {}; + switch (sdkMode) { + case 'plan': + patch[SessionConfigKey.Mode] = 'plan'; + break; + case 'autopilot': + patch[SessionConfigKey.Mode] = 'interactive'; + patch[SessionConfigKey.AutoApprove] = 'autopilot'; + break; + case 'interactive': + patch[SessionConfigKey.Mode] = 'interactive'; + break; + } + this._configurationService.updateSessionConfig(sessionUri, patch); + } + + /** + * Handles the CLI's `exitPlanMode.request` RPC by surfacing it as a + * {@link SessionInputRequest} and awaiting the client's response. The + * resolved {@link IExitPlanModeResponse} flows back to the CLI, which + * calls `session.respondToExitPlanMode` internally — that resumes the + * paused `exit_plan_mode` tool call and (on accept) updates the SDK's + * `currentMode` so the model can continue with implementation. + */ + async handleExitPlanModeRequest(data: IExitPlanModeRequestParams): Promise { + const requestId = generateUuid(); + const questionId = generateUuid(); + this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request: rpcId=${requestId}, actions=[${data.actions.join(',')}], recommended=${data.recommendedAction}`); + + // When the session's effective auto-approval level is `autopilot`, + // approve the plan automatically without surfacing a question to + // the user. Mirrors the "autopilot fast-path" in the Copilot CLI's + // own plan-mode handler. + const autoApprove = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove); + if (autoApprove === 'autopilot') { + const response = autoApproveExitPlanMode(data); + this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request auto-accepted (autoApprove=autopilot): selectedAction=${response.selectedAction ?? '(none)'}`); + return response; + } + + // Resolve the plan file path so we can embed a markdown link. + let planPath: string | null = null; + try { + const planRead = await this._wrapper.session.rpc.plan.read(); + planPath = planRead.path ?? null; + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`); + } + + // Build the input-request markdown: summary + link to the plan file. + let message = data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."); + if (planPath) { + const planUri = URI.file(planPath); + message += `\n\n[${localize('agentHost.planReview.viewPlanLink', "View full plan")}](${planUri.toString()})`; + } + + this._emitMarkdownDelta(message); + + const options = data.actions.map(actionId => { + const desc = getPlanActionDescription(actionId); + return { + id: actionId, + label: desc?.label ?? actionId, + description: desc?.description, + recommended: actionId === data.recommendedAction, + }; + }); + + const inputRequest: SessionInputRequest = { + id: requestId, + questions: [{ + kind: SessionInputQuestionKind.SingleSelect, + id: questionId, + title: localize('agentHost.planReview.title', "Review Plan"), + message: localize('agentHost.planReview.questionMessage', "How would you like to proceed?"), + required: true, + options, + allowFreeformInput: true, + }], + }; + + const deferred = new DeferredPromise(); + this._pendingPlanReviews.set(requestId, { + actions: data.actions, + recommendedAction: data.recommendedAction, + questionId, + deferred, + }); + + this._onDidSessionProgress.fire({ + kind: 'action', + session: this.sessionUri, + action: { + type: ActionType.SessionInputRequested, + session: this.sessionUri.toString(), + request: inputRequest, + } + }); + + try { + return await deferred.p; + } catch (err) { + this._logService.error(err, `[Copilot:${this.sessionId}] exitPlanMode.request handler failed: rpcId=${requestId}`); + return { approved: false }; + } } private _subscribeForLogging(): void { @@ -1236,6 +1554,13 @@ export class CopilotAgentSession extends Disposable { this._pendingUserInputs.clear(); } + private _cancelPendingPlanReviews(): void { + for (const [, pending] of this._pendingPlanReviews) { + pending.deferred.complete({ approved: false }); + } + this._pendingPlanReviews.clear(); + } + private _cancelPendingClientToolCalls(): void { for (const [, deferred] of this._pendingClientToolCalls) { deferred.complete({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' }); @@ -1244,6 +1569,47 @@ export class CopilotAgentSession extends Disposable { } } +/** + * Builds the {@link IExitPlanModeResponse} used when the session is in + * autopilot and we approve the plan without user interaction. + * + * Selection priority mirrors the Copilot CLI's own autopilot handler. + * + * 1. If the SDK's `recommendedAction` is offered, take it. + * 2. Otherwise fall back to `autopilot` → `autopilot_fleet` → `interactive` + * → `exit_only`. + * 3. As a last resort, approve without picking a `selectedAction` (the SDK + * keeps `currentMode='interactive'` in that case). + * + * `autoApproveEdits: true` is set whenever the chosen action is one of the + * autopilot variants, mirroring the CLI behavior. + */ +function autoApproveExitPlanMode(data: IExitPlanModeRequestParams): IExitPlanModeResponse { + const choices = data.actions ?? []; + const isAutopilotAction = (action: string) => action === 'autopilot' || action === 'autopilot_fleet'; + + if (data.recommendedAction && choices.includes(data.recommendedAction)) { + const selectedAction = data.recommendedAction; + return { + approved: true, + selectedAction, + ...(isAutopilotAction(selectedAction) ? { autoApproveEdits: true } : {}), + }; + } + + for (const action of ['autopilot', 'autopilot_fleet', 'interactive', 'exit_only']) { + if (choices.includes(action)) { + return { + approved: true, + selectedAction: action, + ...(isAutopilotAction(action) ? { autoApproveEdits: true } : {}), + }; + } + } + + return { approved: true, autoApproveEdits: true }; +} + /** * Counts added/removed lines in a unified diff string. Ignores the `+++` and * `---` header rows and any non-hunk context. diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 36ad526d416..9a0fd2a7309 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -208,6 +208,11 @@ export class CopilotSessionWrapper extends Disposable { return this._onSystemMessage ??= this._sdkEvent('system.message'); } + private _onSessionModeChanged: Event> | undefined; + get onSessionModeChanged(): Event> { + return this._onSessionModeChanged ??= this._sdkEvent('session.mode_changed'); + } + private _sdkEvent(eventType: K): Event> { const emitter = this._register(new Emitter>()); const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index f60915b393b..4a5d8ac1c73 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -57,6 +57,7 @@ const enum CopilotToolName { AskUser = 'ask_user', ReportIntent = 'report_intent', Skill = 'skill', + ExitPlanMode = 'exit_plan_mode', } /** Parameters for the `bash` / `powershell` shell tools. */ @@ -249,6 +250,7 @@ export function getToolDisplayName(toolName: string): string { case CopilotToolName.WebSearch: return localize('toolName.webSearch', "Web Search"); case CopilotToolName.WebFetch: return localize('toolName.webFetch', "Web Fetch"); case CopilotToolName.AskUser: return localize('toolName.askUser', "Ask User"); + case CopilotToolName.ExitPlanMode: return localize('toolName.exitPlanMode', "Plan"); default: return toolName; } } @@ -323,6 +325,8 @@ export function getInvocationMessage(toolName: string, displayName: string, para } return localize('toolInvoke.glob', "Finding files"); } + case CopilotToolName.ExitPlanMode: + return localize('toolInvoke.exitPlanMode', "Presenting plan"); default: return localize('toolInvoke.generic', "Using \"{0}\"", displayName); } @@ -402,6 +406,8 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } return localize('toolComplete.glob', "Found files"); } + case CopilotToolName.ExitPlanMode: + return localize('toolComplete.exitPlanMode', "Exited plan mode"); default: return localize('toolComplete.generic', "Used \"{0}\"", displayName); } diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index eaee2c92cc1..84c6526c780 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue } from '../../common/agentHostSchema.js'; +import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -297,5 +297,17 @@ suite('agentHostSchema', () => { assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [42], deny: [] }), false); assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [] }), true); }); + + test('validates the agent modes', () => { + const modes: SessionMode[] = ['interactive', 'plan']; + for (const mode of modes) { + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, mode), true, mode); + } + // `autopilot` is intentionally NOT in the AHP mode enum \u2014 it's + // modeled on the orthogonal `autoApprove` axis instead. + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'autopilot'), false); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'shell'), false); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 42), false); + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5a827d361be..ffce98990be 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -161,8 +161,10 @@ class TestableCopilotAgent extends CopilotAgent { @ISessionDataService sessionDataService: ISessionDataService, @IAgentHostGitService gitService: IAgentHostGitService, @IAgentHostTerminalManager terminalManager: IAgentHostTerminalManager, + @IAgentConfigurationService configurationService: IAgentConfigurationService, ) { - super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager); + super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager, configurationService); + this._enablePlanModeOnClient(this._copilotClient); } protected override _createCopilotClient(): ICopilotClient { @@ -349,7 +351,7 @@ suite('CopilotAgent', () => { toolCallId: 'tc-read-plan-agent-composition', }); - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); } finally { if (previousXdgStateHome === undefined) { delete process.env['XDG_STATE_HOME']; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 45642b87670..a1d738e3da5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -24,6 +24,8 @@ import { ActionType } from '../../common/state/sessionActions.js'; import { AttachmentType, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; // ---- Mock CopilotSession (SDK level) ---------------------------------------- @@ -36,8 +38,10 @@ import { createSessionDataService, createZeroDiffComputeService } from '../commo class MockCopilotSession { readonly sessionId = 'test-session-1'; readonly sendRequests: unknown[] = []; + readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; private readonly _handlers = new Map void>>(); + planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null }; on(eventType: K, handler: TypedSessionEventHandler): () => void { let set = this._handlers.get(eventType); @@ -66,6 +70,20 @@ class MockCopilotSession { async setModel() { } async getMessages() { return []; } async destroy() { } + + readonly rpc = { + mode: { + get: async () => ({ mode: 'interactive' as const }), + set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { + this.modeSetCalls.push({ mode: params.mode }); + }, + }, + plan: { + read: async () => this.planReadResult, + update: async (_params: { content: string }) => { /* no-op */ }, + delete: async () => { /* no-op */ }, + }, + }; } class CapturingLogService extends NullLogService { @@ -113,12 +131,15 @@ async function createAgentSession(disposables: DisposableStore, options?: { logService?: ILogService; captureWrapperCallbacks?: { current?: Parameters[0] }; workingDirectory?: URI; + /** Per-key effective config values returned by the fake configuration service. */ + configValues?: Record; }): Promise<{ session: CopilotAgentSession; mockSession: MockCopilotSession; progressEvents: LegacyMockEvent[]; + waitForProgress: (predicate: (event: LegacyMockEvent) => event is T) => Promise; signals: AgentSignal[]; - waitForProgress: (predicate: (event: LegacyMockEvent) => boolean) => Promise; + sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; }> { const progressEmitter = disposables.add(new Emitter()); const progressEvents: LegacyMockEvent[] = []; @@ -160,14 +181,14 @@ async function createAgentSession(disposables: DisposableStore, options?: { notify(view); })); - const waitForProgress = (predicate: (event: LegacyMockEvent) => boolean): Promise => { + const waitForProgress = (predicate: (event: LegacyMockEvent) => event is T): Promise => { const existing = progressEvents.find(predicate); if (existing) { return Promise.resolve(existing); } const deferred = new DeferredPromise(); waiters.push({ predicate, deferred }); - return deferred.p; + return deferred.p as Promise; }; const sessionUri = AgentSession.uri('copilot', 'test-session-1'); @@ -185,6 +206,23 @@ async function createAgentSession(disposables: DisposableStore, options?: { services.set(IFileService, { _serviceBrand: undefined } as IFileService); services.set(ISessionDataService, createSessionDataService()); services.set(IDiffComputeService, createZeroDiffComputeService()); + const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; + const configValues = options?.configValues ?? {}; + const fakeConfigurationService: IAgentConfigurationService = { + _serviceBrand: undefined, + onDidRootConfigChange: new Emitter().event, + // Simple per-key map suffices for tests; the real service walks + // session → parent → host and validates against the schema, but + // neither matters here — we just need to surface a value the + // session class will read. + getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'], + getEffectiveWorkingDirectory: () => undefined, + updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, + getRootValue: () => undefined, + updateRootConfig: () => { /* no-op */ }, + persistRootConfig: () => { /* no-op */ }, + }; + services.set(IAgentConfigurationService, fakeConfigurationService); const environmentService = { _serviceBrand: undefined, userHome: URI.file('/mock-home'), @@ -209,7 +247,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { await session.initializeSession(); - return { session, mockSession, progressEvents, signals, waitForProgress }; + return { session, mockSession, progressEvents, signals, waitForProgress, sessionConfigUpdates }; } // ---- Tests ------------------------------------------------------------------ @@ -257,7 +295,7 @@ suite('CopilotAgentSession', () => { assert.ok(session.respondToPermissionRequest('tc-1', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); }); test('auto-approves read permission for session-state plan files', async () => { @@ -271,7 +309,7 @@ suite('CopilotAgentSession', () => { toolCallId: 'tc-read-plan', }); - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(progressEvents.length, 0); } finally { if (previousXdgStateHome === undefined) { @@ -293,7 +331,7 @@ suite('CopilotAgentSession', () => { toolCallId: 'tc-read-plan-native-env', }); - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(progressEvents.length, 0); } finally { if (previousXdgStateHome === undefined) { @@ -348,7 +386,7 @@ suite('CopilotAgentSession', () => { assert.ok(session.respondToPermissionRequest('tc-1', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); }); test('auto-approves write permission for session-state plan files', async () => { @@ -362,7 +400,7 @@ suite('CopilotAgentSession', () => { toolCallId: 'tc-write-plan', }); - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(progressEvents.length, 0); } finally { if (previousXdgStateHome === undefined) { @@ -389,7 +427,7 @@ suite('CopilotAgentSession', () => { assert.ok(session.respondToPermissionRequest('tc-write-other-plan', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); } finally { if (previousXdgStateHome === undefined) { delete process.env['XDG_STATE_HOME']; @@ -416,7 +454,7 @@ suite('CopilotAgentSession', () => { assert.ok(session.respondToPermissionRequest('tc-write-traversal', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); } finally { if (previousXdgStateHome === undefined) { delete process.env['XDG_STATE_HOME']; @@ -440,7 +478,7 @@ suite('CopilotAgentSession', () => { assert.ok(session.respondToPermissionRequest('tc-write-outside', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); }); test('read permission outside working directory fires tool_ready', async () => { @@ -460,13 +498,13 @@ suite('CopilotAgentSession', () => { // Respond to it assert.ok(session.respondToPermissionRequest('tc-2', true)); const result = await resultPromise; - assert.strictEqual(result.kind, 'approved'); + assert.strictEqual(result.kind, 'approve-once'); }); test('denies permission when no toolCallId', async () => { const { session } = await createAgentSession(disposables); const result = await session.handlePermissionRequest({ kind: 'write' }); - assert.strictEqual(result.kind, 'denied-interactively-by-user'); + assert.strictEqual(result.kind, 'reject'); }); test('denied-interactively when user denies', async () => { @@ -480,7 +518,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(progressEvents.length, 1); session.respondToPermissionRequest('tc-3', false); const result = await resultPromise; - assert.strictEqual(result.kind, 'denied-interactively-by-user'); + assert.strictEqual(result.kind, 'reject'); }); test('pending permissions are denied on dispose', async () => { @@ -492,7 +530,7 @@ suite('CopilotAgentSession', () => { session.dispose(); const result = await resultPromise; - assert.strictEqual(result.kind, 'denied-interactively-by-user'); + assert.strictEqual(result.kind, 'reject'); }); test('pending permissions are denied on abort', async () => { @@ -504,7 +542,7 @@ suite('CopilotAgentSession', () => { await session.abort(); const result = await resultPromise; - assert.strictEqual(result.kind, 'denied-interactively-by-user'); + assert.strictEqual(result.kind, 'reject'); }); test('respondToPermissionRequest returns false for unknown id', async () => { @@ -923,6 +961,44 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.answer, ''); assert.strictEqual(result.wasFreeform, true); }); + + test('autopilot auto-answers a free-form question without firing a progress event', async () => { + const { session, progressEvents } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }); + + const result = await session.handleUserInputRequest( + { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, + { sessionId: 'test-session-1' } + ); + + // `wasFreeform: false` because we picked one of the SDK's + // offered choices — the SDK uses this hint to record whether + // the user typed something custom. + assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); + assert.strictEqual(result.wasFreeform, true); + assert.strictEqual(progressEvents.length, 0); + }); + + test('autopilot does not auto-answer when autoApprove is not "autopilot"', async () => { + // Sanity check: with autoApprove=default the question must + // still be surfaced as a progress event (the existing behavior). + const { session, progressEvents } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'default' }, + }); + + session.handleUserInputRequest( + { question: 'Need user input' }, + { sessionId: 'test-session-1' } + ); + + // Microtask flush so the handler can run far enough to either + // short-circuit or emit a progress event. + await Promise.resolve(); + assert.strictEqual(progressEvents.length, 1); + const event = progressEvents[0]; + assertUserInputEvent(event); + }); }); suite('SDK callback logging', () => { @@ -1094,7 +1170,7 @@ suite('CopilotAgentSession', () => { // Approve and clean up session.respondToPermissionRequest('tc-client-perm', true); const permResult = await resultPromise; - assert.strictEqual(permResult.kind, 'approved'); + assert.strictEqual(permResult.kind, 'approve-once'); session.handleClientToolCallComplete('tc-client-perm', { success: true, pastTenseMessage: 'did it', @@ -1265,4 +1341,341 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.textResultForLlm, 'text part'); }); }); + + // ---- Plan mode ---------------------------------------------------------- + + suite('plan mode', () => { + + const planRequestParams = (overrides?: Partial<{ actions: string[]; recommendedAction: string; summary: string }>) => ({ + sessionId: 'test-session-1', + summary: overrides?.summary ?? '## Plan summary', + planContent: '## Plan', + actions: overrides?.actions ?? ['autopilot', 'interactive', 'exit_only'], + recommendedAction: overrides?.recommendedAction ?? 'autopilot', + }); + + test('applyMode pushes the mode to the SDK only when it changes', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.applyMode('plan'); + await session.applyMode('plan'); + await session.applyMode('autopilot'); + await session.applyMode(undefined); + await session.applyMode('autopilot'); + + assert.deepStrictEqual(mockSession.modeSetCalls, [ + { mode: 'plan' }, + { mode: 'autopilot' }, + ]); + }); + + test('send applies mode before forwarding to the SDK', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('hi', undefined, 'turn-1', 'plan'); + + assert.deepStrictEqual(mockSession.modeSetCalls, [{ mode: 'plan' }]); + assert.strictEqual(mockSession.sendRequests.length, 1); + }); + + test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { + const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables); + + mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); + + const event = await waitForProgress(e => e.type === 'user_input_request'); + const request = event.request; + + // The plan summary and "View full plan" link are emitted as a + // markdown response part before the input request, so the + // client renders them inline above the question. + const deltaContent = progressEvents.flatMap(e => e.type === 'delta' ? [e.content] : []).join(''); + assert.ok(deltaContent.includes('Plan summary'), `expected delta to include plan summary; got: ${deltaContent}`); + assert.ok(deltaContent.includes('plan.md'), 'delta should include a link to the plan file'); + + const question = request.questions?.[0]; + assert.strictEqual(question?.kind, SessionInputQuestionKind.SingleSelect); + if (question?.kind === SessionInputQuestionKind.SingleSelect) { + assert.deepStrictEqual(question.options.map(o => o.id), ['autopilot', 'interactive', 'exit_only']); + const recommended = question.options.find(o => o.recommended); + assert.strictEqual(recommended?.id, 'autopilot'); + assert.strictEqual(question.allowFreeformInput, true); + } + + // Resolve the request so the deferred completes and the test can clean up. + session.respondToUserInputRequest(request.id, SessionInputResponseKind.Decline); + await responsePromise; + }); + + test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); + }); + + test('completing the input request with interactive resolves with approved + interactive (no autoApprove)', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); + }); + + test('declining the input request resolves with approved=false', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); + const event = await waitForProgress(e => e.type === 'user_input_request'); + + session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Decline); + + assert.deepStrictEqual(await responsePromise, { approved: false }); + }); + + test('exit_only resolves as approved + interactive without autoApproveEdits', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'exit_only' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'exit_only' }); + }); + + test('freeform feedback alongside a selected action becomes a revision request', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { + kind: SessionInputAnswerValueKind.Selected, + value: 'interactive', + freeformValues: ['Please use Python instead of Node.js'], + }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { + approved: false, + feedback: 'Please use Python instead of Node.js', + selectedAction: 'interactive', + }); + }); + + test('selectedAction not in offered actions falls back to recommendedAction', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + // SDK only offered `interactive` and `exit_only`; the client + // somehow sent `autopilot` (e.g. stale UI state). The agent + // host clamps to `recommendedAction` so the SDK never sees a + // value it didn't offer. + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); + }); + + test('selectedAction not in offered actions and no fallback resolves to approved=false', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + // SDK offered `exit_only` only and recommended a value not in + // the offered set. The client picked something invalid. With + // no usable selectedAction and no feedback, decline. + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: false }); + }); + + test('text answer with feedback becomes a revision request without selectedAction', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + // The single-select question normally produces a Selected + // value, but a defensive Text response should still be + // translated to a revision request when the answer is + // non-empty (selectedAction falls back to recommendedAction). + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { + approved: false, + feedback: 'Add tests for edge cases', + selectedAction: 'interactive', + }); + }); + + test('whitespace-only freeform feedback is ignored', async () => { + const { session, waitForProgress } = await createAgentSession(disposables); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); + const event = await waitForProgress(e => e.type === 'user_input_request'); + const requestId = event.request.id; + const questionId = event.request.questions![0].id; + + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { + kind: SessionInputAnswerValueKind.Selected, + value: 'interactive', + freeformValues: [' ', ''], + }, + }, + }); + + assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); + }); + + test('session.mode_changed → plan updates the AHP session config', async () => { + const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); + + mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data']); + + assert.deepStrictEqual(sessionConfigUpdates, [ + { session: 'copilot:/test-session-1', patch: { mode: 'plan' } }, + ]); + }); + + test('session.mode_changed → interactive updates the AHP session config', async () => { + const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); + + mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'interactive' } as SessionEventPayload<'session.mode_changed'>['data']); + + assert.deepStrictEqual(sessionConfigUpdates, [ + { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, + ]); + }); + + test('session.mode_changed → autopilot translates to mode=interactive + autoApprove=autopilot', async () => { + // The SDK has a first-class `autopilot` mode but AHP exposes it + // as the `autopilot` value on the orthogonal `autoApprove` axis. + // The translation is contained in the Copilot agent. + const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); + + mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'autopilot' } as SessionEventPayload<'session.mode_changed'>['data']); + + assert.deepStrictEqual(sessionConfigUpdates, [ + { session: 'copilot:/test-session-1', patch: { mode: 'interactive', autoApprove: 'autopilot' } }, + ]); + }); + + test('session.mode_changed for unsupported mode is ignored', async () => { + const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); + + mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'shell' } as SessionEventPayload<'session.mode_changed'>['data']); + + assert.strictEqual(sessionConfigUpdates.length, 0); + }); + + // ---- autopilot fast-path ------------------------------------------- + + test('handleExitPlanModeRequest auto-accepts when autoApprove=autopilot (recommended action)', async () => { + const { session, progressEvents } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }); + + const response = await session.handleExitPlanModeRequest(planRequestParams({ + actions: ['autopilot', 'interactive', 'exit_only'], + recommendedAction: 'autopilot', + })); + + assert.deepStrictEqual(response, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); + // User-input request should NOT be surfaced to the client. + assert.strictEqual(progressEvents.filter(e => e.type === 'user_input_request').length, 0); + }); + + test('handleExitPlanModeRequest auto-accepts with priority order when no recommended action available', async () => { + const { session } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }); + + // SDK proposes a recommended action that's NOT in the offered set — + // fall back to the priority order (autopilot > autopilot_fleet > + // interactive > exit_only). + const response = await session.handleExitPlanModeRequest(planRequestParams({ + actions: ['interactive', 'exit_only'], + recommendedAction: 'autopilot_fleet', + })); + + assert.deepStrictEqual(response, { approved: true, selectedAction: 'interactive' }); + }); + + test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => { + const { session, waitForProgress } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'default' }, + }); + + const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); + + // The user-input request fires — the user must respond. + const event = await waitForProgress(e => e.type === 'user_input_request'); + session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Decline); + await responsePromise; + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index f3e711d1998..2015b56a87c 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -845,7 +845,7 @@ export type LegacyMockEvent = invocationMessage: StringOrMarkdown; toolInput?: string; confirmationTitle?: StringOrMarkdown; - permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool'; + permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory'; permissionPath?: string; edits?: { items: FileEdit[] }; } diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index f062eb973f5..533d3507091 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -32,7 +32,7 @@ import { SubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; import { ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type SessionInputAnswer, type SessionInputRequest, type SessionState, type TerminalState, type ToolResultContent, type ToolResultSubagentContent } from '../../../common/state/sessionState.js'; import type { RootState } from '../../../common/state/protocol/state.js'; -import type { RootAgentsChangedAction, SessionAddedNotification, SessionInputRequestedAction, SessionResponsePartAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import type { RootAgentsChangedAction, SessionAddedNotification, SessionInputRequestedAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; import { getActionEnvelope, @@ -166,10 +166,23 @@ function getAcceptedAnswers(request: SessionInputRequest): Record isActionNotification(n, 'session/responsePart')) - .map(notification => getActionEnvelope(notification).action as SessionResponsePartAction) - .flatMap(action => action.part.kind === ResponsePartKind.Markdown ? [action.part.content] : []) - .join('\n'); + // Markdown content arrives as a `session/responsePart` action that opens + // the part with the first chunk, followed by `session/delta` actions + // appending subsequent chunks. Concatenate both to get the full text. + const markdownPartIds = new Set(); + const pieces: string[] = []; + for (const notification of c.receivedNotifications(n => + isActionNotification(n, 'session/responsePart') || isActionNotification(n, 'session/delta') + )) { + const action = getActionEnvelope(notification).action; + if (action.type === 'session/responsePart' && action.part.kind === ResponsePartKind.Markdown) { + markdownPartIds.add(action.part.id); + pieces.push(action.part.content); + } else if (action.type === 'session/delta' && markdownPartIds.has(action.partId)) { + pieces.push(action.content); + } + } + return pieces.join(''); } interface IDrivenTurnResult { @@ -432,9 +445,18 @@ function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackground createdSessions.length = 0; client.close(); - // Remove temp directories created during this test + // Remove temp directories created during this test. On Windows the + // agent subprocess can still hold handles to the working directory for + // a brief moment after `disposeSession` returns, which surfaces as + // EBUSY. Retry a few times to give the OS a chance to release the + // handle before failing the teardown. for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + // Best-effort cleanup — leftover temp dirs in os.tmpdir() are + // harmless and shouldn't fail an otherwise passing test. + } } tempDirs.length = 0; }); @@ -497,20 +519,28 @@ function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackground await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); }); - test.skip('planning-mode session-state writes are auto-approved in default mode', async function () { - // TODO: re-enable once exit_plan_mode is fully supported in @github/copilot-sdk. - // The public SDK currently lacks agentMode: 'plan' on MessageOptions and - // respondToExitPlanMode() on the session, so the model never calls exit_plan_mode - // and sawInputRequest never becomes true. - + test('planning-mode session-state writes are auto-approved in default mode', async function () { this.timeout(180_000); const tempDir = mkdtempSync(`${tmpdir()}/ahp-plan-test-`); tempDirs.push(tempDir); const sessionUri = await createRealSession(client, 'real-sdk-plan-mode', createdSessions, URI.file(tempDir).toString()); + // Switch the session into plan mode via the standard config-change flow + // before sending the first turn. The agent host reads this value at + // turn-start time and pushes it to the SDK via `rpc.mode.set`. + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/configChanged', + session: sessionUri, + config: { mode: 'plan' }, + }, + }); + await client.waitForNotification(n => isActionNotification(n, 'session/configChanged')); + const planTurn = await driveTurnToCompletion(client, sessionUri, 'turn-plan', - 'Enter plan mode for the trivial task "say hello". Write the shortest possible plan to your session plan.md, then stop at exit_plan_mode. Do not inspect or modify workspace files.', 1); + 'Help me implement a Python script that prints "hello world" to stdout. Write the shortest possible plan to your session plan.md and use the exit_plan_mode tool to ask me to approve it before writing any code.', 2); assert.strictEqual(planTurn.sawPendingConfirmation, false, 'should not have received pending-confirmation toolCallReady while writing session-state plan.md'); assert.ok(planTurn.sawInputRequest, 'should reach the exit_plan_mode question so the test can continue the same session'); @@ -519,11 +549,26 @@ function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackground ); assert.strictEqual(extraSessionNotificationsAfterPlan.length, 0, 'should not create a second session while answering the plan-mode question'); + // Mirror what a real UI client would do after the user accepted the + // plan: update the session config so subsequent turns no longer run + // in plan mode. Without this the agent host would re-set the SDK's + // mode to 'plan' at the next send because the session config still + // holds the original 'plan' value. + client.notify('dispatchAction', { + clientSeq: 50, + action: { + type: 'session/configChanged', + session: sessionUri, + config: { mode: 'interactive' }, + }, + }); + await client.waitForNotification(n => isActionNotification(n, 'session/configChanged')); + const followupTurn = await driveTurnToCompletion(client, sessionUri, 'turn-followup', - 'What was the trivial task from the plan? Reply with exactly "say hello".', 10, + 'What did the plan I just approved say to print? Reply with exactly "hello world".', 100, ); assert.strictEqual(followupTurn.sawPendingConfirmation, false, 'follow-up turn should not surface new pending confirmations'); - assert.match(followupTurn.responseText, /say hello/i, 'follow-up turn should retain the original plan context'); + assert.match(followupTurn.responseText, /hello world/i, 'follow-up turn should retain the original plan context'); const extraSessionNotificationsAfterFollowup = client.receivedNotifications(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts new file mode 100644 index 00000000000..4b2933c2a80 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; + +interface IModePickerItem { + readonly value: string; + readonly label: string; + readonly description?: string; +} + +function getModeIcon(value: string | undefined): ThemeIcon | undefined { + switch (value) { + case 'plan': return Codicon.checklist; + case 'autopilot': return Codicon.rocket; + case 'interactive': return Codicon.comment; + default: return undefined; + } +} + +/** + * Self-contained picker widget for the agent-host `mode` session-config + * property (`interactive` / `plan` / `autopilot`). + * + * Mirrors the existing default-Copilot mode picker UX but is backed by the + * active agent-host session's resolved config via + * {@link IAgentHostSessionsProvider}. Renders nothing when the active + * session does not advertise a {@link isWellKnownModeSchema well-known} + * mode schema, so the generic per-property + * {@link AgentHostSessionConfigPicker} can take over for non-conforming + * agents. + */ +export class AgentHostModePicker extends Disposable { + + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _providerListeners = this._register(new DisposableMap()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + constructor( + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + this._register(autorun(reader => { + this._sessionsManagementService.activeSession.read(reader); + this._updateTrigger(); + })); + + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.removed) { + this._providerListeners.deleteAndDispose(provider.id); + } + this._watchProviders(e.added); + this._updateTrigger(); + })); + this._watchProviders(this._sessionsProvidersService.getProviders()); + } + + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + this._slotElement = slot; + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._renderDisposables.add(Gesture.addTarget(trigger)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, e => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + } + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, e => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + this._updateTrigger(); + } + + private _watchProviders(providers: readonly ISessionsProvider[]): void { + for (const provider of providers) { + if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) { + continue; + } + this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._updateTrigger())); + } + } + + private _getActiveContext(): { provider: IAgentHostSessionsProvider; sessionId: string; currentValue: string; items: readonly IModePickerItem[] } | undefined { + const session = this._sessionsManagementService.activeSession.get(); + if (!session) { + return undefined; + } + const rawProvider = this._sessionsProvidersService.getProvider(session.providerId); + if (!rawProvider || !isAgentHostProvider(rawProvider)) { + return undefined; + } + const config = rawProvider.getSessionConfig(session.sessionId); + const schema = config?.schema.properties[SessionConfigKey.Mode]; + if (!schema || !isWellKnownModeSchema(schema)) { + return undefined; + } + const enumValues = schema.enum ?? []; + const enumLabels = schema.enumLabels ?? []; + const enumDescriptions = schema.enumDescriptions ?? []; + const items: IModePickerItem[] = enumValues.map((value, index) => ({ + value, + label: enumLabels[index] ?? value, + description: enumDescriptions[index], + })); + const rawCurrent = config?.values[SessionConfigKey.Mode] ?? schema.default; + const currentValue = typeof rawCurrent === 'string' && enumValues.includes(rawCurrent) ? rawCurrent : enumValues[0] ?? ''; + return { provider: rawProvider, sessionId: session.sessionId, currentValue, items }; + } + + private _updateTrigger(): void { + if (!this._triggerElement || !this._slotElement) { + return; + } + + const ctx = this._getActiveContext(); + if (!ctx) { + this._slotElement.style.display = 'none'; + return; + } + this._slotElement.style.display = ''; + + dom.clearNode(this._triggerElement); + + const item = ctx.items.find(i => i.value === ctx.currentValue); + const label = item?.label ?? ctx.currentValue; + + const icon = getModeIcon(ctx.currentValue); + if (icon) { + dom.append(this._triggerElement, renderIcon(icon)); + } + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._triggerElement.ariaLabel = localize('agentHostModePicker.triggerAriaLabel', "Pick Agent Mode, {0}", label); + } + + private _showPicker(): void { + if (!this._triggerElement || this._actionWidgetService.isVisible) { + return; + } + const ctx = this._getActiveContext(); + if (!ctx) { + return; + } + + const triggerElement = this._triggerElement; + const actionItems: IActionListItem[] = ctx.items.map(item => ({ + kind: ActionListItemKind.Action, + label: item.label, + description: item.description, + group: { title: '', icon: item.value === ctx.currentValue ? Codicon.check : Codicon.blank }, + item, + })); + + const delegate: IActionListDelegate = { + onSelect: item => { + this._actionWidgetService.hide(); + ctx.provider.setSessionConfigValue(ctx.sessionId, SessionConfigKey.Mode, item.value) + .catch(() => { /* best-effort */ }); + }, + onHide: () => triggerElement.focus(), + }; + + this._actionWidgetService.show( + 'agentHostModePicker', + false, + actionItems, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: i => i.label ?? '', + getWidgetAriaLabel: () => localize('agentHostModePicker.ariaLabel', "Agent Mode Picker"), + }, + ); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts index 813acaef0fe..f982c8f9d96 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -15,6 +15,7 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; const REQUIRED_AUTO_APPROVE_VALUE = 'default'; +const REQUIRED_MODE_VALUE = 'interactive'; /** * Returns `true` when an `autoApprove` session-config property uses the @@ -137,3 +138,23 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe } } } + +/** + * Returns `true` when a `mode` session-config property uses the shape the + * dedicated agent-host mode picker expects: a string enum that contains + * at least `interactive`. + * + * Callers use this to decide whether to render the dedicated mode picker + * (with mode-specific icons and behavior) or fall back to the generic + * per-property picker. + */ +export function isWellKnownModeSchema(schema: SessionConfigPropertySchema): boolean { + if (schema.type !== 'string' || !Array.isArray(schema.enum) || schema.enum.length === 0) { + return false; + } + if (!schema.enum.includes(REQUIRED_MODE_VALUE)) { + return false; + } + return true; +} + diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index eac82e5d99c..2b704aded13 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -36,8 +36,9 @@ import { ISessionsManagementService } from '../../../../services/sessions/common import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js'; +import { AgentHostModePicker } from './agentHostModePicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; -import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; +import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); @@ -318,6 +319,13 @@ class AgentHostSessionConfigPicker extends Disposable { if (property === SessionConfigKey.AutoApprove && isWellKnownAutoApproveSchema(schema)) { continue; } + // When the mode property uses the well-known schema, the dedicated + // {@link AgentHostModePicker} (registered separately for + // `Menus.NewSessionConfig`) handles it. Non-conforming schemas + // still fall through to the generic per-property picker below. + if (property === SessionConfigKey.Mode && isWellKnownModeSchema(schema)) { + continue; + } const value = resolvedConfig.values[property] ?? schema.default; const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot')); const trigger = renderPickerTrigger(slot, !!schema.readOnly, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger)); @@ -464,6 +472,16 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo 'sessions.agentHost.sessionConfigPicker', () => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostSessionConfigPicker)), )); + this._register(actionViewItemService.register( + Menus.NewSessionConfig, + NEW_SESSION_MODE_PICKER_ID, + () => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostModePicker)), + )); + this._register(actionViewItemService.register( + MenuId.ChatInput, + RUNNING_SESSION_MODE_PICKER_ID, + () => new PickerActionViewItem(this._instantiationService.createInstance(AgentHostModePicker)), + )); this._register(actionViewItemService.register( Menus.NewSessionControl, NEW_SESSION_APPROVE_PICKER_ID, @@ -533,6 +551,29 @@ registerAction2(class extends Action2 { }); +// ---- New session mode picker (NewSessionConfig) ---- + +const NEW_SESSION_MODE_PICKER_ID = 'sessions.agentHost.newSessionModePicker'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: NEW_SESSION_MODE_PICKER_ID, + title: localize2('agentHostNewSessionModePicker', "Agent Mode"), + f1: false, + menu: [{ + id: Menus.NewSessionConfig, + group: 'navigation', + order: 0, + when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + }], + }); + } + + override async run(): Promise { } +}); + + // ---- Running session config picker (ChatInputSecondary) ---- const RUNNING_SESSION_CONFIG_PICKER_ID = 'sessions.agentHost.runningSessionConfigPicker'; @@ -556,4 +597,30 @@ registerAction2(class extends Action2 { }); +// ---- Running session mode picker (ChatInput, beside the model picker) ---- + +const RUNNING_SESSION_MODE_PICKER_ID = 'sessions.agentHost.runningSessionModePicker'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: RUNNING_SESSION_MODE_PICKER_ID, + title: localize2('agentHostRunningSessionModePicker', "Agent Mode"), + f1: false, + menu: [{ + id: MenuId.ChatInput, + group: 'navigation', + // `OpenModelPickerAction` (the "Auto" model picker) is at order 3 + // in the same menu — sit just before it so the mode pill renders + // to the left of "Pick Model". + order: 2, + when: ChatContextKeyExprs.isAgentHostSession, + }], + }); + } + + override async run(): Promise { } +}); + + registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index f7c73c88c40..e5de91a3f25 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -12,7 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ResolveSessionConfigResult, SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; -import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js'; +import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownModeSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js'; import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; @@ -210,3 +210,35 @@ suite('isWellKnownAutoApproveSchema', () => { assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: [] })), false); }); }); + +suite('isWellKnownModeSchema', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function schema(overrides: Partial = {}): SessionConfigPropertySchema { + return { + title: 'Agent Mode', + description: 'desc', + type: 'string', + enum: ['interactive', 'plan'], + ...overrides, + } as SessionConfigPropertySchema; + } + + test('matches the canonical two-value enum', () => { + assert.strictEqual(isWellKnownModeSchema(schema()), true); + }); + + test('matches a subset that still contains "interactive"', () => { + assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive'] })), true); + }); + + test('rejects schemas missing the required "interactive" value', () => { + assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['plan'] })), false); + }); + + test('rejects non-string types and missing/empty enums', () => { + assert.strictEqual(isWellKnownModeSchema(schema({ type: 'number' as 'string' })), false); + assert.strictEqual(isWellKnownModeSchema(schema({ enum: undefined })), false); + assert.strictEqual(isWellKnownModeSchema(schema({ enum: [] })), false); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 57d9e13275e..7ae47c9d1b7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1681,7 +1681,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // state echo (handled above) updates the carousel with the server's // authoritative answer afterwards. carousel.completion.p.then(result => { - if (carousel.isUsed) { + if (store.isDisposed) { return; } if (!result.answers) {