mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-25 09:06:21 +01:00
Merge pull request #313301 from microsoft/connor4312/ah-plan-mode
agentHost: implement plan and native autopilot modes
This commit is contained in:
Generated
+33
-33
@@ -11,8 +11,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.49",
|
||||
"@github/copilot": "1.0.34",
|
||||
"@github/copilot-sdk": "^0.2.2",
|
||||
"@github/copilot": "1.0.39",
|
||||
"@github/copilot-sdk": "^0.3.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
@@ -1041,26 +1041,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz",
|
||||
"integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz",
|
||||
"integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.34",
|
||||
"@github/copilot-darwin-x64": "1.0.34",
|
||||
"@github/copilot-linux-arm64": "1.0.34",
|
||||
"@github/copilot-linux-x64": "1.0.34",
|
||||
"@github/copilot-win32-arm64": "1.0.34",
|
||||
"@github/copilot-win32-x64": "1.0.34"
|
||||
"@github/copilot-darwin-arm64": "1.0.39",
|
||||
"@github/copilot-darwin-x64": "1.0.39",
|
||||
"@github/copilot-linux-arm64": "1.0.39",
|
||||
"@github/copilot-linux-x64": "1.0.39",
|
||||
"@github/copilot-win32-arm64": "1.0.39",
|
||||
"@github/copilot-win32-x64": "1.0.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1074,9 +1074,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1090,9 +1090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1106,9 +1106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1122,12 +1122,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.2.2.tgz",
|
||||
"integrity": "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz",
|
||||
"integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.21",
|
||||
"@github/copilot": "^1.0.36-0",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -1145,9 +1145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1161,9 +1161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+2
-2
@@ -90,8 +90,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.49",
|
||||
"@github/copilot": "1.0.34",
|
||||
"@github/copilot-sdk": "^0.2.2",
|
||||
"@github/copilot": "1.0.39",
|
||||
"@github/copilot-sdk": "^0.3.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
|
||||
Generated
+33
-33
@@ -9,8 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.49",
|
||||
"@github/copilot": "1.0.34",
|
||||
"@github/copilot-sdk": "^0.2.2",
|
||||
"@github/copilot": "1.0.39",
|
||||
"@github/copilot-sdk": "^0.3.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
@@ -81,26 +81,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz",
|
||||
"integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz",
|
||||
"integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.34",
|
||||
"@github/copilot-darwin-x64": "1.0.34",
|
||||
"@github/copilot-linux-arm64": "1.0.34",
|
||||
"@github/copilot-linux-x64": "1.0.34",
|
||||
"@github/copilot-win32-arm64": "1.0.34",
|
||||
"@github/copilot-win32-x64": "1.0.34"
|
||||
"@github/copilot-darwin-arm64": "1.0.39",
|
||||
"@github/copilot-darwin-x64": "1.0.39",
|
||||
"@github/copilot-linux-arm64": "1.0.39",
|
||||
"@github/copilot-linux-x64": "1.0.39",
|
||||
"@github/copilot-win32-arm64": "1.0.39",
|
||||
"@github/copilot-win32-x64": "1.0.39"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -114,9 +114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -130,9 +130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -146,9 +146,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -162,12 +162,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.2.2.tgz",
|
||||
"integrity": "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz",
|
||||
"integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.21",
|
||||
"@github/copilot": "^1.0.36-0",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -185,9 +185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz",
|
||||
"integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz",
|
||||
"integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -201,9 +201,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz",
|
||||
"integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==",
|
||||
"version": "1.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz",
|
||||
"integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.49",
|
||||
"@github/copilot": "1.0.34",
|
||||
"@github/copilot-sdk": "^0.2.2",
|
||||
"@github/copilot": "1.0.39",
|
||||
"@github/copilot-sdk": "^0.3.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
|
||||
@@ -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<SessionMode>({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -265,7 +265,7 @@ export interface IAgentToolPendingConfirmationSignal {
|
||||
/** Protocol-shaped pending-confirmation state, dispatched verbatim into `SessionToolCallReady`. */
|
||||
readonly state: ToolCallPendingConfirmationState;
|
||||
/** Host-only auto-approval kind (not part of the dispatched action). */
|
||||
readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool';
|
||||
readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory';
|
||||
/** Host-only auto-approval path target (not part of the dispatched action). */
|
||||
readonly permissionPath?: string;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'fs/promises';
|
||||
import { Limiter, SequencerByKey } from '../../../../base/common/async.js';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js';
|
||||
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
|
||||
import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../../base/common/map.js';
|
||||
import { FileAccess } from '../../../../base/common/network.js';
|
||||
import { equals } from '../../../../base/common/objects.js';
|
||||
@@ -23,23 +23,23 @@ import { IFileService } from '../../../files/common/files.js';
|
||||
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../log/common/log.js';
|
||||
import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js';
|
||||
import { AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';
|
||||
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
|
||||
import { AgentSession, AgentSignal, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js';
|
||||
import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
|
||||
import { CustomizationStatus, CustomizationRef, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn, type PolicyState } from '../../common/state/sessionState.js';
|
||||
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js';
|
||||
import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
|
||||
import { IAgentConfigurationService } from '../agentConfigurationService.js';
|
||||
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js';
|
||||
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
|
||||
import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js';
|
||||
import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js';
|
||||
import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
|
||||
import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js';
|
||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||
import { createShellTools, ShellManager } from './copilotShellTools.js';
|
||||
import { ShellManager, createShellTools } from './copilotShellTools.js';
|
||||
|
||||
interface ICreatedWorktree {
|
||||
readonly repositoryRoot: URI;
|
||||
@@ -70,6 +70,40 @@ export interface ICopilotClient {
|
||||
readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's
|
||||
* private `connection` field to wire plan mode. See {@link CopilotAgent._enablePlanModeOnClient}.
|
||||
*/
|
||||
interface IExitPlanModeConnection {
|
||||
sendRequest(method: string, params: unknown): Promise<unknown>;
|
||||
onRequest(method: string, handler: (params: IExitPlanModeRequestParams) => Promise<IExitPlanModeResponse>): { dispose(): void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of the CLI's `exitPlanMode.request` RPC. The CLI dispatches one
|
||||
* per `exit_plan_mode` tool invocation when the session was created with
|
||||
* `requestExitPlanMode: true`.
|
||||
*/
|
||||
export interface IExitPlanModeRequestParams {
|
||||
readonly sessionId: string;
|
||||
readonly summary: string;
|
||||
readonly planContent: string;
|
||||
readonly actions: readonly string[];
|
||||
readonly recommendedAction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for the CLI's `exitPlanMode.request` RPC. The CLI feeds this
|
||||
* directly into `session.respondToExitPlanMode`, which resolves the
|
||||
* pending tool call and (when approved) updates the SDK's `currentMode`.
|
||||
*/
|
||||
export interface IExitPlanModeResponse {
|
||||
readonly approved: boolean;
|
||||
readonly selectedAction?: string;
|
||||
readonly autoApproveEdits?: boolean;
|
||||
readonly feedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrected shape of {@link CopilotClient.listModels} entries.
|
||||
*
|
||||
@@ -199,6 +233,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
||||
@IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
|
||||
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
|
||||
) {
|
||||
super();
|
||||
this._plugins = this._register(this._instantiationService.createInstance(PluginController));
|
||||
@@ -282,6 +317,59 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
await client?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables plan mode by injecting `requestExitPlanMode: true` into the
|
||||
* payload of every `session.create` / `session.resume` JSON-RPC request,
|
||||
* and registers a connection-level handler for the resulting
|
||||
* `exitPlanMode.request` RPC the CLI sends back.
|
||||
*
|
||||
* The SDK (`@github/copilot-sdk@^0.3.0`) does not expose `onExitPlanMode`
|
||||
* in its public {@link SessionConfig} surface, so both the wire flag and
|
||||
* the response handler are wired through the SDK's private
|
||||
* `MessageConnection`. Once the SDK adds first-class support, this shim
|
||||
* should be removed.
|
||||
*/
|
||||
protected _enablePlanModeOnClient(client: ICopilotClient): void {
|
||||
// `connection` is declared private on `CopilotClient` at the type
|
||||
// level but is a plain field at runtime — see the SDK's compiled
|
||||
// `dist/client.js`.
|
||||
const connection = (client as unknown as { connection?: IExitPlanModeConnection }).connection;
|
||||
if (!connection) {
|
||||
this._logService.warn('[Copilot] Could not enable plan mode: client.connection is null');
|
||||
return;
|
||||
}
|
||||
if (typeof connection.sendRequest !== 'function') {
|
||||
this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.sendRequest is ${typeof connection.sendRequest}`);
|
||||
return;
|
||||
}
|
||||
if (typeof connection.onRequest !== 'function') {
|
||||
this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.onRequest is ${typeof connection.onRequest}`);
|
||||
return;
|
||||
}
|
||||
const originalSendRequest = connection.sendRequest.bind(connection);
|
||||
connection.sendRequest = (method: string, params: unknown) => {
|
||||
if ((method === 'session.create' || method === 'session.resume') && params && typeof params === 'object') {
|
||||
return originalSendRequest(method, { ...params as Record<string, unknown>, 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<IExitPlanModeResponse> => {
|
||||
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<ICopilotClient> {
|
||||
@@ -333,7 +421,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`);
|
||||
|
||||
const client = this._createCopilotClient({
|
||||
githubToken: tokenAtStartup,
|
||||
gitHubToken: tokenAtStartup,
|
||||
useLoggedInUser: false,
|
||||
useStdio: true,
|
||||
autoStart: true,
|
||||
@@ -346,6 +434,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
throw new Error('Copilot authentication changed while the client was starting');
|
||||
}
|
||||
this._logService.info('[Copilot] CopilotClient started successfully');
|
||||
this._enablePlanModeOnClient(client);
|
||||
this._client = client;
|
||||
this._clientStarting = undefined;
|
||||
return client;
|
||||
@@ -644,6 +733,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
const values = sessionSchema.validateOrDefault(params.config, {
|
||||
[SessionConfigKey.Isolation]: isolationValue,
|
||||
[SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel,
|
||||
[SessionConfigKey.Mode]: 'interactive' satisfies SessionMode,
|
||||
// Permissions intentionally omitted — leave unset so auto-approval
|
||||
// falls through to the host-level `permissions` default, and only
|
||||
// materializes on the session once the user hits "Allow in this
|
||||
@@ -727,7 +817,8 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
try {
|
||||
await entry.send(prompt, attachments, turnId);
|
||||
const sdkMode = this._resolveSdkMode(session);
|
||||
await entry.send(prompt, attachments, turnId, sdkMode);
|
||||
} catch (err) {
|
||||
const errCode = (err as { code?: number })?.code;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -737,6 +828,35 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the AHP-side `(mode, autoApprove)` pair to the Copilot
|
||||
* SDK's three-mode space (`interactive` / `plan` / `autopilot`):
|
||||
*
|
||||
* - `mode='plan'` → SDK `plan` (auto-approval is irrelevant; the
|
||||
* agent host's existing session-state auto-approval logic handles
|
||||
* `plan.md` writes).
|
||||
* - `mode='interactive'` + `autoApprove='autopilot'` → SDK `autopilot`
|
||||
* (the SDK auto-approves all tool calls).
|
||||
* - `mode='interactive'` + any other autoApprove → SDK `interactive`
|
||||
* (the agent host's own auto-approval logic continues to gate tool
|
||||
* calls based on `autoApprove`).
|
||||
*
|
||||
* Returns `undefined` when no mode is configured for the session, so
|
||||
* the SDK's current mode is left untouched.
|
||||
*/
|
||||
private _resolveSdkMode(session: URI): CopilotSdkMode | undefined {
|
||||
const sessionKey = session.toString();
|
||||
const mode = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode);
|
||||
if (mode === 'plan') {
|
||||
return 'plan';
|
||||
}
|
||||
if (mode === 'interactive') {
|
||||
const autoApprove = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove);
|
||||
return autoApprove === 'autopilot' ? 'autopilot' : 'interactive';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
|
||||
const sessionId = AgentSession.id(session);
|
||||
const entry = this._sessions.get(sessionId);
|
||||
@@ -962,6 +1082,12 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
customAgents,
|
||||
skillDirectories: toSdkSkillDirectories(plugins.flatMap(p => p.skills)),
|
||||
tools: [...shellTools, ...callbacks.clientTools],
|
||||
// Enable infinite sessions so the SDK provisions a workspace
|
||||
// directory (containing `plan.md`, `checkpoints/`, `files/`).
|
||||
// The workspace is required for plan mode to work — without
|
||||
// it, `rpc.plan.read()` returns `path: null` and the SDK
|
||||
// never emits `exit_plan_mode.requested`.
|
||||
infiniteSessions: { enabled: true },
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,17 +12,22 @@ import { join } from '../../../../base/common/path.js';
|
||||
import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import type { IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
|
||||
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
|
||||
import { IFileService } from '../../../files/common/files.js';
|
||||
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../log/common/log.js';
|
||||
import { platformSessionSchema } from '../../common/agentHostSchema.js';
|
||||
import { AgentSignal, IAgentAttachment } from '../../common/agentService.js';
|
||||
import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js';
|
||||
import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type URI as ProtocolURI, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn } from '../../common/state/sessionState.js';
|
||||
import { IAgentConfigurationService } from '../agentConfigurationService.js';
|
||||
import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.js';
|
||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||
import type { ShellManager } from './copilotShellTools.js';
|
||||
import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js';
|
||||
@@ -30,9 +35,50 @@ import { FileEditTracker } from './fileEditTracker.js';
|
||||
import { mapSessionEvents } from './mapSessionEvents.js';
|
||||
import { buildPendingEditContentUri } from './pendingEditContentStore.js';
|
||||
|
||||
/**
|
||||
* The full set of agent modes the Copilot SDK accepts. Wider than the
|
||||
* {@link SessionMode} the AHP exposes — the SDK has a first-class
|
||||
* `'autopilot'` mode while AHP models that as
|
||||
* `mode='interactive', autoApprove='autopilot'`. The Copilot agent
|
||||
* translates between the two views in {@link CopilotAgentSession.send}
|
||||
* and the `session.mode_changed` listener.
|
||||
*/
|
||||
export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot';
|
||||
|
||||
const COPILOT_HOME_DIRECTORY = '.copilot';
|
||||
const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state');
|
||||
|
||||
/**
|
||||
* Display labels and descriptions for the SDK's `exit_plan_mode` action ids.
|
||||
* Keys not present here fall back to the raw action id.
|
||||
*/
|
||||
function getPlanActionDescription(actionId: string): { label: string; description: string } | undefined {
|
||||
switch (actionId) {
|
||||
case 'autopilot':
|
||||
return {
|
||||
label: localize('agentHost.planReview.autopilot.label', "Implement with Autopilot"),
|
||||
description: localize('agentHost.planReview.autopilot.description', "Auto-approve all tool calls and continue until done."),
|
||||
};
|
||||
case 'autopilot_fleet':
|
||||
return {
|
||||
label: localize('agentHost.planReview.autopilotFleet.label', "Implement with Autopilot Fleet"),
|
||||
description: localize('agentHost.planReview.autopilotFleet.description', "Auto-approve all tool calls, including fleet management actions, and continue until done."),
|
||||
};
|
||||
case 'interactive':
|
||||
return {
|
||||
label: localize('agentHost.planReview.interactive.label', "Implement Plan"),
|
||||
description: localize('agentHost.planReview.interactive.description', "Implement the plan, asking for input and approval for each action."),
|
||||
};
|
||||
case 'exit_only':
|
||||
return {
|
||||
label: localize('agentHost.planReview.exitOnly.label', "Approve Plan Only"),
|
||||
description: localize('agentHost.planReview.exitOnly.description', "Approve the plan without executing it. I will implement it myself."),
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type UserInputHandler = NonNullable<SessionConfig['onUserInputRequest']>;
|
||||
type UserInputRequest = Parameters<UserInputHandler>[0];
|
||||
type UserInputResponse = Awaited<ReturnType<UserInputHandler>>;
|
||||
@@ -109,6 +155,20 @@ export class CopilotAgentSession extends Disposable {
|
||||
private readonly _pendingPermissions = new Map<string, DeferredPromise<boolean>>();
|
||||
/** Pending user input requests awaiting a renderer-side answer. */
|
||||
private readonly _pendingUserInputs = new Map<string, { deferred: DeferredPromise<{ response: SessionInputResponseKind; answers?: Record<string, SessionInputAnswer> }>; 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<string, {
|
||||
readonly actions: readonly string[];
|
||||
readonly recommendedAction: string;
|
||||
readonly questionId: string;
|
||||
readonly deferred: DeferredPromise<IExitPlanModeResponse>;
|
||||
}>();
|
||||
/** File edit tracker for this session. */
|
||||
private readonly _editTracker: FileEditTracker;
|
||||
/** Session database reference. */
|
||||
@@ -117,6 +177,8 @@ export class CopilotAgentSession extends Disposable {
|
||||
private _turnId = '';
|
||||
/** SDK session wrapper, set by {@link initializeSession}. */
|
||||
private _wrapper!: CopilotSessionWrapper;
|
||||
/** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */
|
||||
private _lastAppliedMode: CopilotSdkMode | undefined;
|
||||
|
||||
/** Snapshot captured at session creation for refresh detection. */
|
||||
private readonly _appliedSnapshot: IActiveClientSnapshot;
|
||||
@@ -152,6 +214,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
@ISessionDataService sessionDataService: ISessionDataService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
|
||||
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
|
||||
) {
|
||||
super();
|
||||
this.sessionId = options.rawSessionId;
|
||||
@@ -173,6 +236,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._register(toDisposable(() => this._denyPendingPermissions()));
|
||||
this._register(toDisposable(() => this._shellManager?.dispose()));
|
||||
this._register(toDisposable(() => this._cancelPendingUserInputs()));
|
||||
this._register(toDisposable(() => this._cancelPendingPlanReviews()));
|
||||
|
||||
// When a shell tool associates a terminal with a tool call, fire a
|
||||
// tool_content_changed event so the UI can connect to the terminal
|
||||
@@ -395,7 +459,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
|
||||
// ---- session operations -------------------------------------------------
|
||||
|
||||
async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void> {
|
||||
async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise<void> {
|
||||
if (turnId) {
|
||||
this._turnId = turnId;
|
||||
}
|
||||
@@ -412,10 +476,29 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`);
|
||||
}
|
||||
|
||||
await this.applyMode(mode);
|
||||
await this._wrapper.session.send({ prompt, attachments: sdkAttachments });
|
||||
this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes `mode` to the SDK via `rpc.mode.set` if it differs from the
|
||||
* last applied value. Failures are logged and swallowed so that mode
|
||||
* propagation does not block the turn.
|
||||
*/
|
||||
async applyMode(mode: CopilotSdkMode | undefined): Promise<void> {
|
||||
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<void> {
|
||||
this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`);
|
||||
try {
|
||||
@@ -495,13 +578,13 @@ export class CopilotAgentSession extends Disposable {
|
||||
if (!toolCallId) {
|
||||
// TODO: handle permission requests without a toolCallId by creating a synthetic tool call
|
||||
this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`);
|
||||
return { kind: 'denied-interactively-by-user' };
|
||||
return { kind: 'reject' };
|
||||
}
|
||||
|
||||
const sessionResourcePath = this._getInternalSessionResourcePath(request);
|
||||
if (sessionResourcePath) {
|
||||
this._logService.info(`[Copilot:${this.sessionId}] Auto-approving internal session resource ${sessionResourcePath}`);
|
||||
return { kind: 'approved' };
|
||||
return { kind: 'approve-once' };
|
||||
}
|
||||
|
||||
this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`);
|
||||
@@ -524,7 +607,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
// `pending-edit-content:` entry has been cleaned up. Bail without
|
||||
// firing tool_ready.
|
||||
if (!this._pendingPermissions.has(toolCallId)) {
|
||||
return { kind: 'denied-interactively-by-user' };
|
||||
return { kind: 'reject' };
|
||||
}
|
||||
|
||||
// Fire a pending_confirmation signal to transition the tool to PendingConfirmation
|
||||
@@ -548,7 +631,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
|
||||
const approved = await deferred.p;
|
||||
this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`);
|
||||
return { kind: approved ? 'approved' : 'denied-interactively-by-user' };
|
||||
return { kind: approved ? 'approve-once' : 'reject' };
|
||||
} catch (error) {
|
||||
this._logService.error(error, `[Copilot:${this.sessionId}] Failed to handle permission request: kind=${request.kind}, toolCallId=${request.toolCallId ?? 'missing'}`);
|
||||
throw error;
|
||||
@@ -661,6 +744,14 @@ export class CopilotAgentSession extends Disposable {
|
||||
request: UserInputRequest,
|
||||
_invocation: { sessionId: string },
|
||||
): Promise<UserInputResponse> {
|
||||
const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot';
|
||||
if (isAutopilot) {
|
||||
return {
|
||||
answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.',
|
||||
wasFreeform: true,
|
||||
};
|
||||
}
|
||||
|
||||
const questionPreview = request.question.substring(0, 100);
|
||||
try {
|
||||
const requestId = generateUuid();
|
||||
@@ -726,6 +817,13 @@ export class CopilotAgentSession extends Disposable {
|
||||
}
|
||||
|
||||
respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record<string, SessionInputAnswer>): boolean {
|
||||
const pendingPlanReview = this._pendingPlanReviews.get(requestId);
|
||||
if (pendingPlanReview) {
|
||||
this._pendingPlanReviews.delete(requestId);
|
||||
pendingPlanReview.deferred.complete(this._resolveExitPlanMode(pendingPlanReview, response, answers));
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = this._pendingUserInputs.get(requestId);
|
||||
if (pending) {
|
||||
this._pendingUserInputs.delete(requestId);
|
||||
@@ -735,6 +833,87 @@ export class CopilotAgentSession extends Disposable {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an `exit_plan_mode` input response back to an
|
||||
* {@link IExitPlanModeResponse} that the CLI can feed into
|
||||
* `session.respondToExitPlanMode`. Mapping rules:
|
||||
*
|
||||
* - Decline / Cancel / no answer → `{ approved: false }` (model gets a
|
||||
* rejection result and stays in plan mode).
|
||||
* - Accept + freeform feedback → `{ approved: false, feedback, selectedAction? }`
|
||||
* (the SDK treats this as a revision request and re-emits
|
||||
* `exit_plan_mode.requested` after revising the plan).
|
||||
* - Accept + selected option → `{ approved: true, selectedAction, autoApproveEdits }`
|
||||
* where `autoApproveEdits` is set for the autopilot variants.
|
||||
*
|
||||
* `selectedAction` is validated against the SDK's offered `actions`; an
|
||||
* unknown value is treated as a decline so the SDK isn't fed a value it
|
||||
* cannot handle.
|
||||
*/
|
||||
private _resolveExitPlanMode(
|
||||
pending: { actions: readonly string[]; recommendedAction: string; questionId: string },
|
||||
response: SessionInputResponseKind,
|
||||
answers?: Record<string, SessionInputAnswer>,
|
||||
): IExitPlanModeResponse {
|
||||
if (response !== SessionInputResponseKind.Accept) {
|
||||
return { approved: false };
|
||||
}
|
||||
const answer = answers?.[pending.questionId];
|
||||
if (!answer || answer.state === SessionInputAnswerState.Skipped) {
|
||||
return { approved: false };
|
||||
}
|
||||
const value = answer.value;
|
||||
|
||||
// Determine the selected action and any freeform feedback. The
|
||||
// `single-select` question may carry both (when the user picks an
|
||||
// option AND types feedback), or just freeform text (when the
|
||||
// user types instead of picking). Normalize to one shape.
|
||||
let candidateAction: string | undefined;
|
||||
let feedback: string | undefined;
|
||||
if (value.kind === SessionInputAnswerValueKind.Selected) {
|
||||
candidateAction = value.value;
|
||||
const freeform = value.freeformValues?.find(s => s.trim().length > 0)?.trim();
|
||||
feedback = freeform;
|
||||
} else if (value.kind === SessionInputAnswerValueKind.Text) {
|
||||
feedback = value.value.trim() || undefined;
|
||||
} else {
|
||||
return { approved: false };
|
||||
}
|
||||
|
||||
// Clamp `selectedAction` to the SDK's offered set. Anything else
|
||||
// (including freeform text smuggled into the `value` field) falls
|
||||
// back to the recommended action so we never feed the SDK a value
|
||||
// it can't act on.
|
||||
const selectedAction = candidateAction && pending.actions.includes(candidateAction)
|
||||
? candidateAction
|
||||
: pending.actions.includes(pending.recommendedAction)
|
||||
? pending.recommendedAction
|
||||
: undefined;
|
||||
|
||||
// Freeform feedback => revision request. The SDK semantics are
|
||||
// `approved: false` with a non-empty `feedback`; it will revise
|
||||
// the plan and re-emit `exit_plan_mode.requested`.
|
||||
if (feedback) {
|
||||
return {
|
||||
approved: false,
|
||||
feedback,
|
||||
...(selectedAction ? { selectedAction } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// No selectable action and no feedback — nothing actionable.
|
||||
if (!selectedAction) {
|
||||
return { approved: false };
|
||||
}
|
||||
|
||||
const isAutopilot = selectedAction === 'autopilot' || selectedAction === 'autopilot_fleet';
|
||||
return {
|
||||
approved: true,
|
||||
selectedAction,
|
||||
...(isAutopilot ? { autoApproveEdits: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async _handlePreToolUse(input: PreToolUseHookInput): Promise<void> {
|
||||
try {
|
||||
if (isEditTool(input.toolName)) {
|
||||
@@ -1033,6 +1212,145 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`);
|
||||
this._emitReasoningDelta(e.data.deltaContent);
|
||||
}));
|
||||
|
||||
// Sync the AHP session config when the SDK's `currentMode` changes
|
||||
// (e.g. after the model approves a plan, or after we set the mode
|
||||
// before sending). The SDK has three modes (`interactive` / `plan` /
|
||||
// `autopilot`); AHP only models `interactive` / `plan` and treats
|
||||
// autopilot as `mode='interactive', autoApprove='autopilot'`, so we
|
||||
// translate before writing.
|
||||
this._register(wrapper.onSessionModeChanged(e => {
|
||||
this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`);
|
||||
const newMode = e.data.newMode;
|
||||
if (newMode !== 'interactive' && newMode !== 'plan' && newMode !== 'autopilot') {
|
||||
return;
|
||||
}
|
||||
this._lastAppliedMode = newMode;
|
||||
this._syncAhpConfigFromSdkMode(newMode);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the SDK's three-mode space (`interactive` / `plan` /
|
||||
* `autopilot`) to AHP's two-axis model:
|
||||
*
|
||||
* - SDK `plan` → AHP `mode='plan'`.
|
||||
* - SDK `interactive` → AHP `mode='interactive'`.
|
||||
* - SDK `autopilot` → AHP `mode='interactive', autoApprove='autopilot'`.
|
||||
* Autopilot is exposed in AHP as the highest auto-approval level on
|
||||
* the orthogonal `autoApprove` axis, not as a mode value.
|
||||
*
|
||||
* Patches that already match the current AHP values are still
|
||||
* dispatched (the reducer is a no-op in that case) but written values
|
||||
* propagate to all subscribed clients via `session/configChanged`.
|
||||
*/
|
||||
private _syncAhpConfigFromSdkMode(sdkMode: CopilotSdkMode): void {
|
||||
const sessionUri = this.sessionUri.toString();
|
||||
const patch: Record<string, unknown> = {};
|
||||
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<IExitPlanModeResponse> {
|
||||
const requestId = generateUuid();
|
||||
const questionId = generateUuid();
|
||||
this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request: rpcId=${requestId}, actions=[${data.actions.join(',')}], recommended=${data.recommendedAction}`);
|
||||
|
||||
// When the session's effective auto-approval level is `autopilot`,
|
||||
// approve the plan automatically without surfacing a question to
|
||||
// the user. Mirrors the "autopilot fast-path" in the Copilot CLI's
|
||||
// own plan-mode handler.
|
||||
const autoApprove = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove);
|
||||
if (autoApprove === 'autopilot') {
|
||||
const response = autoApproveExitPlanMode(data);
|
||||
this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request auto-accepted (autoApprove=autopilot): selectedAction=${response.selectedAction ?? '(none)'}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Resolve the plan file path so we can embed a markdown link.
|
||||
let planPath: string | null = null;
|
||||
try {
|
||||
const planRead = await this._wrapper.session.rpc.plan.read();
|
||||
planPath = planRead.path ?? null;
|
||||
} catch (err) {
|
||||
this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Build the input-request markdown: summary + link to the plan file.
|
||||
let message = data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review.");
|
||||
if (planPath) {
|
||||
const planUri = URI.file(planPath);
|
||||
message += `\n\n[${localize('agentHost.planReview.viewPlanLink', "View full plan")}](${planUri.toString()})`;
|
||||
}
|
||||
|
||||
this._emitMarkdownDelta(message);
|
||||
|
||||
const options = data.actions.map(actionId => {
|
||||
const desc = getPlanActionDescription(actionId);
|
||||
return {
|
||||
id: actionId,
|
||||
label: desc?.label ?? actionId,
|
||||
description: desc?.description,
|
||||
recommended: actionId === data.recommendedAction,
|
||||
};
|
||||
});
|
||||
|
||||
const inputRequest: SessionInputRequest = {
|
||||
id: requestId,
|
||||
questions: [{
|
||||
kind: SessionInputQuestionKind.SingleSelect,
|
||||
id: questionId,
|
||||
title: localize('agentHost.planReview.title', "Review Plan"),
|
||||
message: localize('agentHost.planReview.questionMessage', "How would you like to proceed?"),
|
||||
required: true,
|
||||
options,
|
||||
allowFreeformInput: true,
|
||||
}],
|
||||
};
|
||||
|
||||
const deferred = new DeferredPromise<IExitPlanModeResponse>();
|
||||
this._pendingPlanReviews.set(requestId, {
|
||||
actions: data.actions,
|
||||
recommendedAction: data.recommendedAction,
|
||||
questionId,
|
||||
deferred,
|
||||
});
|
||||
|
||||
this._onDidSessionProgress.fire({
|
||||
kind: 'action',
|
||||
session: this.sessionUri,
|
||||
action: {
|
||||
type: ActionType.SessionInputRequested,
|
||||
session: this.sessionUri.toString(),
|
||||
request: inputRequest,
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await deferred.p;
|
||||
} catch (err) {
|
||||
this._logService.error(err, `[Copilot:${this.sessionId}] exitPlanMode.request handler failed: rpcId=${requestId}`);
|
||||
return { approved: false };
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribeForLogging(): void {
|
||||
@@ -1236,6 +1554,13 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._pendingUserInputs.clear();
|
||||
}
|
||||
|
||||
private _cancelPendingPlanReviews(): void {
|
||||
for (const [, pending] of this._pendingPlanReviews) {
|
||||
pending.deferred.complete({ approved: false });
|
||||
}
|
||||
this._pendingPlanReviews.clear();
|
||||
}
|
||||
|
||||
private _cancelPendingClientToolCalls(): void {
|
||||
for (const [, deferred] of this._pendingClientToolCalls) {
|
||||
deferred.complete({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' });
|
||||
@@ -1244,6 +1569,47 @@ export class CopilotAgentSession extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link IExitPlanModeResponse} used when the session is in
|
||||
* autopilot and we approve the plan without user interaction.
|
||||
*
|
||||
* Selection priority mirrors the Copilot CLI's own autopilot handler.
|
||||
*
|
||||
* 1. If the SDK's `recommendedAction` is offered, take it.
|
||||
* 2. Otherwise fall back to `autopilot` → `autopilot_fleet` → `interactive`
|
||||
* → `exit_only`.
|
||||
* 3. As a last resort, approve without picking a `selectedAction` (the SDK
|
||||
* keeps `currentMode='interactive'` in that case).
|
||||
*
|
||||
* `autoApproveEdits: true` is set whenever the chosen action is one of the
|
||||
* autopilot variants, mirroring the CLI behavior.
|
||||
*/
|
||||
function autoApproveExitPlanMode(data: IExitPlanModeRequestParams): IExitPlanModeResponse {
|
||||
const choices = data.actions ?? [];
|
||||
const isAutopilotAction = (action: string) => action === 'autopilot' || action === 'autopilot_fleet';
|
||||
|
||||
if (data.recommendedAction && choices.includes(data.recommendedAction)) {
|
||||
const selectedAction = data.recommendedAction;
|
||||
return {
|
||||
approved: true,
|
||||
selectedAction,
|
||||
...(isAutopilotAction(selectedAction) ? { autoApproveEdits: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
for (const action of ['autopilot', 'autopilot_fleet', 'interactive', 'exit_only']) {
|
||||
if (choices.includes(action)) {
|
||||
return {
|
||||
approved: true,
|
||||
selectedAction: action,
|
||||
...(isAutopilotAction(action) ? { autoApproveEdits: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { approved: true, autoApproveEdits: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts added/removed lines in a unified diff string. Ignores the `+++` and
|
||||
* `---` header rows and any non-hunk context.
|
||||
|
||||
@@ -208,6 +208,11 @@ export class CopilotSessionWrapper extends Disposable {
|
||||
return this._onSystemMessage ??= this._sdkEvent('system.message');
|
||||
}
|
||||
|
||||
private _onSessionModeChanged: Event<SessionEventPayload<'session.mode_changed'>> | undefined;
|
||||
get onSessionModeChanged(): Event<SessionEventPayload<'session.mode_changed'>> {
|
||||
return this._onSessionModeChanged ??= this._sdkEvent('session.mode_changed');
|
||||
}
|
||||
|
||||
private _sdkEvent<K extends SessionEventType>(eventType: K): Event<SessionEventPayload<K>> {
|
||||
const emitter = this._register(new Emitter<SessionEventPayload<K>>());
|
||||
const unsubscribe = this.session.on(eventType, (data: SessionEventPayload<K>) => emitter.fire(data));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,8 +161,10 @@ class TestableCopilotAgent extends CopilotAgent {
|
||||
@ISessionDataService sessionDataService: ISessionDataService,
|
||||
@IAgentHostGitService gitService: IAgentHostGitService,
|
||||
@IAgentHostTerminalManager terminalManager: IAgentHostTerminalManager,
|
||||
@IAgentConfigurationService configurationService: IAgentConfigurationService,
|
||||
) {
|
||||
super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager);
|
||||
super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager, configurationService);
|
||||
this._enablePlanModeOnClient(this._copilotClient);
|
||||
}
|
||||
|
||||
protected override _createCopilotClient(): ICopilotClient {
|
||||
@@ -349,7 +351,7 @@ suite('CopilotAgent', () => {
|
||||
toolCallId: 'tc-read-plan-agent-composition',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
delete process.env['XDG_STATE_HOME'];
|
||||
|
||||
@@ -24,6 +24,8 @@ import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import { AttachmentType, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js';
|
||||
import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js';
|
||||
import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js';
|
||||
import { IAgentConfigurationService } from '../../node/agentConfigurationService.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js';
|
||||
|
||||
// ---- Mock CopilotSession (SDK level) ----------------------------------------
|
||||
@@ -36,8 +38,10 @@ import { createSessionDataService, createZeroDiffComputeService } from '../commo
|
||||
class MockCopilotSession {
|
||||
readonly sessionId = 'test-session-1';
|
||||
readonly sendRequests: unknown[] = [];
|
||||
readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = [];
|
||||
|
||||
private readonly _handlers = new Map<string, Set<(event: SessionEvent) => void>>();
|
||||
planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null };
|
||||
|
||||
on<K extends SessionEventType>(eventType: K, handler: TypedSessionEventHandler<K>): () => void {
|
||||
let set = this._handlers.get(eventType);
|
||||
@@ -66,6 +70,20 @@ class MockCopilotSession {
|
||||
async setModel() { }
|
||||
async getMessages() { return []; }
|
||||
async destroy() { }
|
||||
|
||||
readonly rpc = {
|
||||
mode: {
|
||||
get: async () => ({ mode: 'interactive' as const }),
|
||||
set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => {
|
||||
this.modeSetCalls.push({ mode: params.mode });
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
read: async () => this.planReadResult,
|
||||
update: async (_params: { content: string }) => { /* no-op */ },
|
||||
delete: async () => { /* no-op */ },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class CapturingLogService extends NullLogService {
|
||||
@@ -113,12 +131,15 @@ async function createAgentSession(disposables: DisposableStore, options?: {
|
||||
logService?: ILogService;
|
||||
captureWrapperCallbacks?: { current?: Parameters<SessionWrapperFactory>[0] };
|
||||
workingDirectory?: URI;
|
||||
/** Per-key effective config values returned by the fake configuration service. */
|
||||
configValues?: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
session: CopilotAgentSession;
|
||||
mockSession: MockCopilotSession;
|
||||
progressEvents: LegacyMockEvent[];
|
||||
waitForProgress: <T extends LegacyMockEvent>(predicate: (event: LegacyMockEvent) => event is T) => Promise<T>;
|
||||
signals: AgentSignal[];
|
||||
waitForProgress: (predicate: (event: LegacyMockEvent) => boolean) => Promise<LegacyMockEvent>;
|
||||
sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record<string, unknown> }>;
|
||||
}> {
|
||||
const progressEmitter = disposables.add(new Emitter<AgentSignal>());
|
||||
const progressEvents: LegacyMockEvent[] = [];
|
||||
@@ -160,14 +181,14 @@ async function createAgentSession(disposables: DisposableStore, options?: {
|
||||
notify(view);
|
||||
}));
|
||||
|
||||
const waitForProgress = (predicate: (event: LegacyMockEvent) => boolean): Promise<LegacyMockEvent> => {
|
||||
const waitForProgress = <T extends LegacyMockEvent>(predicate: (event: LegacyMockEvent) => event is T): Promise<T> => {
|
||||
const existing = progressEvents.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
const deferred = new DeferredPromise<LegacyMockEvent>();
|
||||
waiters.push({ predicate, deferred });
|
||||
return deferred.p;
|
||||
return deferred.p as Promise<T>;
|
||||
};
|
||||
|
||||
const sessionUri = AgentSession.uri('copilot', 'test-session-1');
|
||||
@@ -185,6 +206,23 @@ async function createAgentSession(disposables: DisposableStore, options?: {
|
||||
services.set(IFileService, { _serviceBrand: undefined } as IFileService);
|
||||
services.set(ISessionDataService, createSessionDataService());
|
||||
services.set(IDiffComputeService, createZeroDiffComputeService());
|
||||
const sessionConfigUpdates: Array<{ session: string; patch: Record<string, unknown> }> = [];
|
||||
const configValues = options?.configValues ?? {};
|
||||
const fakeConfigurationService: IAgentConfigurationService = {
|
||||
_serviceBrand: undefined,
|
||||
onDidRootConfigChange: new Emitter<void>().event,
|
||||
// Simple per-key map suffices for tests; the real service walks
|
||||
// session → parent → host and validates against the schema, but
|
||||
// neither matters here — we just need to surface a value the
|
||||
// session class will read.
|
||||
getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'],
|
||||
getEffectiveWorkingDirectory: () => undefined,
|
||||
updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); },
|
||||
getRootValue: () => undefined,
|
||||
updateRootConfig: () => { /* no-op */ },
|
||||
persistRootConfig: () => { /* no-op */ },
|
||||
};
|
||||
services.set(IAgentConfigurationService, fakeConfigurationService);
|
||||
const environmentService = {
|
||||
_serviceBrand: undefined,
|
||||
userHome: URI.file('/mock-home'),
|
||||
@@ -209,7 +247,7 @@ async function createAgentSession(disposables: DisposableStore, options?: {
|
||||
|
||||
await session.initializeSession();
|
||||
|
||||
return { session, mockSession, progressEvents, signals, waitForProgress };
|
||||
return { session, mockSession, progressEvents, signals, waitForProgress, sessionConfigUpdates };
|
||||
}
|
||||
|
||||
// ---- Tests ------------------------------------------------------------------
|
||||
@@ -257,7 +295,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
assert.ok(session.respondToPermissionRequest('tc-1', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
});
|
||||
|
||||
test('auto-approves read permission for session-state plan files', async () => {
|
||||
@@ -271,7 +309,7 @@ suite('CopilotAgentSession', () => {
|
||||
toolCallId: 'tc-read-plan',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
@@ -293,7 +331,7 @@ suite('CopilotAgentSession', () => {
|
||||
toolCallId: 'tc-read-plan-native-env',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
@@ -348,7 +386,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
assert.ok(session.respondToPermissionRequest('tc-1', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
});
|
||||
|
||||
test('auto-approves write permission for session-state plan files', async () => {
|
||||
@@ -362,7 +400,7 @@ suite('CopilotAgentSession', () => {
|
||||
toolCallId: 'tc-write-plan',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
@@ -389,7 +427,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
assert.ok(session.respondToPermissionRequest('tc-write-other-plan', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
delete process.env['XDG_STATE_HOME'];
|
||||
@@ -416,7 +454,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
assert.ok(session.respondToPermissionRequest('tc-write-traversal', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
} finally {
|
||||
if (previousXdgStateHome === undefined) {
|
||||
delete process.env['XDG_STATE_HOME'];
|
||||
@@ -440,7 +478,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
assert.ok(session.respondToPermissionRequest('tc-write-outside', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
});
|
||||
|
||||
test('read permission outside working directory fires tool_ready', async () => {
|
||||
@@ -460,13 +498,13 @@ suite('CopilotAgentSession', () => {
|
||||
// Respond to it
|
||||
assert.ok(session.respondToPermissionRequest('tc-2', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
assert.strictEqual(result.kind, 'approve-once');
|
||||
});
|
||||
|
||||
test('denies permission when no toolCallId', async () => {
|
||||
const { session } = await createAgentSession(disposables);
|
||||
const result = await session.handlePermissionRequest({ kind: 'write' });
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
assert.strictEqual(result.kind, 'reject');
|
||||
});
|
||||
|
||||
test('denied-interactively when user denies', async () => {
|
||||
@@ -480,7 +518,7 @@ suite('CopilotAgentSession', () => {
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
session.respondToPermissionRequest('tc-3', false);
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
assert.strictEqual(result.kind, 'reject');
|
||||
});
|
||||
|
||||
test('pending permissions are denied on dispose', async () => {
|
||||
@@ -492,7 +530,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
session.dispose();
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
assert.strictEqual(result.kind, 'reject');
|
||||
});
|
||||
|
||||
test('pending permissions are denied on abort', async () => {
|
||||
@@ -504,7 +542,7 @@ suite('CopilotAgentSession', () => {
|
||||
|
||||
await session.abort();
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
assert.strictEqual(result.kind, 'reject');
|
||||
});
|
||||
|
||||
test('respondToPermissionRequest returns false for unknown id', async () => {
|
||||
@@ -923,6 +961,44 @@ suite('CopilotAgentSession', () => {
|
||||
assert.strictEqual(result.answer, '');
|
||||
assert.strictEqual(result.wasFreeform, true);
|
||||
});
|
||||
|
||||
test('autopilot auto-answers a free-form question without firing a progress event', async () => {
|
||||
const { session, progressEvents } = await createAgentSession(disposables, {
|
||||
configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' },
|
||||
});
|
||||
|
||||
const result = await session.handleUserInputRequest(
|
||||
{ question: 'Pick a color', choices: ['red', 'blue', 'green'] },
|
||||
{ sessionId: 'test-session-1' }
|
||||
);
|
||||
|
||||
// `wasFreeform: false` because we picked one of the SDK's
|
||||
// offered choices — the SDK uses this hint to record whether
|
||||
// the user typed something custom.
|
||||
assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.');
|
||||
assert.strictEqual(result.wasFreeform, true);
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
});
|
||||
|
||||
test('autopilot does not auto-answer when autoApprove is not "autopilot"', async () => {
|
||||
// Sanity check: with autoApprove=default the question must
|
||||
// still be surfaced as a progress event (the existing behavior).
|
||||
const { session, progressEvents } = await createAgentSession(disposables, {
|
||||
configValues: { [SessionConfigKey.AutoApprove]: 'default' },
|
||||
});
|
||||
|
||||
session.handleUserInputRequest(
|
||||
{ question: 'Need user input' },
|
||||
{ sessionId: 'test-session-1' }
|
||||
);
|
||||
|
||||
// Microtask flush so the handler can run far enough to either
|
||||
// short-circuit or emit a progress event.
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
const event = progressEvents[0];
|
||||
assertUserInputEvent(event);
|
||||
});
|
||||
});
|
||||
|
||||
suite('SDK callback logging', () => {
|
||||
@@ -1094,7 +1170,7 @@ suite('CopilotAgentSession', () => {
|
||||
// Approve and clean up
|
||||
session.respondToPermissionRequest('tc-client-perm', true);
|
||||
const permResult = await resultPromise;
|
||||
assert.strictEqual(permResult.kind, 'approved');
|
||||
assert.strictEqual(permResult.kind, 'approve-once');
|
||||
session.handleClientToolCallComplete('tc-client-perm', {
|
||||
success: true,
|
||||
pastTenseMessage: 'did it',
|
||||
@@ -1265,4 +1341,341 @@ suite('CopilotAgentSession', () => {
|
||||
assert.strictEqual(result.textResultForLlm, 'text part');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Plan mode ----------------------------------------------------------
|
||||
|
||||
suite('plan mode', () => {
|
||||
|
||||
const planRequestParams = (overrides?: Partial<{ actions: string[]; recommendedAction: string; summary: string }>) => ({
|
||||
sessionId: 'test-session-1',
|
||||
summary: overrides?.summary ?? '## Plan summary',
|
||||
planContent: '## Plan',
|
||||
actions: overrides?.actions ?? ['autopilot', 'interactive', 'exit_only'],
|
||||
recommendedAction: overrides?.recommendedAction ?? 'autopilot',
|
||||
});
|
||||
|
||||
test('applyMode pushes the mode to the SDK only when it changes', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
|
||||
await session.applyMode('plan');
|
||||
await session.applyMode('plan');
|
||||
await session.applyMode('autopilot');
|
||||
await session.applyMode(undefined);
|
||||
await session.applyMode('autopilot');
|
||||
|
||||
assert.deepStrictEqual(mockSession.modeSetCalls, [
|
||||
{ mode: 'plan' },
|
||||
{ mode: 'autopilot' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('send applies mode before forwarding to the SDK', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
|
||||
await session.send('hi', undefined, 'turn-1', 'plan');
|
||||
|
||||
assert.deepStrictEqual(mockSession.modeSetCalls, [{ mode: 'plan' }]);
|
||||
assert.strictEqual(mockSession.sendRequests.length, 1);
|
||||
});
|
||||
|
||||
test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => {
|
||||
const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' };
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams());
|
||||
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const request = event.request;
|
||||
|
||||
// The plan summary and "View full plan" link are emitted as a
|
||||
// markdown response part before the input request, so the
|
||||
// client renders them inline above the question.
|
||||
const deltaContent = progressEvents.flatMap(e => e.type === 'delta' ? [e.content] : []).join('');
|
||||
assert.ok(deltaContent.includes('Plan summary'), `expected delta to include plan summary; got: ${deltaContent}`);
|
||||
assert.ok(deltaContent.includes('plan.md'), 'delta should include a link to the plan file');
|
||||
|
||||
const question = request.questions?.[0];
|
||||
assert.strictEqual(question?.kind, SessionInputQuestionKind.SingleSelect);
|
||||
if (question?.kind === SessionInputQuestionKind.SingleSelect) {
|
||||
assert.deepStrictEqual(question.options.map(o => o.id), ['autopilot', 'interactive', 'exit_only']);
|
||||
const recommended = question.options.find(o => o.recommended);
|
||||
assert.strictEqual(recommended?.id, 'autopilot');
|
||||
assert.strictEqual(question.allowFreeformInput, true);
|
||||
}
|
||||
|
||||
// Resolve the request so the deferred completes and the test can clean up.
|
||||
session.respondToUserInputRequest(request.id, SessionInputResponseKind.Decline);
|
||||
await responsePromise;
|
||||
});
|
||||
|
||||
test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true });
|
||||
});
|
||||
|
||||
test('completing the input request with interactive resolves with approved + interactive (no autoApprove)', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' });
|
||||
});
|
||||
|
||||
test('declining the input request resolves with approved=false', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams());
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
|
||||
session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Decline);
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: false });
|
||||
});
|
||||
|
||||
test('exit_only resolves as approved + interactive without autoApproveEdits', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Selected, value: 'exit_only' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'exit_only' });
|
||||
});
|
||||
|
||||
test('freeform feedback alongside a selected action becomes a revision request', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: {
|
||||
kind: SessionInputAnswerValueKind.Selected,
|
||||
value: 'interactive',
|
||||
freeformValues: ['Please use Python instead of Node.js'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, {
|
||||
approved: false,
|
||||
feedback: 'Please use Python instead of Node.js',
|
||||
selectedAction: 'interactive',
|
||||
});
|
||||
});
|
||||
|
||||
test('selectedAction not in offered actions falls back to recommendedAction', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
// SDK only offered `interactive` and `exit_only`; the client
|
||||
// somehow sent `autopilot` (e.g. stale UI state). The agent
|
||||
// host clamps to `recommendedAction` so the SDK never sees a
|
||||
// value it didn't offer.
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' });
|
||||
});
|
||||
|
||||
test('selectedAction not in offered actions and no fallback resolves to approved=false', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
// SDK offered `exit_only` only and recommended a value not in
|
||||
// the offered set. The client picked something invalid. With
|
||||
// no usable selectedAction and no feedback, decline.
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: false });
|
||||
});
|
||||
|
||||
test('text answer with feedback becomes a revision request without selectedAction', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
// The single-select question normally produces a Selected
|
||||
// value, but a defensive Text response should still be
|
||||
// translated to a revision request when the answer is
|
||||
// non-empty (selectedAction falls back to recommendedAction).
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: { kind: SessionInputAnswerValueKind.Text, value: 'Add tests for edge cases' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, {
|
||||
approved: false,
|
||||
feedback: 'Add tests for edge cases',
|
||||
selectedAction: 'interactive',
|
||||
});
|
||||
});
|
||||
|
||||
test('whitespace-only freeform feedback is ignored', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables);
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }));
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
const requestId = event.request.id;
|
||||
const questionId = event.request.questions![0].id;
|
||||
|
||||
session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, {
|
||||
[questionId]: {
|
||||
state: SessionInputAnswerState.Submitted,
|
||||
value: {
|
||||
kind: SessionInputAnswerValueKind.Selected,
|
||||
value: 'interactive',
|
||||
freeformValues: [' ', ''],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' });
|
||||
});
|
||||
|
||||
test('session.mode_changed → plan updates the AHP session config', async () => {
|
||||
const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables);
|
||||
|
||||
mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data']);
|
||||
|
||||
assert.deepStrictEqual(sessionConfigUpdates, [
|
||||
{ session: 'copilot:/test-session-1', patch: { mode: 'plan' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('session.mode_changed → interactive updates the AHP session config', async () => {
|
||||
const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables);
|
||||
|
||||
mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'interactive' } as SessionEventPayload<'session.mode_changed'>['data']);
|
||||
|
||||
assert.deepStrictEqual(sessionConfigUpdates, [
|
||||
{ session: 'copilot:/test-session-1', patch: { mode: 'interactive' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('session.mode_changed → autopilot translates to mode=interactive + autoApprove=autopilot', async () => {
|
||||
// The SDK has a first-class `autopilot` mode but AHP exposes it
|
||||
// as the `autopilot` value on the orthogonal `autoApprove` axis.
|
||||
// The translation is contained in the Copilot agent.
|
||||
const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables);
|
||||
|
||||
mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'autopilot' } as SessionEventPayload<'session.mode_changed'>['data']);
|
||||
|
||||
assert.deepStrictEqual(sessionConfigUpdates, [
|
||||
{ session: 'copilot:/test-session-1', patch: { mode: 'interactive', autoApprove: 'autopilot' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('session.mode_changed for unsupported mode is ignored', async () => {
|
||||
const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables);
|
||||
|
||||
mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'shell' } as SessionEventPayload<'session.mode_changed'>['data']);
|
||||
|
||||
assert.strictEqual(sessionConfigUpdates.length, 0);
|
||||
});
|
||||
|
||||
// ---- autopilot fast-path -------------------------------------------
|
||||
|
||||
test('handleExitPlanModeRequest auto-accepts when autoApprove=autopilot (recommended action)', async () => {
|
||||
const { session, progressEvents } = await createAgentSession(disposables, {
|
||||
configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' },
|
||||
});
|
||||
|
||||
const response = await session.handleExitPlanModeRequest(planRequestParams({
|
||||
actions: ['autopilot', 'interactive', 'exit_only'],
|
||||
recommendedAction: 'autopilot',
|
||||
}));
|
||||
|
||||
assert.deepStrictEqual(response, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true });
|
||||
// User-input request should NOT be surfaced to the client.
|
||||
assert.strictEqual(progressEvents.filter(e => e.type === 'user_input_request').length, 0);
|
||||
});
|
||||
|
||||
test('handleExitPlanModeRequest auto-accepts with priority order when no recommended action available', async () => {
|
||||
const { session } = await createAgentSession(disposables, {
|
||||
configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' },
|
||||
});
|
||||
|
||||
// SDK proposes a recommended action that's NOT in the offered set —
|
||||
// fall back to the priority order (autopilot > autopilot_fleet >
|
||||
// interactive > exit_only).
|
||||
const response = await session.handleExitPlanModeRequest(planRequestParams({
|
||||
actions: ['interactive', 'exit_only'],
|
||||
recommendedAction: 'autopilot_fleet',
|
||||
}));
|
||||
|
||||
assert.deepStrictEqual(response, { approved: true, selectedAction: 'interactive' });
|
||||
});
|
||||
|
||||
test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => {
|
||||
const { session, waitForProgress } = await createAgentSession(disposables, {
|
||||
configValues: { [SessionConfigKey.AutoApprove]: 'default' },
|
||||
});
|
||||
|
||||
const responsePromise = session.handleExitPlanModeRequest(planRequestParams());
|
||||
|
||||
// The user-input request fires — the user must respond.
|
||||
const event = await waitForProgress(e => e.type === 'user_input_request');
|
||||
session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Decline);
|
||||
await responsePromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -845,7 +845,7 @@ export type LegacyMockEvent =
|
||||
invocationMessage: StringOrMarkdown;
|
||||
toolInput?: string;
|
||||
confirmationTitle?: StringOrMarkdown;
|
||||
permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool';
|
||||
permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory';
|
||||
permissionPath?: string;
|
||||
edits?: { items: FileEdit[] };
|
||||
}
|
||||
|
||||
+61
-16
@@ -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<string, Sessio
|
||||
}
|
||||
|
||||
function getMarkdownResponseText(c: TestProtocolClient): string {
|
||||
return c.receivedNotifications(n => 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<string>();
|
||||
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',
|
||||
|
||||
@@ -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<string>());
|
||||
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<IModePickerItem>[] = 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<IModePickerItem> = {
|
||||
onSelect: item => {
|
||||
this._actionWidgetService.hide();
|
||||
ctx.provider.setSessionConfigValue(ctx.sessionId, SessionConfigKey.Mode, item.value)
|
||||
.catch(() => { /* best-effort */ });
|
||||
},
|
||||
onHide: () => triggerElement.focus(),
|
||||
};
|
||||
|
||||
this._actionWidgetService.show<IModePickerItem>(
|
||||
'agentHostModePicker',
|
||||
false,
|
||||
actionItems,
|
||||
delegate,
|
||||
this._triggerElement,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
getAriaLabel: i => i.label ?? '',
|
||||
getWidgetAriaLabel: () => localize('agentHostModePicker.ariaLabel', "Agent Mode Picker"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser
|
||||
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
|
||||
|
||||
const REQUIRED_AUTO_APPROVE_VALUE = 'default';
|
||||
const REQUIRED_MODE_VALUE = 'interactive';
|
||||
|
||||
/**
|
||||
* Returns `true` when an `autoApprove` session-config property uses the
|
||||
@@ -137,3 +138,23 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when a `mode` session-config property uses the shape the
|
||||
* dedicated agent-host mode picker expects: a string enum that contains
|
||||
* at least `interactive`.
|
||||
*
|
||||
* Callers use this to decide whether to render the dedicated mode picker
|
||||
* (with mode-specific icons and behavior) or fall back to the generic
|
||||
* per-property picker.
|
||||
*/
|
||||
export function isWellKnownModeSchema(schema: SessionConfigPropertySchema): boolean {
|
||||
if (schema.type !== 'string' || !Array.isArray(schema.enum) || schema.enum.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!schema.enum.includes(REQUIRED_MODE_VALUE)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> { }
|
||||
});
|
||||
|
||||
|
||||
// ---- 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<void> { }
|
||||
});
|
||||
|
||||
|
||||
registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored);
|
||||
|
||||
+33
-1
@@ -12,7 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/
|
||||
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ResolveSessionConfigResult, SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js';
|
||||
import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js';
|
||||
import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownModeSchema } from '../../../browser/agentHost/agentHostPermissionPickerDelegate.js';
|
||||
import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js';
|
||||
import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js';
|
||||
import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js';
|
||||
@@ -210,3 +210,35 @@ suite('isWellKnownAutoApproveSchema', () => {
|
||||
assert.strictEqual(isWellKnownAutoApproveSchema(schema({ enum: [] })), false);
|
||||
});
|
||||
});
|
||||
|
||||
suite('isWellKnownModeSchema', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function schema(overrides: Partial<SessionConfigPropertySchema> = {}): SessionConfigPropertySchema {
|
||||
return {
|
||||
title: 'Agent Mode',
|
||||
description: 'desc',
|
||||
type: 'string',
|
||||
enum: ['interactive', 'plan'],
|
||||
...overrides,
|
||||
} as SessionConfigPropertySchema;
|
||||
}
|
||||
|
||||
test('matches the canonical two-value enum', () => {
|
||||
assert.strictEqual(isWellKnownModeSchema(schema()), true);
|
||||
});
|
||||
|
||||
test('matches a subset that still contains "interactive"', () => {
|
||||
assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['interactive'] })), true);
|
||||
});
|
||||
|
||||
test('rejects schemas missing the required "interactive" value', () => {
|
||||
assert.strictEqual(isWellKnownModeSchema(schema({ enum: ['plan'] })), false);
|
||||
});
|
||||
|
||||
test('rejects non-string types and missing/empty enums', () => {
|
||||
assert.strictEqual(isWellKnownModeSchema(schema({ type: 'number' as 'string' })), false);
|
||||
assert.strictEqual(isWellKnownModeSchema(schema({ enum: undefined })), false);
|
||||
assert.strictEqual(isWellKnownModeSchema(schema({ enum: [] })), false);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user