From 75ec86b07f2485913631a1a76e9cdc765321f8e8 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 29 Apr 2026 11:43:46 -0700 Subject: [PATCH 1/4] agentHost: implement plan and native autopilot modes --- package-lock.json | 64 ++-- package.json | 2 +- remote/package-lock.json | 64 ++-- remote/package.json | 2 +- .../agentHost/common/agentHostSchema.ts | 18 + .../agentHost/common/sessionConfigKeys.ts | 16 + .../agentHost/node/copilot/copilotAgent.ts | 136 +++++++- .../node/copilot/copilotAgentSession.ts | 322 +++++++++++++++++- .../node/copilot/copilotSessionWrapper.ts | 5 + .../node/copilot/copilotToolDisplay.ts | 6 + .../test/common/agentHostSchema.test.ts | 14 +- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../test/node/copilotAgentSession.test.ts | 283 ++++++++++++++- .../toolApprovalRealSdk.integrationTest.ts | 77 ++++- .../browser/agentHost/agentHostModePicker.ts | 215 ++++++++++++ .../agentHostPermissionPickerDelegate.ts | 23 +- .../agentHost/agentHostSessionConfigPicker.ts | 69 +++- .../agentHostPermissionPickerDelegate.test.ts | 44 ++- 18 files changed, 1253 insertions(+), 113 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts diff --git a/package-lock.json b/package-lock.json index 11292eff672..55605380903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@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", @@ -1072,26 +1072,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.38.tgz", + "integrity": "sha512-GjtKCiFczeKuECOuxkBkJYb8estSnhxgh4iQ9BTkWg4y3EWYl2VaMCXCu9KkVPf/fwy/URt1l8Rf4M4tZxVZAA==", "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.38", + "@github/copilot-darwin-x64": "1.0.38", + "@github/copilot-linux-arm64": "1.0.38", + "@github/copilot-linux-x64": "1.0.38", + "@github/copilot-win32-arm64": "1.0.38", + "@github/copilot-win32-x64": "1.0.38" } }, "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.38.tgz", + "integrity": "sha512-JyzyQ/VUC30QBOnOoqBbfAlMbIycKVqIOepeTdArNk+oER8qfQ9LqQPxA6FDqCQl3GAMclzqZGL9jK7I2WldhA==", "cpu": [ "arm64" ], @@ -1105,9 +1105,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.38.tgz", + "integrity": "sha512-2Wv/4KPY2XC6JRGvJzavrk/RBmbH3Z5pNZZslL0BW2+AeZsoYqmVrA/1pxUs+KSVaGDC420dqS7uZ6u/mg23oQ==", "cpu": [ "x64" ], @@ -1121,9 +1121,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.38.tgz", + "integrity": "sha512-s+rNuvL3pKkZ6orZZoKcsbNDlu79f6/EBj5ovo2pJ6iBI3YMNwUM8AZq9pcFUpZCaLJ6E7GGZoujRMbpjKP/wQ==", "cpu": [ "arm64" ], @@ -1137,9 +1137,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.38.tgz", + "integrity": "sha512-8aAXJ0Qv+4naW4FcsqQNzgGykaiYe5q7ZO55ZuUMQ92ZY+Kae5kTttwiZ325T9CdeNHVT9f+aMx8gAGVWxfvFg==", "cpu": [ "x64" ], @@ -1153,12 +1153,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" }, @@ -1176,9 +1176,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.38.tgz", + "integrity": "sha512-M7Da1h25IsnYyw9LBCatxgQUsu+C5+xJsHMZeR8dnxRF/kt75Ksqk1+pWp8oBk1BqK9ahTgb4zFqCfFDhmUO3w==", "cpu": [ "arm64" ], @@ -1192,9 +1192,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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.38.tgz", + "integrity": "sha512-PhAUhWRbg718Uc+a6RXqoGN8fGYD+Rj5FWQPQ3rbmgZitPRzlT/WrQaWj0BenRERUjLshPuxSm1GJUB4Kyc/7Q==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index dcfc3cd9cbf..b5ec62b82e2 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@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 5cd9518aae5..e9cdead2958 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@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.38", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.38.tgz", + "integrity": "sha512-GjtKCiFczeKuECOuxkBkJYb8estSnhxgh4iQ9BTkWg4y3EWYl2VaMCXCu9KkVPf/fwy/URt1l8Rf4M4tZxVZAA==", "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.38", + "@github/copilot-darwin-x64": "1.0.38", + "@github/copilot-linux-arm64": "1.0.38", + "@github/copilot-linux-x64": "1.0.38", + "@github/copilot-win32-arm64": "1.0.38", + "@github/copilot-win32-x64": "1.0.38" } }, "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.38.tgz", + "integrity": "sha512-JyzyQ/VUC30QBOnOoqBbfAlMbIycKVqIOepeTdArNk+oER8qfQ9LqQPxA6FDqCQl3GAMclzqZGL9jK7I2WldhA==", "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.38.tgz", + "integrity": "sha512-2Wv/4KPY2XC6JRGvJzavrk/RBmbH3Z5pNZZslL0BW2+AeZsoYqmVrA/1pxUs+KSVaGDC420dqS7uZ6u/mg23oQ==", "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.38.tgz", + "integrity": "sha512-s+rNuvL3pKkZ6orZZoKcsbNDlu79f6/EBj5ovo2pJ6iBI3YMNwUM8AZq9pcFUpZCaLJ6E7GGZoujRMbpjKP/wQ==", "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.38.tgz", + "integrity": "sha512-8aAXJ0Qv+4naW4FcsqQNzgGykaiYe5q7ZO55ZuUMQ92ZY+Kae5kTttwiZ325T9CdeNHVT9f+aMx8gAGVWxfvFg==", "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.38.tgz", + "integrity": "sha512-M7Da1h25IsnYyw9LBCatxgQUsu+C5+xJsHMZeR8dnxRF/kt75Ksqk1+pWp8oBk1BqK9ahTgb4zFqCfFDhmUO3w==", "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.38", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.38.tgz", + "integrity": "sha512-PhAUhWRbg718Uc+a6RXqoGN8fGYD+Rj5FWQPQ3rbmgZitPRzlT/WrQaWj0BenRERUjLshPuxSm1GJUB4Kyc/7Q==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 123ff2899d7..8755077ed8f 100644 --- a/remote/package.json +++ b/remote/package.json @@ -5,7 +5,7 @@ "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", - "@github/copilot-sdk": "^0.2.2", + "@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/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index c3371bc42d6..1359a3f7dc9 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', } /** @@ -36,3 +38,17 @@ export const enum SessionConfigKey { * are optional (an agent may choose to advertise a subset). */ export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); + +/** + * The set of agent execution modes the platform exposes via the + * {@link SessionConfigKey.Mode} property. Agents that opt into plan mode + * SHOULD advertise this exact set of enum values. + * + * Note: deliberately does NOT include `'autopilot'`. Autopilot is modeled + * as an auto-approval level on {@link SessionConfigKey.AutoApprove}, not + * as a mode — keeping the two axes orthogonal lets a user pick + * "plan + autopilot" or "interactive + autopilot" without conflating them. + * Agents that map AHP modes onto an SDK that has its own "autopilot" mode + * should derive the SDK mode from `(mode, autoApprove)` at send time. + */ +export const KNOWN_MODE_VALUES: ReadonlySet = new Set(['interactive', 'plan']); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 88c588345d9..dba5d95dc38 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'; @@ -25,7 +25,7 @@ import { ILogService } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, SessionHistoryEvent } from '../../common/agentService.js'; -import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AutoApproveLevel, ISchemaProperty, SessionMode, 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'; @@ -35,7 +35,7 @@ import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type P import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; import { IAgentConfigurationService } from '../agentConfigurationService.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'; @@ -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. * @@ -191,6 +225,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)); @@ -274,6 +309,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 { @@ -325,7 +413,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, @@ -338,6 +426,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; @@ -636,6 +725,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 @@ -715,7 +805,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); @@ -725,6 +816,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); @@ -934,6 +1054,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 eec841b7a1d..bf48863a6ae 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -17,11 +17,26 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; +import { localize } from '../../../../nls.js'; import { IAgentAttachment, IAgentProgressEvent, SessionHistoryEvent } from '../../common/agentService.js'; +import { platformSessionSchema } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent } from '../../common/state/sessionState.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.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'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolEvents, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -32,6 +47,38 @@ import { buildPendingEditContentUri } from './pendingEditContentStore.js'; 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. + * + * `autopilot_fleet` is deliberately omitted: {@link CopilotSdkMode} only + * models `interactive` / `plan` / `autopilot`, and the SDK's fleet-start + * side effect isn't wired through the agent host yet. Sessions that + * advertise it will see the raw `autopilot_fleet` label and the action + * will be mapped to `autopilot` mode for now. + */ +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 '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>; @@ -108,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. */ @@ -116,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; @@ -141,6 +204,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; @@ -162,6 +226,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 @@ -294,7 +359,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; } @@ -311,10 +376,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 { @@ -381,13 +465,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}`); @@ -410,7 +494,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 tool_ready event to transition the tool to PendingConfirmation @@ -428,7 +512,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; @@ -606,6 +690,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); @@ -615,6 +706,45 @@ 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 + selected option → `{ approved: true, selectedAction, autoApproveEdits }` + * where `autoApproveEdits` is set for the autopilot variants. + * + * Freeform feedback (revision request) is currently not surfaced to the + * SDK because the public API has no feedback channel; the user must send + * a follow-up turn instead. + */ + 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; + const selectedAction = value.kind === SessionInputAnswerValueKind.Selected + ? value.value + : pending.recommendedAction; + + 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)) { @@ -848,6 +978,140 @@ export class CopilotAgentSession extends Disposable { content: 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()})`; + } + + 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, + message, + 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({ + session: this.sessionUri, + type: 'user_input_request', + 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 { @@ -1051,6 +1315,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' }); @@ -1059,6 +1330,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 2d061529b76..eda74ac006c 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 db6c2e917bb..1af67ada518 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -159,8 +159,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 { @@ -331,7 +333,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 948533c528e..dc3db8a7414 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -22,6 +22,8 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; import { AttachmentType, 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) ---------------------------------------- @@ -34,8 +36,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); @@ -64,6 +68,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 { @@ -111,11 +129,14 @@ 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: IAgentProgressEvent[]; waitForProgress: (predicate: (event: IAgentProgressEvent) => boolean) => Promise; + sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; }> { const progressEmitter = disposables.add(new Emitter()); const progressEvents: IAgentProgressEvent[] = []; @@ -156,6 +177,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'), @@ -180,7 +218,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { await session.initializeSession(); - return { session, mockSession, progressEvents, waitForProgress }; + return { session, mockSession, progressEvents, waitForProgress, sessionConfigUpdates }; } // ---- Tests ------------------------------------------------------------------ @@ -228,7 +266,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 () => { @@ -242,7 +280,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) { @@ -264,7 +302,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) { @@ -319,7 +357,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 () => { @@ -333,7 +371,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) { @@ -360,7 +398,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']; @@ -387,7 +425,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']; @@ -411,7 +449,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 () => { @@ -431,13 +469,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 () => { @@ -451,7 +489,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 () => { @@ -463,7 +501,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 () => { @@ -475,7 +513,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 () => { @@ -1016,7 +1054,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', @@ -1187,4 +1225,219 @@ 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, 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') as IAgentUserInputRequestEvent; + const request = event.request; + assert.ok(request.message?.includes('Plan summary')); + assert.ok(request.message?.includes('plan.md'), 'message 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') as IAgentUserInputRequestEvent; + 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') as IAgentUserInputRequestEvent; + 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') as IAgentUserInputRequestEvent; + + 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') as IAgentUserInputRequestEvent; + 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('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') as IAgentUserInputRequestEvent; + session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Decline); + await responsePromise; + }); + }); }); 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..1ac9953b20f 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -5,7 +5,7 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; -import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_AUTO_APPROVE_VALUES, KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; @@ -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 is a subset + * of `interactive | plan | autopilot` and 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 schema.enum.every(value => KNOWN_MODE_VALUES.has(value)); +} + 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..d69166b6aea 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,45 @@ 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 schemas with the legacy "autopilot" mode value', () => { + // `autopilot` is exposed as an `autoApprove` level, not a mode. + assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'autopilot'] })), false); + assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'plan', 'autopilot'] })), false); + }); + + test('rejects schemas with unknown enum values', () => { + assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'shell'] })), 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); + }); +}); From e8848aa993c78c3e5fa060046fc9301edd97c30b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 29 Apr 2026 12:59:53 -0700 Subject: [PATCH 2/4] comments and polish --- package-lock.json | 175 +++-------------- package.json | 2 +- remote/package-lock.json | 175 +++-------------- remote/package.json | 2 +- .../agentHost/common/sessionConfigKeys.ts | 14 -- .../node/copilot/copilotAgentSession.ts | 76 +++++-- .../test/node/copilotAgentSession.test.ts | 185 +++++++++++++++++- .../agentHostPermissionPickerDelegate.ts | 8 +- .../agentHostPermissionPickerDelegate.test.ts | 10 - .../agentHost/agentHostSessionHandler.ts | 2 +- 10 files changed, 314 insertions(+), 335 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4eec23a0b74..588c00d6ccc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", + "@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", @@ -1073,26 +1073,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" ], @@ -1106,9 +1106,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" ], @@ -1122,9 +1122,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" ], @@ -1138,9 +1138,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" ], @@ -1167,88 +1167,16 @@ "node": ">=20.0.0" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot": { - "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.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-sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64": { - "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" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-x64": { - "version": "1.0.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" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", - "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", - "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-arm64": { + "node_modules/@github/copilot-win32-arm64": { "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==", @@ -1264,7 +1192,7 @@ "copilot-win32-arm64": "copilot.exe" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-x64": { + "node_modules/@github/copilot-win32-x64": { "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==", @@ -1280,47 +1208,6 @@ "copilot-win32-x64": "copilot.exe" } }, - "node_modules/@github/copilot-sdk/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", - "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", - "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", diff --git a/package.json b/package.json index 9b915a09f1b..bf30a4a92c4 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", + "@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", diff --git a/remote/package-lock.json b/remote/package-lock.json index 6f6477f855e..c49ffedfe7a 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", + "@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", @@ -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" ], @@ -175,88 +175,16 @@ "node": ">=20.0.0" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot": { - "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.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-sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64": { - "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" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-x64": { - "version": "1.0.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" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", - "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", - "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-arm64": { + "node_modules/@github/copilot-win32-arm64": { "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==", @@ -272,7 +200,7 @@ "copilot-win32-arm64": "copilot.exe" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-x64": { + "node_modules/@github/copilot-win32-x64": { "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==", @@ -288,47 +216,6 @@ "copilot-win32-x64": "copilot.exe" } }, - "node_modules/@github/copilot-sdk/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", - "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", - "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", diff --git a/remote/package.json b/remote/package.json index 1e3d4b069cf..1322f9e7895 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "1.0.34", + "@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", diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index 1359a3f7dc9..33afd3210c5 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -38,17 +38,3 @@ export const enum SessionConfigKey { * are optional (an agent may choose to advertise a subset). */ export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); - -/** - * The set of agent execution modes the platform exposes via the - * {@link SessionConfigKey.Mode} property. Agents that opt into plan mode - * SHOULD advertise this exact set of enum values. - * - * Note: deliberately does NOT include `'autopilot'`. Autopilot is modeled - * as an auto-approval level on {@link SessionConfigKey.AutoApprove}, not - * as a mode — keeping the two axes orthogonal lets a user pick - * "plan + autopilot" or "interactive + autopilot" without conflating them. - * Agents that map AHP modes onto an SDK that has its own "autopilot" mode - * should derive the SDK mode from `(mode, autoApprove)` at send time. - */ -export const KNOWN_MODE_VALUES: ReadonlySet = new Set(['interactive', 'plan']); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index ad3598c92e2..77bf4032f5b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -51,12 +51,6 @@ 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. - * - * `autopilot_fleet` is deliberately omitted: {@link CopilotSdkMode} only - * models `interactive` / `plan` / `autopilot`, and the SDK's fleet-start - * side effect isn't wired through the agent host yet. Sessions that - * advertise it will see the raw `autopilot_fleet` label and the action - * will be mapped to `autopilot` mode for now. */ function getPlanActionDescription(actionId: string): { label: string; description: string } | undefined { switch (actionId) { @@ -65,6 +59,11 @@ function getPlanActionDescription(actionId: string): { label: string; descriptio 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"), @@ -745,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: request.choices?.[0] || 'Proceed with the recommended action.', + wasFreeform: !request.choices?.length, + }; + } + const questionPreview = request.question.substring(0, 100); try { const requestId = generateUuid(); @@ -833,12 +840,15 @@ export class CopilotAgentSession extends Disposable { * * - 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. * - * Freeform feedback (revision request) is currently not surfaced to the - * SDK because the public API has no feedback channel; the user must send - * a follow-up turn instead. + * `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 }, @@ -853,9 +863,48 @@ export class CopilotAgentSession extends Disposable { return { approved: false }; } const value = answer.value; - const selectedAction = value.kind === SessionInputAnswerValueKind.Selected - ? value.value - : pending.recommendedAction; + + // 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 { @@ -1253,6 +1302,8 @@ export class CopilotAgentSession extends Disposable { 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 { @@ -1265,7 +1316,6 @@ export class CopilotAgentSession extends Disposable { const inputRequest: SessionInputRequest = { id: requestId, - message, questions: [{ kind: SessionInputQuestionKind.SingleSelect, id: questionId, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 0669b683ad0..14b1f0e7bf6 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -961,6 +961,63 @@ 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 () => { + // In autopilot the session should never block on the user — fall + // through to a generic "proceed" answer so the SDK can keep + // running. The progress event must NOT fire because surfacing + // it to the client would defeat the purpose of autopilot. + const { session, progressEvents } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }); + + const result = await session.handleUserInputRequest( + { question: 'What should I do next?' }, + { sessionId: 'test-session-1' } + ); + + assert.strictEqual(result.answer, 'Proceed with the recommended action.'); + assert.strictEqual(result.wasFreeform, true); + assert.strictEqual(progressEvents.length, 0); + }); + + test('autopilot picks the first choice when the SDK offers a choice list', 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, 'red'); + assert.strictEqual(result.wasFreeform, false); + 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', () => { @@ -1341,7 +1398,7 @@ suite('CopilotAgentSession', () => { }); test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { - const { session, mockSession, waitForProgress } = await createAgentSession(disposables); + const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables); mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; @@ -1349,8 +1406,13 @@ suite('CopilotAgentSession', () => { const event = await waitForProgress(e => e.type === 'user_input_request'); const request = event.request; - assert.ok(request.message?.includes('Plan summary')); - assert.ok(request.message?.includes('plan.md'), 'message should include a link to the plan file'); + + // 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); @@ -1431,6 +1493,123 @@ suite('CopilotAgentSession', () => { 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); diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts index 1ac9953b20f..f982c8f9d96 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -5,7 +5,7 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; -import { KNOWN_AUTO_APPROVE_VALUES, KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; @@ -141,8 +141,8 @@ 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 is a subset - * of `interactive | plan | autopilot` and contains at least `interactive`. + * 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 @@ -155,6 +155,6 @@ export function isWellKnownModeSchema(schema: SessionConfigPropertySchema): bool if (!schema.enum.includes(REQUIRED_MODE_VALUE)) { return false; } - return schema.enum.every(value => KNOWN_MODE_VALUES.has(value)); + return true; } 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 d69166b6aea..3dde440dfff 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 @@ -241,14 +241,4 @@ suite('isWellKnownModeSchema', () => { assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'autopilot'] })), false); assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'plan', 'autopilot'] })), false); }); - - test('rejects schemas with unknown enum values', () => { - assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'shell'] })), 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) { From 93711c3a70af380372c5ae6af968e0ed7e84fbf2 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 29 Apr 2026 14:16:20 -0700 Subject: [PATCH 3/4] test and tweak to ap answer --- .../agentHost/node/copilot/copilotAgentSession.ts | 4 ++-- .../agentHost/agentHostPermissionPickerDelegate.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 77bf4032f5b..c3105b0e375 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -747,8 +747,8 @@ export class CopilotAgentSession extends Disposable { const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot'; if (isAutopilot) { return { - answer: request.choices?.[0] || 'Proceed with the recommended action.', - wasFreeform: !request.choices?.length, + answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', + wasFreeform: true, }; } 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 3dde440dfff..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 @@ -236,9 +236,9 @@ suite('isWellKnownModeSchema', () => { assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['plan'] })), false); }); - test('rejects schemas with the legacy "autopilot" mode value', () => { - // `autopilot` is exposed as an `autoApprove` level, not a mode. - assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'autopilot'] })), false); - assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive', 'plan', 'autopilot'] })), 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); }); }); From f1def1447c8662f2feb03f1210de6117db7b1a00 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 29 Apr 2026 14:39:25 -0700 Subject: [PATCH 4/4] update --- .../test/node/copilotAgentSession.test.ts | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 14b1f0e7bf6..a1d738e3da5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -963,25 +963,6 @@ suite('CopilotAgentSession', () => { }); test('autopilot auto-answers a free-form question without firing a progress event', async () => { - // In autopilot the session should never block on the user — fall - // through to a generic "proceed" answer so the SDK can keep - // running. The progress event must NOT fire because surfacing - // it to the client would defeat the purpose of autopilot. - const { session, progressEvents } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, - }); - - const result = await session.handleUserInputRequest( - { question: 'What should I do next?' }, - { sessionId: 'test-session-1' } - ); - - assert.strictEqual(result.answer, 'Proceed with the recommended action.'); - assert.strictEqual(result.wasFreeform, true); - assert.strictEqual(progressEvents.length, 0); - }); - - test('autopilot picks the first choice when the SDK offers a choice list', async () => { const { session, progressEvents } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, }); @@ -994,8 +975,8 @@ suite('CopilotAgentSession', () => { // `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, 'red'); - assert.strictEqual(result.wasFreeform, false); + 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); });