diff --git a/patches/00-brand-remove-branding.patch b/patches/00-brand-remove-branding.patch index 8be3244..4ec7494 100644 --- a/patches/00-brand-remove-branding.patch +++ b/patches/00-brand-remove-branding.patch @@ -136,7 +136,7 @@ index 1be4d0e1..d60d8357 100644 -export const redundantImplicitActivationEvent = l10n.t("This activation event can be removed as VS Code generates these automatically from your package.json contribution declarations."); +export const redundantImplicitActivationEvent = l10n.t("This activation event can be removed as !!APP_NAME!! generates these automatically from your package.json contribution declarations."); diff --git a/extensions/extension-editing/src/extensionLinter.ts b/extensions/extension-editing/src/extensionLinter.ts -index 00b153a4..b81b6428 100644 +index a6ab287a..dc928226 100644 --- a/extensions/extension-editing/src/extensionLinter.ts +++ b/extensions/extension-editing/src/extensionLinter.ts @@ -32,7 +32,7 @@ const embeddedSvgsNotValid = l10n.t("Embedded SVGs are not a valid image source. @@ -145,9 +145,9 @@ index 00b153a4..b81b6428 100644 const relativeBadgeUrlRequiresHttpsRepository = l10n.t("Relative badge URLs require a repository with HTTPS protocol to be specified in this package.json."); -const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the VS Code team."); +const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the !!APP_NAME!! team."); + const apiProposalVersionNotSupported = l10n.t("API proposal versions are no longer supported. Remove the '@' suffix."); const starActivation = l10n.t("Using '*' activation is usually a bad idea as it impacts performance."); - const parsingErrorHeader = l10n.t("Error parsing the when-clause:"); diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 147a75f9..33aeb0d4 100644 --- a/extensions/git/package.nls.json @@ -548,7 +548,7 @@ index 032dd35d..0b893063 100644 "mimeTypes": [ "image/gif", diff --git a/extensions/npm/package.nls.json b/extensions/npm/package.nls.json -index 1235a551..451c90f0 100644 +index 5c77708d..8a988891 100644 --- a/extensions/npm/package.nls.json +++ b/extensions/npm/package.nls.json @@ -1,6 +1,6 @@ @@ -640,7 +640,7 @@ index a697f8c0..f55ecc02 100644 + "walkthroughs.nodejsWelcome.learnMoreAboutJs.altText": "Learn more about JavaScript and Node.js in !!APP_NAME!!." } diff --git a/extensions/typescript-language-features/src/tsServer/versionManager.ts b/extensions/typescript-language-features/src/tsServer/versionManager.ts -index 8d99637d..5114c543 100644 +index 1154a29c..a42a1d25 100644 --- a/extensions/typescript-language-features/src/tsServer/versionManager.ts +++ b/extensions/typescript-language-features/src/tsServer/versionManager.ts @@ -110,7 +110,7 @@ export class TypeScriptVersionManager extends Disposable { @@ -822,10 +822,10 @@ index a351aa77..8d4e933d 100644 } return Promise.resolve(extractDir); diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts -index 373c8809..a8af4574 100644 +index 5c9b4785..dfe21380 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts -@@ -209,7 +209,7 @@ export function activate(context: vscode.ExtensionContext) { +@@ -213,7 +213,7 @@ export function activate(context: vscode.ExtensionContext) { let serverLocation = env['VSCODE_REMOTE_SERVER_PATH']; // support environment variable to specify location of server on disk if (!serverLocation) { const serverBin = path.join(remoteDataDir, 'bin'); @@ -835,10 +835,10 @@ index 373c8809..a8af4574 100644 } diff --git a/src/main.ts b/src/main.ts -index e92fe6af..48d1734c 100644 +index f70cf87e..a3aa84bf 100644 --- a/src/main.ts +++ b/src/main.ts -@@ -415,16 +415,16 @@ function createDefaultArgvConfigSync(argvConfigPath: string): void { +@@ -419,16 +419,16 @@ function createDefaultArgvConfigSync(argvConfigPath: string): void { // Default argv content const defaultArgvConfigContent = [ @@ -859,10 +859,10 @@ index e92fe6af..48d1734c 100644 '}' ]; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts -index 9a1041f1..50934b0c 100644 +index 811673d0..6860fa57 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts -@@ -604,7 +604,7 @@ export class CodeApplication extends Disposable { +@@ -608,7 +608,7 @@ export class CodeApplication extends Disposable { } async startup(): Promise { @@ -928,22 +928,6 @@ index 6eefd716..4e8cb6af 100644 } } -diff --git a/src/vs/platform/extensions/common/extensionValidator.ts b/src/vs/platform/extensions/common/extensionValidator.ts -index 6346b9d4..7b2c68de 100644 ---- a/src/vs/platform/extensions/common/extensionValidator.ts -+++ b/src/vs/platform/extensions/common/extensionValidator.ts -@@ -378,9 +378,9 @@ export function areApiProposalsCompatible(apiProposals: string[], arg1?: string[ - if (incompatibleProposals.length) { - if (notices) { - if (incompatibleProposals.length === 1) { -- notices.push(nls.localize('apiProposalMismatch1', "This extension is using the API proposal '{0}' that is not compatible with the current version of VS Code.", incompatibleProposals[0])); -+ notices.push(nls.localize('apiProposalMismatch1', "This extension is using the API proposal '{0}' that is not compatible with the current version of !!APP_NAME!!.", incompatibleProposals[0])); - } else { -- notices.push(nls.localize('apiProposalMismatch2', "This extension is using the API proposals {0} and '{1}' that are not compatible with the current version of VS Code.", -+ notices.push(nls.localize('apiProposalMismatch2', "This extension is using the API proposals {0} and '{1}' that are not compatible with the current version of !!APP_NAME!!.", - incompatibleProposals.slice(0, incompatibleProposals.length - 1).map(p => `'${p}'`).join(', '), - incompatibleProposals[incompatibleProposals.length - 1])); - } diff --git a/src/vs/platform/externalTerminal/node/externalTerminalService.ts b/src/vs/platform/externalTerminal/node/externalTerminalService.ts index e7cf3f54..ac295f0a 100644 --- a/src/vs/platform/externalTerminal/node/externalTerminalService.ts @@ -1102,7 +1086,7 @@ index 0d713847..33749645 100644 disposables.add(extHostNotebooks.onDidChangeActiveNotebookEditor(() => count += 1)); diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts -index cdc59743..b4e742f9 100644 +index ee85640c..bddb01c5 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -688,7 +688,7 @@ class PolicyDiagnosticsAction extends Action2 { @@ -1141,10 +1125,10 @@ index e342f838..98e500bc 100644 created = true; } diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts -index eeddba00..fd4e677f 100644 +index f2a3b35e..1fecb536 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts -@@ -827,7 +827,7 @@ const registry = Registry.as(ConfigurationExtensions.Con +@@ -833,7 +833,7 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('rootNameShort', "`${rootNameShort}`: shortened name of the workspace without suffixes (e.g. myFolder, myRemoteFolder or myWorkspace)."), localize('rootPath', "`${rootPath}`: file path of the opened workspace or folder (e.g. /Users/Development/myWorkspace)."), localize('profileName', "`${profileName}`: name of the profile in which the workspace is opened (e.g. Data Science (Profile)). Ignored if default profile is used."), @@ -1154,7 +1138,7 @@ index eeddba00..fd4e677f 100644 localize('dirty', "`${dirty}`: an indicator for when the active editor has unsaved changes."), localize('focusedView', "`${focusedView}`: the name of the view that is currently focused."), diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts -index c650e128..802a5f21 100644 +index 51a4a3f2..0e3b27dc 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -38,7 +38,7 @@ export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSyst @@ -1167,10 +1151,10 @@ index c650e128..802a5f21 100644 //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -index f97bccf1..bb326157 100644 +index 883dab9e..3fcf5140 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -@@ -734,7 +734,7 @@ configurationRegistry.registerConfiguration({ +@@ -802,7 +802,7 @@ configurationRegistry.registerConfiguration({ ], enumDescriptions: [ nls.localize('chat.mcp.access.none', "No access to MCP servers."), @@ -1179,7 +1163,7 @@ index f97bccf1..bb326157 100644 nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") ], default: McpAccessValue.All, -@@ -761,7 +761,7 @@ configurationRegistry.registerConfiguration({ +@@ -829,7 +829,7 @@ configurationRegistry.registerConfiguration({ key: 'chat.mcp.access.none', value: nls.localize('chat.mcp.access.none', "No access to MCP servers."), }, { @@ -1265,10 +1249,10 @@ index 0b9ced32..b5ed7134 100644 }, 'preLaunchTask': { diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts -index 695b1ed1..d886ff59 100644 +index 0c0965d3..f4b0faef 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts -@@ -380,24 +380,24 @@ CommandsRegistry.registerCommand({ +@@ -417,24 +417,24 @@ CommandsRegistry.registerCommand({ { name: 'options', description: '(optional) Options for installing the extension. Object with the following properties: ' + @@ -1297,7 +1281,7 @@ index 695b1ed1..d886ff59 100644 default: false }, 'justification': { -@@ -922,8 +922,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi +@@ -959,8 +959,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (requireReload) { notificationService.prompt( Severity.Info, @@ -1309,7 +1293,7 @@ index 695b1ed1..d886ff59 100644 label: localize('InstallVSIXAction.reloadNow', "Reload Now"), run: () => hostService.reload() diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts -index 37b94354..3d1916fd 100644 +index 452db88d..282c9b5a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -107,7 +107,7 @@ export class PromptExtensionInstallFailureAction extends Action { @@ -1349,10 +1333,10 @@ index 37b94354..3d1916fd 100644 const message = new MarkdownString(localize('deprecated tooltip', "This extension is deprecated as it is no longer being maintained.")); if (this.extension.deprecationInfo.additionalInfo) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts -index 43d2b947..2a89a908 100644 +index a58d77d1..cc5004c7 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts -@@ -478,7 +478,7 @@ export class Extension implements IExtension { +@@ -477,7 +477,7 @@ export class Extension implements IExtension { if (this.type === ExtensionType.System) { return Promise.resolve(`# ${this.displayName || this.name} @@ -1361,7 +1345,7 @@ index 43d2b947..2a89a908 100644 ## Features ${this.description} `); -@@ -516,7 +516,7 @@ ${this.description} +@@ -515,7 +515,7 @@ ${this.description} } if (this.type === ExtensionType.System) { @@ -1578,7 +1562,7 @@ index c43718e3..ca17a601 100644 fi # Remove any leftover Python activation env vars. diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish -index 6cff7487..37c8342b 100644 +index f87fc762..15ffe678 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -92,7 +92,7 @@ if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD @@ -2026,7 +2010,7 @@ index 168a3d01..fd76dda2 100644 }, { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts -index ad276fc2..90e1e643 100644 +index c1fb3e12..4bbf4eb3 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -180,7 +180,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi @@ -2079,7 +2063,7 @@ index bdd30bf2..cc397e9c 100644 - Work with version control by pressing kb(workbench.view.scm). Understand how to stage, commit, change branches, and view diffs and more by reviewing the [version control documentation](https://code.visualstudio.com/docs/editor/versioncontrol) - Browse thousands of extensions in our integrated gallery by pressing kb(workbench.view.extensions). The [documentation](https://code.visualstudio.com/docs/editor/extension-gallery) will show you how to see the most popular extensions, disable installed ones and more. diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts -index 64b8586a..20b82d7c 100644 +index 6cccbced..1e404b92 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -799,7 +799,7 @@ Registry.as(ConfigurationExtensions.Configuration) @@ -2101,10 +2085,10 @@ index 64b8586a..20b82d7c 100644 scope: ConfigurationScope.APPLICATION } diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts -index 885cbf9b..4d008991 100644 +index 9db62d8f..26208123 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts -@@ -450,11 +450,11 @@ import product from '../../platform/product/common/product.js'; +@@ -451,11 +451,11 @@ import product from '../../platform/product/common/product.js'; }, 'disable-chromium-sandbox': { type: 'boolean', @@ -2216,7 +2200,7 @@ index 09dd590e..d3fc08d7 100644 } } diff --git a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts -index 2b6104a7..1eb63831 100644 +index 4340604b..9ea3a495 100644 --- a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts +++ b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts @@ -165,7 +165,7 @@ export class NativeExtensionService extends AbstractExtensionService implements diff --git a/patches/00-build-disable-mangle.patch b/patches/00-build-disable-mangle.patch index 4190445..0296aaa 100644 --- a/patches/00-build-disable-mangle.patch +++ b/patches/00-build-disable-mangle.patch @@ -1,8 +1,10 @@ diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts -index 948c6b4..66ecdd3 100644 +index 32e3e25e..6e9396d2 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts -@@ -131,27 +131,3 @@ export function compileTask(src: string, out: string, build: boolean, options: { +@@ -137,31 +137,7 @@ export function compileTask(src: string, out: string, build: boolean, options: { + generator.execute(); + } - // mangle: TypeScript to TypeScript - let mangleStream = es.through(); @@ -27,6 +29,8 @@ index 948c6b4..66ecdd3 100644 - }); - } - - return srcPipe + const emit = util.streamToPromise(srcPipe - .pipe(mangleStream) .pipe(generator.stream) + .pipe(compile()) + .pipe(gulp.dest(out))); diff --git a/patches/00-copilot-fix-action-condition.patch b/patches/00-copilot-fix-action-condition.patch index bfa0b75..11a3d54 100644 --- a/patches/00-copilot-fix-action-condition.patch +++ b/patches/00-copilot-fix-action-condition.patch @@ -1,8 +1,8 @@ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts -index dd222258..1c1e7201 100644 +index 7f7d1479..87b739be 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts -@@ -1166,7 +1166,7 @@ export function registerChatActions() { +@@ -1202,7 +1202,7 @@ export function registerChatActions() { id: 'workbench.action.chat.configureCodeCompletions', title: localize2('configureCompletions', "Configure Inline Suggestions..."), precondition: ContextKeyExpr.and( @@ -12,7 +12,7 @@ index dd222258..1c1e7201 100644 ChatContextKeys.Setup.untrusted.negate() ), diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts -index c7020b52..bb69de4f 100644 +index 00c445ab..f1e87408 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -317,7 +317,8 @@ class AttachSelectionToChatAction extends Action2 { @@ -26,9 +26,10 @@ index c7020b52..bb69de4f 100644 }, { id: MenuId.InlineChatEditorAffordance, diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +index 3fcf5140..82d4066f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -@@ -1672,7 +1672,7 @@ configurationRegistry.registerConfiguration({ +@@ -1757,7 +1757,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.AIDisabled]: { type: 'boolean', description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), @@ -50,10 +51,10 @@ index 8fb472b3..16cff2b1 100644 ChatContextKeys.Setup.disabledInWorkspace.negate(), ), diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts -index 34449705..86277e78 100644 +index ac91175c..8fd319a7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts -@@ -229,12 +229,13 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr +@@ -230,12 +230,13 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr title: ChatSetupTriggerAction.CHAT_SETUP_ACTION_LABEL, category: CHAT_CATEGORY, f1: true, @@ -73,7 +74,7 @@ index 34449705..86277e78 100644 ) }); } -@@ -367,6 +368,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr +@@ -368,6 +369,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr id: MenuId.AccountsContext, group: '2_copilot', when: ContextKeyExpr.and( @@ -81,14 +82,15 @@ index 34449705..86277e78 100644 ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ChatContextKeys.Setup.completed.negate(), -@@ -399,5 +401,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr +@@ -400,6 +402,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr id: MenuId.TitleBarAdjacentCenter, order: 0, // same position as the update button when: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), IsWebContext.negate(), ChatContextKeys.Entitlement.signedOut, -@@ -430,6 +433,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr + ChatEntitlementContextKeys.hasByokModels.negate(), +@@ -449,6 +452,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr category: localize2('chat.category', 'Chat'), f1: true, precondition: ContextKeyExpr.and( @@ -96,7 +98,7 @@ index 34449705..86277e78 100644 ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ContextKeyExpr.or( -@@ -489,6 +493,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr +@@ -514,6 +518,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr category: localize2('chat.category', 'Chat'), f1: true, precondition: ContextKeyExpr.and( @@ -104,15 +106,15 @@ index 34449705..86277e78 100644 ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ContextKeyExpr.or( -@@ -585,6 +590,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - registerGenerateCodeCommand('chat.internal.codeReview.run', 'github.copilot.chat.codeReview.run'); +@@ -608,6 +613,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr + registerGenerateCodeCommand('chat.internal.review', 'github.copilot.chat.review'); const internalGenerateCodeContext = ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ChatContextKeys.Setup.completed.negate(), -@@ -813,7 +819,11 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo +@@ -873,7 +879,11 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo title: ChatSetupHideAction.TITLE, f1: true, category: CHAT_CATEGORY, @@ -140,7 +142,7 @@ index 7e5d8f63..e949b389 100644 export class StartSessionAction extends Action2 { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts -index c47eeb39..dcc00326 100644 +index c983379d..bc16fdc5 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -543,7 +543,7 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc @@ -199,10 +201,10 @@ index 6ee2a32c..c5cdc792 100644 ChatContextKeys.Setup.disabledInWorkspace.negate(), ChatContextKeys.Setup.completed.negate(), diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts -index 6adf03c6..38a6d42c 100644 +index 63642985..5dee8dca 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts -@@ -846,6 +846,7 @@ registerAction2(class extends Action2 { +@@ -847,6 +847,7 @@ registerAction2(class extends Action2 { menu: { id: MenuId.SCMInputBox, when: ContextKeyExpr.and( diff --git a/patches/20-keymap-use-custom-lib.patch b/patches/20-keymap-use-custom-lib.patch index ecd1331..bd3adeb 100644 --- a/patches/20-keymap-use-custom-lib.patch +++ b/patches/20-keymap-use-custom-lib.patch @@ -1,5 +1,5 @@ diff --git a/.npmrc b/.npmrc -index 8c21e58ef14..25a93f408ad 100644 +index 8c21e58e..25a93f40 100644 --- a/.npmrc +++ b/.npmrc @@ -4,5 +4,6 @@ ms_build_id="14159160" @@ -10,7 +10,7 @@ index 8c21e58ef14..25a93f408ad 100644 legacy-peer-deps="true" timeout=180000 diff --git a/build/.moduleignore b/build/.moduleignore -index 90780acffa9..b226373f632 100644 +index 8eb8da15..da57545b 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -63,11 +63,11 @@ fsevents/test/** @@ -31,10 +31,10 @@ index 90780acffa9..b226373f632 100644 native-is-elevated/binding.gyp native-is-elevated/build/** diff --git a/eslint.config.js b/eslint.config.js -index fda37713fee..a2150a119a6 100644 +index 51b085ee..938419e2 100644 --- a/eslint.config.js +++ b/eslint.config.js -@@ -1530,7 +1530,7 @@ export default defineConfig( +@@ -1561,7 +1561,7 @@ export default defineConfig( 'inspector', 'minimist', 'node:module', @@ -44,7 +44,7 @@ index fda37713fee..a2150a119a6 100644 'node-pty', 'os', diff --git a/package-lock.json b/package-lock.json -index 38d50ffa4de..cad1ae14f2c 100644 +index 81d315f0..1d99e154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ @@ -52,10 +52,10 @@ index 38d50ffa4de..cad1ae14f2c 100644 "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", + "@vscodium/native-keymap": "3.3.7-258424", - "@xterm/addon-clipboard": "^0.3.0-beta.220", - "@xterm/addon-image": "^0.10.0-beta.220", - "@xterm/addon-ligatures": "^0.11.0-beta.220", -@@ -59,7 +60,6 @@ + "@xterm/addon-clipboard": "^0.3.0-beta.285", + "@xterm/addon-image": "^0.10.0-beta.285", + "@xterm/addon-ligatures": "^0.11.0-beta.285", +@@ -60,7 +61,6 @@ "kerberos": "2.1.1", "minimist": "^1.2.8", "native-is-elevated": "0.9.0", @@ -63,7 +63,7 @@ index 38d50ffa4de..cad1ae14f2c 100644 "node-addon-api": "^6.0.0", "node-pty": "^1.2.0-beta.13", "open": "^10.1.2", -@@ -4466,6 +4466,13 @@ +@@ -4496,6 +4496,13 @@ "hasInstallScript": true, "license": "MIT" }, @@ -77,7 +77,7 @@ index 38d50ffa4de..cad1ae14f2c 100644 "node_modules/@webgpu/types": { "version": "0.1.66", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", -@@ -14114,9 +14121,10 @@ +@@ -14157,9 +14164,10 @@ } }, "node_modules/napi-build-utils": { @@ -91,7 +91,7 @@ index 38d50ffa4de..cad1ae14f2c 100644 }, "node_modules/native-is-elevated": { "version": "0.9.0", -@@ -14125,13 +14133,6 @@ +@@ -14168,13 +14176,6 @@ "hasInstallScript": true, "license": "MIT" }, @@ -105,7 +105,7 @@ index 38d50ffa4de..cad1ae14f2c 100644 "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", -@@ -15526,16 +15527,17 @@ +@@ -15580,16 +15581,17 @@ "license": "ISC" }, "node_modules/prebuild-install": { @@ -128,10 +128,10 @@ index 38d50ffa4de..cad1ae14f2c 100644 "pump": "^3.0.0", "rc": "^1.2.7", diff --git a/package.json b/package.json -index a43d079633a..508acdfb4dd 100644 +index 36485330..2a5830cd 100644 --- a/package.json +++ b/package.json -@@ -141,7 +141,7 @@ +@@ -144,7 +144,7 @@ "kerberos": "2.1.1", "minimist": "^1.2.8", "native-is-elevated": "0.9.0", @@ -141,7 +141,7 @@ index a43d079633a..508acdfb4dd 100644 "node-pty": "^1.2.0-beta.13", "open": "^10.1.2", diff --git a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts -index 9b11a712c25..975c312747a 100644 +index 9b11a712..975c3127 100644 --- a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts +++ b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts @@ -42,12 +42,12 @@ flakySuite('Native Modules (all platforms)', () => { @@ -162,7 +162,7 @@ index 9b11a712c25..975c312747a 100644 test('@vscode/native-watchdog', async () => { diff --git a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts -index 8950ce2184a..f31cea623bc 100644 +index 8950ce21..f31cea62 100644 --- a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts +++ b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts @@ -3,7 +3,7 @@ diff --git a/patches/21-policy-use-custom-lib.patch b/patches/21-policy-use-custom-lib.patch index 232590b..88a25bb 100644 --- a/patches/21-policy-use-custom-lib.patch +++ b/patches/21-policy-use-custom-lib.patch @@ -1,5 +1,5 @@ diff --git a/build/.moduleignore b/build/.moduleignore -index b226373f..7750bbf0 100644 +index da57545b..5056155d 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -151,13 +151,15 @@ vsda/** @@ -39,10 +39,10 @@ index 7f650ba7..3e3521fd 100644 ` `, ` `, diff --git a/build/lib/policies/render.ts b/build/lib/policies/render.ts -index 47b485d1..c314d031 100644 +index bd5958cb..b45f99b9 100644 --- a/build/lib/policies/render.ts +++ b/build/lib/policies/render.ts -@@ -47,7 +47,7 @@ export function renderADMX(regKey: string, versions: string[], categories: Categ +@@ -54,7 +54,7 @@ export function renderADMX(regKey: string, versions: string[], categories: Categ return ` @@ -51,7 +51,7 @@ index 47b485d1..c314d031 100644 -@@ -165,7 +165,7 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, +@@ -172,7 +172,7 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, pfm_default @@ -60,7 +60,7 @@ index 47b485d1..c314d031 100644 pfm_name PayloadOrganization pfm_title -@@ -183,11 +183,11 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, +@@ -190,11 +190,11 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, pfm_app_url @@ -74,7 +74,7 @@ index 47b485d1..c314d031 100644 pfm_domain ${bundleIdentifier} pfm_format_version -@@ -253,13 +253,13 @@ ${policyEntries} +@@ -260,13 +260,13 @@ ${policyEntries} PayloadDescription @@ -91,10 +91,10 @@ index 47b485d1..c314d031 100644 Configuration PayloadUUID diff --git a/eslint.config.js b/eslint.config.js -index a6dd3274..96b0db3b 100644 +index 938419e2..f3a103ca 100644 --- a/eslint.config.js +++ b/eslint.config.js -@@ -1512,7 +1512,7 @@ export default defineConfig( +@@ -1542,7 +1542,7 @@ export default defineConfig( '@vscode/ripgrep-universal', '@vscode/iconv-lite-umd', '@vscode/native-watchdog', @@ -104,33 +104,35 @@ index a6dd3274..96b0db3b 100644 '@vscode/spdlog', '@vscode/windows-process-tree', diff --git a/package-lock.json b/package-lock.json -index ab7a6f15..3a191a0f 100644 +index 1d99e154..4acb64a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "@vscode/diff": "0.0.2-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", -- "@vscode/policy-watcher": "^1.3.2", +- "@vscode/policy-watcher": "^1.4.0", "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", -@@ -42,6 +41,7 @@ - "@vscode/windows-process-tree": "^0.7.0", - "@vscode/windows-registry": "^1.2.0", - "@vscodium/native-keymap": "3.3.7-258424", -+ "@vscodium/policy-watcher": "^1.3.2-252465", - "@xterm/addon-clipboard": "^0.3.0-beta.220", - "@xterm/addon-image": "^0.10.0-beta.220", - "@xterm/addon-ligatures": "^0.11.0-beta.220", -@@ -4103,26 +4103,6 @@ +@@ -74,7 +73,8 @@ + "ws": "^8.19.0", + "yauzl": "^3.3.1", + "yazl": "^2.4.3", +- "zod": "^3.25.76" ++ "zod": "^3.25.76", ++ "@vscodium/policy-watcher": "^1.3.2-252465" + }, + "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.169", +@@ -4120,26 +4120,6 @@ "hasInstallScript": true, "license": "MIT" }, - "node_modules/@vscode/policy-watcher": { -- "version": "1.3.7", -- "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", -- "integrity": "sha512-OvIczTbtGLZs7YU0ResbjM0KEB2ORBnlJ4ICxaB9fKHNVBwNVp4i2qIkDQGp3UBGtu7P8/+eg4/ZKk2oJGFcug==", +- "version": "1.4.0", +- "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.4.0.tgz", +- "integrity": "sha512-QKTLV/UtV0HH5AJELfN5D3Jcxj2hB9CYT9GtG334I8bU5TdusaSGYSpDjumZBGWO2YxwVYpBNytA7mq/I3ZtzA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { @@ -150,7 +152,7 @@ index ab7a6f15..3a191a0f 100644 "node_modules/@vscode/proxy-agent": { "version": "0.42.0", "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", -@@ -4459,6 +4439,46 @@ +@@ -4503,6 +4483,26 @@ "hasInstallScript": true, "license": "MIT" }, @@ -168,26 +170,6 @@ index ab7a6f15..3a191a0f 100644 + "node_modules/@vscodium/policy-watcher/node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", -+ "integrity": "sha512-uH63yYpyBQOzHTSDcN+yYRsetH8U4hd6Bb+kZTZRRzAGiWcUOMdsWlqkOICUqWy/zUWxlDmpywXmH4M0fNu5SQ==", -+ "license": "MIT", -+ "engines": { -+ "node": "^18 || ^20 || >= 21" -+ } -+ }, -+ "node_modules/@vscodium/policy-watcher": { -+ "version": "1.3.2-255408", -+ "resolved": "https://registry.npmjs.org/@vscodium/policy-watcher/-/policy-watcher-1.3.2-255408.tgz", -+ "integrity": "sha512-0KERmB+VkSz9hvFWEDGalCpxQ9+qjLaUazXMBkzWQ9SjKPaD6zU9u6wA4/OUu816JnvCFEeJYEe9WcDZPnKQ1w==", -+ "hasInstallScript": true, -+ "license": "MIT", -+ "dependencies": { -+ "bindings": "^1.5.0", -+ "node-addon-api": "^8.2.0" -+ } -+ }, -+ "node_modules/@vscodium/policy-watcher/node_modules/node-addon-api": { -+ "version": "8.6.0", -+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { @@ -198,14 +180,14 @@ index ab7a6f15..3a191a0f 100644 "version": "0.1.66", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", diff --git a/package.json b/package.json -index 433f446d..942259cc 100644 +index 2a5830cd..9a425766 100644 --- a/package.json +++ b/package.json -@@ -108,7 +108,7 @@ +@@ -113,7 +113,7 @@ "@vscode/diff": "0.0.2-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", -- "@vscode/policy-watcher": "^1.3.2", +- "@vscode/policy-watcher": "^1.4.0", + "@vscodium/policy-watcher": "^1.3.2-252465", "@vscode/proxy-agent": "^0.42.0", "@vscode/ripgrep-universal": "^1.18.0", @@ -358,3 +340,40 @@ index 6039dbb7..6fe3fe47 100644 this._onDidPolicyChange(update); c(); }); +diff --git a/src/vs/platform/policy/node/copilotManagedSettingsService.ts b/src/vs/platform/policy/node/copilotManagedSettingsService.ts +index 5b76979d..d8223ab9 100644 +--- a/src/vs/platform/policy/node/copilotManagedSettingsService.ts ++++ b/src/vs/platform/policy/node/copilotManagedSettingsService.ts +@@ -11,7 +11,7 @@ import { IManagedSettingsPolicyDefinitions, ManagedSettingsData } from '../../.. + import { ILogService } from '../../log/common/log.js'; + import { collectManagedSettingsDefinitions, ICopilotManagedSettingsService } from '../common/copilotManagedSettings.js'; + import { PolicyDefinition, PolicyValue } from '../common/policy.js'; +-import type { Watcher } from '@vscode/policy-watcher'; ++import type { Watcher } from '@vscodium/policy-watcher'; + + export interface ICopilotPolicyWatcherOptions { + readonly registryPath?: string; +@@ -89,7 +89,22 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo + return; + } + +- const { createWatcher } = this.watcherFactory ? { createWatcher: this.watcherFactory } : (await import('@vscode/policy-watcher') as { createWatcher: CopilotPolicyWatcherFactory }); ++ let createWatcher: CopilotPolicyWatcherFactory; ++ if (this.watcherFactory) { ++ createWatcher = this.watcherFactory; ++ } else { ++ const policyWatcher = await import('@vscodium/policy-watcher'); ++ // `@vscodium/policy-watcher`'s `createWatcher` takes a leading vendor name ++ // and no options argument, unlike upstream `@vscode/policy-watcher`; adapt ++ // it to this service's CopilotPolicyWatcherFactory shape. ++ const createNativeWatcher = policyWatcher.createWatcher as unknown as ( ++ vendorName: string, ++ productName: string, ++ policies: Record, ++ onDidChange: (update: Record) => void, ++ ) => Watcher; ++ createWatcher = (productName, policies, onDidChange) => createNativeWatcher('BradfordCode', productName, policies, onDidChange); ++ } + await this.throttler.queue(() => new Promise((c, e) => { + try { + this.logService.trace(`Creating Copilot managed-settings watcher for productName ${this.productName}`); diff --git a/patches/50-build-improve-gulp-tasks.patch b/patches/50-build-improve-gulp-tasks.patch index af7dc8d..4806970 100644 --- a/patches/50-build-improve-gulp-tasks.patch +++ b/patches/50-build-improve-gulp-tasks.patch @@ -10,7 +10,7 @@ index 88c66de6..386bbb0b 100644 +import { compileBuildWithoutManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; - import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; + import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; @@ -655,26 +655,46 @@ BUILD_TARGETS.forEach(buildTarget => { minified && useCdnSourceMapsForPackagingTasks ? `${sourceMappingURLBase}/core` : undefined ) diff --git a/patches/51-ext-copilot-remove-it.patch b/patches/51-ext-copilot-remove-it.patch index 45ca345..904b0f3 100644 --- a/patches/51-ext-copilot-remove-it.patch +++ b/patches/51-ext-copilot-remove-it.patch @@ -1,5 +1,5 @@ diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts -index beb7ba848fd..3b0be1b7692 100644 +index 62c30da5..cac6a2e4 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -23,13 +23,12 @@ import glob from 'glob'; @@ -13,23 +13,24 @@ index beb7ba848fd..3b0be1b7692 100644 import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls, fetchGithub } from './lib/fetch.ts'; --import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +-import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; + import { readAgentSdkResults } from './agent-sdk/common.ts'; - const rcedit = promisify(rceditCallback); -@@ -379,10 +378,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN +@@ -395,11 +394,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); - const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch, 'remote/node_modules'), { base: 'remote', dot: true, allowEmpty: true }); - const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) - .pipe(filter(getCopilotExcludeFilter(platform, arch))) +- .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) - .pipe(filter(getRipgrepExcludeFilter(platform, arch))) + const deps = cleanedDeps .pipe(jsFilter) .pipe(util.stripSourceMappingURL()) .pipe(jsFilter.restore); -@@ -535,16 +531,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { +@@ -553,16 +548,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } @@ -46,7 +47,7 @@ index beb7ba848fd..3b0be1b7692 100644 /** * @param product The parsed product.json file contents */ -@@ -594,7 +580,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { +@@ -612,7 +597,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { task.task(`node-${platform}-${arch}`) as task.Task, util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), packageTask(type, platform, arch, sourceFolderName, destinationFolderName), @@ -54,7 +55,7 @@ index beb7ba848fd..3b0be1b7692 100644 ]; if (platform === 'win32') { -@@ -608,7 +593,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { +@@ -626,7 +610,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { compileBuildWithManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, @@ -63,7 +64,7 @@ index beb7ba848fd..3b0be1b7692 100644 minified ? minifyTask : bundleTask, serverTaskCI diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts -index 190ba87eb51..8476c4568fd 100644 +index db372863..c5af9fda 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -26,9 +26,8 @@ import { config } from './lib/electron.ts'; @@ -73,23 +74,24 @@ index 190ba87eb51..8476c4568fd 100644 -import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; +import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; --import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +-import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; + import { readAgentSdkResults } from './agent-sdk/common.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; - import globCallback from 'glob'; -@@ -332,10 +331,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d +@@ -340,11 +339,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); - const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch), { base: '.', dot: true, allowEmpty: true }); - const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) - .pipe(filter(getCopilotExcludeFilter(platform, arch))) +- .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) - .pipe(filter(getRipgrepExcludeFilter(platform, arch))) + const deps = cleanedDeps .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) -@@ -591,23 +587,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { +@@ -601,23 +596,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } @@ -113,7 +115,7 @@ index 190ba87eb51..8476c4568fd 100644 const buildRoot = path.dirname(root); const BUILD_TARGETS = [ -@@ -633,7 +612,6 @@ BUILD_TARGETS.forEach(buildTarget => { +@@ -643,7 +621,6 @@ BUILD_TARGETS.forEach(buildTarget => { compileNativeExtensionsBuildTask, util.rimraf(path.join(buildRoot, destinationFolderName)), packageTask(platform, arch, sourceFolderName, destinationFolderName, opts), @@ -121,7 +123,7 @@ index 190ba87eb51..8476c4568fd 100644 ]; if (platform === 'win32') { -@@ -659,7 +637,6 @@ BUILD_TARGETS.forEach(buildTarget => { +@@ -669,7 +646,6 @@ BUILD_TARGETS.forEach(buildTarget => { copyCodiconsTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, @@ -129,7 +131,7 @@ index 190ba87eb51..8476c4568fd 100644 compileExtensionMediaBuildTask, )); task.task(prepackTask); -@@ -680,7 +657,6 @@ BUILD_TARGETS.forEach(buildTarget => { +@@ -690,7 +666,6 @@ BUILD_TARGETS.forEach(buildTarget => { compileBuildWithoutManglingTask, cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, @@ -138,7 +140,7 @@ index 190ba87eb51..8476c4568fd 100644 minified ? minifyVSCodeTask : bundleVSCodeTask, )); diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts -index 289a469754a..8c6df6f7ede 100644 +index 289a4697..8c6df6f7 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -15,7 +15,6 @@ export const dirs = [ @@ -150,29 +152,13 @@ index 289a469754a..8c6df6f7ede 100644 'extensions/css-language-features/server', 'extensions/debug-auto-launch', diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts -index 0d00ac39261..23e23c051a7 100644 +index 0d00ac39..7e2ef16c 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts -@@ -318,37 +318,6 @@ async function main() { - - fs.writeFileSync(stateFile, JSON.stringify(_state)); - fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); -- -- // Symlink .claude/ files to their canonical locations to test Claude agent harness -- const claudeDir = path.join(root, '.claude'); -- fs.mkdirSync(claudeDir, { recursive: true }); -- -- const claudeMdLink = path.join(claudeDir, 'CLAUDE.md'); -- const claudeMdLinkType = ensureAgentHarnessLink(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); -- if (claudeMdLinkType !== 'existing') { -- log('.', `Created ${claudeMdLinkType} .claude/CLAUDE.md -> .github/copilot-instructions.md`); -- } -- -- const claudeSkillsLink = path.join(claudeDir, 'skills'); -- const claudeSkillsLinkType = ensureAgentHarnessLink(path.join('..', '.agents', 'skills'), claudeSkillsLink); -- if (claudeSkillsLinkType !== 'existing') { -- log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`); -- } +@@ -334,21 +334,6 @@ async function main() { + if (claudeSkillsLinkType !== 'existing') { + log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`); + } - - // Temporary: patch @github/copilot-sdk session.js to fix ESM import - // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32. @@ -192,48 +178,49 @@ index 0d00ac39261..23e23c051a7 100644 main().catch(err => { diff --git a/package-lock.json b/package-lock.json -index 8ffdf12aa94..a4ae22d6b31 100644 +index 4acb64a7..2ec27af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,6 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", -- "@github/copilot": "^1.0.57", -- "@github/copilot-sdk": "^1.0.0", +- "@github/copilot": "^1.0.64-0", +- "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", -@@ -1106,179 +1104,6 @@ +@@ -1084,192 +1082,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@github/copilot": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.57.tgz", -- "integrity": "sha512-7dpOu9/qiodmFohZVpTxYmTcjbcXfstWeHof0Ka5RkhguKMkbS3c+sW23a7TTjtlViTV73z+IZFfFW1ru621kw==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", +- "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { -- "detect-libc": "^2.1.2" +- "detect-libc": "^2.1.2", +- "os-theme": "^0.0.8" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { -- "@github/copilot-darwin-arm64": "1.0.57", -- "@github/copilot-darwin-x64": "1.0.57", -- "@github/copilot-linux-arm64": "1.0.57", -- "@github/copilot-linux-x64": "1.0.57", -- "@github/copilot-linuxmusl-arm64": "1.0.57", -- "@github/copilot-linuxmusl-x64": "1.0.57", -- "@github/copilot-win32-arm64": "1.0.57", -- "@github/copilot-win32-x64": "1.0.57" +- "@github/copilot-darwin-arm64": "1.0.64-0", +- "@github/copilot-darwin-x64": "1.0.64-0", +- "@github/copilot-linux-arm64": "1.0.64-0", +- "@github/copilot-linux-x64": "1.0.64-0", +- "@github/copilot-linuxmusl-arm64": "1.0.64-0", +- "@github/copilot-linuxmusl-x64": "1.0.64-0", +- "@github/copilot-win32-arm64": "1.0.64-0", +- "@github/copilot-win32-x64": "1.0.64-0" - } - }, - "node_modules/@github/copilot-darwin-arm64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.57.tgz", -- "integrity": "sha512-ZmsojZbitPSRfgw3W9wBrHGLRDsBvMCjGsGnJ7xXOU6qxeF/IyWHADxEv1WKfDw8BdCM+LE5yITPXB8bcvCdqQ==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", +- "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", - "cpu": [ - "arm64" - ], @@ -247,9 +234,9 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-darwin-x64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.57.tgz", -- "integrity": "sha512-F4TFDOdORy4oSHJS4DE+3sTk09uk1lohOloe0jfvoEVxJSU6jdQcJLNGoo+BQljcG7a1HEBrmB04iAWG1UXVfA==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", +- "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", - "cpu": [ - "x64" - ], @@ -263,12 +250,15 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-linux-arm64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.57.tgz", -- "integrity": "sha512-6apNY/v7CMxKk45CctUZLzQnddBpIG9keSendFKYN+kBIEBSdy//s/Cz/4YQX1iERnklpgZRP7FvcwaKs0/7YA==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", +- "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", - "cpu": [ - "arm64" - ], +- "libc": [ +- "glibc" +- ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ @@ -279,12 +269,15 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-linux-x64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.57.tgz", -- "integrity": "sha512-EOOnU4Y+vZHfxVl8eBAP7JtSTmu5d4ZDUC9wCGpAA5k703lEnpu8UOv04mTHRn8KTzb8gj+ijNhxDWe3Xljbaw==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", +- "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", - "cpu": [ - "x64" - ], +- "libc": [ +- "glibc" +- ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ @@ -295,12 +288,15 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.57.tgz", -- "integrity": "sha512-FCAaaJLX5T2ZpMeS1TCNnhQuGqyH9WVZndFdN1VOEnN/iWeSSaVF3lM4TPyRHHnWDVxzZtB+VLqOSjINZntD6g==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", +- "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", - "cpu": [ - "arm64" - ], +- "libc": [ +- "musl" +- ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ @@ -311,12 +307,15 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.57.tgz", -- "integrity": "sha512-AMIBN830yOvNcrj2Q0tGMImqat/V24wZS/4m5BaUssELM7r7KrT9ZBnBs+nWDZYeQaRoblFWL3f4AfxE3t94lQ==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", +- "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", - "cpu": [ - "x64" - ], +- "libc": [ +- "musl" +- ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ @@ -327,17 +326,17 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-sdk": { -- "version": "1.0.0", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0.tgz", -- "integrity": "sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==", +- "version": "1.0.2", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", +- "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", - "license": "MIT", - "dependencies": { -- "@github/copilot": "^1.0.57", +- "@github/copilot": "^1.0.64-0", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { -- "node": ">=20.0.0" +- "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@github/copilot-sdk/node_modules/zod": { @@ -350,9 +349,9 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-win32-arm64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.57.tgz", -- "integrity": "sha512-3TL2bd1/p/sYbNgDIqbnjES//zlXP5b0sPEXKQRrpVF9ZLN3vjQ1tmBWx8Qx7zn2J3oywH2dG7qKjuxWTJRXKA==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", +- "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", - "cpu": [ - "arm64" - ], @@ -366,9 +365,9 @@ index 8ffdf12aa94..a4ae22d6b31 100644 - } - }, - "node_modules/@github/copilot-win32-x64": { -- "version": "1.0.57", -- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.57.tgz", -- "integrity": "sha512-zuKqRn0pIF+ZvuiMXbZkYK1AMlrV21kFTpyf5l7gdI1dzJuwHNI0Qfe0gzaZYaU1B4htbzMk9MhEbjR1PQcoJg==", +- "version": "1.0.64-0", +- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", +- "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", - "cpu": [ - "x64" - ], @@ -385,57 +384,43 @@ index 8ffdf12aa94..a4ae22d6b31 100644 "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", diff --git a/package.json b/package.json -index a7bc41dcbfc..5d2243257a9 100644 +index 9a425766..594fa86b 100644 --- a/package.json +++ b/package.json -@@ -43,9 +43,6 @@ - "watch-extensions": "npm run gulp watch-extensions watch-extension-media", - "watch-extensionsd": "deemon npm run watch-extensions", - "kill-watch-extensionsd": "deemon --kill npm run watch-extensions", -- "watch-copilot": "npm --prefix extensions/copilot run watch", -- "watch-copilotd": "deemon npm run watch-copilot", -- "kill-watch-copilotd": "deemon --kill npm run watch-copilot", - "precommit": "node --experimental-strip-types build/hygiene.ts", - "gulp": "node --experimental-strip-types --max-old-space-size=8192 ./node_modules/gulp/bin/gulp.js", - "electron": "node build/lib/electron.ts", -@@ -84,8 +81,6 @@ - "perf": "node scripts/code-perf.js", - "perf:chat": "node scripts/chat-simulation/test-chat-perf-regression.js", - "perf:chat-leak": "node scripts/chat-simulation/test-chat-mem-leaks.js", -- "copilot:setup": "npm --prefix extensions/copilot run setup", -- "copilot:get_token": "npm --prefix extensions/copilot run get_token", - "update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)", - "install-local-component-explorer": "npm install ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-0.1.0.tgz ../vscode-packages/js-component-explorer/dist/vscode-component-explorer-cli-0.1.0.tgz --no-save && cd build/rspack && npm install ../../../vscode-packages/js-component-explorer/dist/vscode-component-explorer-webpack-plugin-0.1.0.tgz --no-save && cd ../vite && npm install ../../../vscode-packages/js-component-explorer/dist/vscode-component-explorer-vite-plugin-0.1.0.tgz --no-save", - "symlink-local-component-explorer": "npm install ../vscode-packages/js-component-explorer/packages/explorer ../vscode-packages/js-component-explorer/packages/cli --no-save && cd build/rspack && npm install ../../../vscode-packages/js-component-explorer/packages/webpack-plugin ../../../vscode-packages/js-component-explorer/packages/explorer --no-save && cd ../vite && npm install ../../../vscode-packages/js-component-explorer/packages/vite-plugin ../../../vscode-packages/js-component-explorer/packages/explorer --no-save", -@@ -93,8 +88,6 @@ +@@ -95,8 +95,6 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", -- "@github/copilot": "^1.0.57", -- "@github/copilot-sdk": "^1.0.0", +- "@github/copilot": "^1.0.64-0", +- "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts -index 667e3bc8c6d..51af8b2ce19 100644 +index 667e3bc8..634bca98 100644 --- a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts +++ b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts -@@ -3,7 +3,12 @@ +@@ -3,7 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { TelemetryConfig } from '@github/copilot-sdk'; +// `TelemetryConfig` was originally imported from '@github/copilot-sdk'; the SDK -+// is removed in BradfordCode. This service still exposes a config shape that -+// mirrors the SDK's `TelemetryConfig`; keep the type symbol available at -+// compile time without depending on the removed package. -+// eslint-disable-next-line @typescript-eslint/no-explicit-any -+type TelemetryConfig = any; ++// is removed in !!APP_NAME!! (proprietary Copilot binaries can't be ++// redistributed). The OTel service no longer feeds a Copilot SDK, so define the ++// config shape locally from the fields the service actually builds/reads. ++export type TelemetryConfig = { ++ exporterType?: string; ++ otlpEndpoint?: string; ++ filePath?: string; ++ sourceName?: string; ++ captureContent?: boolean; ++}; import type { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts -index 59c5257d573..5b854613420 100644 +index 0dd36914..6440c830 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -20,7 +20,6 @@ import { AgentService } from './agentService.js'; @@ -443,45 +428,187 @@ index 59c5257d573..5b854613420 100644 import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; + import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; - import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; -@@ -169,7 +168,6 @@ async function startAgentHost(): Promise { +@@ -184,7 +183,6 @@ async function startAgentHost(): Promise { diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentConfigurationService, agentService.configurationService); diServices.set(IAgentHostCompletions, agentService.completionsService); - agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); - // The Claude agent provider is opt-in. Gated on the - // `chat.agentHost.claudeAgent.path` workbench setting being non-empty, - // forwarded by the agent host starters as `VSCODE_AGENT_HOST_CLAUDE_SDK_PATH`. + // Claude and Codex providers are gated on two things: + // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, + // forwarded as an env var by the starters). Claude defaults to on, diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts -index 52a291518b6..6197da16587 100644 +index 5c428d75..a23b5047 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts -@@ -32,7 +32,6 @@ import product from '../../product/common/product.js'; - import { IProductService } from '../../product/common/productService.js'; +@@ -33,7 +33,6 @@ import { IProductService } from '../../product/common/productService.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; + import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; + import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; - import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; -@@ -251,9 +250,6 @@ async function main(): Promise { +@@ -282,9 +281,6 @@ async function main(): Promise { diServices.set(ICodexProxyService, codexProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); - const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); - agentService.registerProvider(copilotAgent); - log('CopilotAgent registered'); - if (options.claudeSdkPath) { - // `ClaudeAgentSdkService` reads `AgentHostClaudeSdkPathEnvVar` directly, - // so make sure it is set even if the path was provided via CLI flag. + // Claude and Codex providers are gated on two things: + // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, + // forwarded as an env var by the renderer-side starters; the remote +diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +deleted file mode 100644 +index ec3155d1..00000000 +--- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts ++++ /dev/null +@@ -1,136 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import { Event } from '../../../../base/common/event.js'; +-import { FileAccess } from '../../../../base/common/network.js'; +-import { dirname } from '../../../../base/common/path.js'; +-import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { createHash } from 'crypto'; +-import { IEnvironmentService, INativeEnvironmentService } from '../../../environment/common/environment.js'; +-import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +-import { IProductService } from '../../../product/common/productService.js'; +-import { ISandboxHelperService, type ISandboxDependencyStatus, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../../../sandbox/common/sandboxHelperService.js'; +-import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; +-import { IAgentConfigurationService } from '../agentConfigurationService.js'; +-import { AgentHostSandboxConfigKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; +- +-/** Subdirectory under the user home + product data folder where the engine creates its temp dir. */ +-const SANDBOX_TEMP_DIR_NAME = 'tmp'; +- +-/** +- * Host adapter that bridges agent-host environment data into the shared +- * {@link TerminalSandboxEngine}. One instance per session, wired up via +- * {@link createAgentHostSandboxEngine}. +- */ +-class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { +- readonly onDidChangeRoots = Event.None; +- readonly onDidChangeSandboxSettings: Event; +- private readonly _sandboxHelper: ISandboxHelperService; +- +- constructor( +- private readonly _sessionId: string, +- private readonly _workingDirectory: URI | undefined, +- private readonly _environmentService: INativeEnvironmentService, +- private readonly _productService: IProductService, +- private readonly _agentConfigurationService: IAgentConfigurationService, +- sandboxHelper: ISandboxHelperService, +- ) { +- this._sandboxHelper = sandboxHelper; +- this.onDidChangeSandboxSettings = this._agentConfigurationService.onDidRootConfigChange; +- } +- +- async getOS(): Promise { +- return OS; +- } +- +- async getRuntimeInfo(): Promise { +- const appRoot = dirname(FileAccess.asFileUri('').path); +- const runAsNode = !!process.versions['electron']; +- return { appRoot, execPath: process.execPath, runAsNode }; +- } +- +- async getUserHome(): Promise { +- return this._environmentService.userHome; +- } +- +- async getSandboxTempDir(): Promise { +- const userHome = this._environmentService.userHome; +- if (!userHome) { +- return undefined; +- } +- const sandboxRoot = URI.joinPath(userHome, this._productService.dataFolderName, SANDBOX_TEMP_DIR_NAME); +- // Keep the per-session leaf short and bounded: the sandbox runtime +- // creates its network-bridge UNIX sockets (e.g. `claude-socks-.sock`, +- // ~35 bytes) directly under this directory, and the full socket path must +- // stay within the AF_UNIX 108-byte limit. The raw session id is a URI +- // segment (often a UUID), so hash it to a short hex string instead. A +- // 64-bit SHA-256 prefix (16 hex chars) keeps the leaf short and +- // collisions infeasible. +- const digest = createHash('sha256').update(this._sessionId).digest('hex'); +- const sessionLeaf = `agenthost_${digest.substring(0, 16)}`; +- return URI.joinPath(sandboxRoot, sessionLeaf); +- } +- +- async getWorkspaceStorageReadRoot(): Promise { +- // The agent host has no workspace-storage equivalent today. +- return undefined; +- } +- +- getWriteRoots(): readonly URI[] { +- return this._workingDirectory ? [this._workingDirectory] : []; +- } +- +- async checkSandboxDependencies(): Promise { +- return this._sandboxHelper.checkSandboxDependencies(); +- } +- +- async getWindowsMxcFilesystemPolicy() { +- return this._sandboxHelper.getWindowsMxcFilesystemPolicy(); +- } +- +- async getWindowsMxcEnvironment() { +- return this._sandboxHelper.getWindowsMxcEnvironment(); +- } +- +- async buildWindowsMxcSandboxPayload(commandLine: string, policy: IWindowsMxcSandboxPolicy, workingDirectory?: string, containerName?: string, containment?: IWindowsMxcPolicyContainment) { +- return this._sandboxHelper.buildWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment); +- } +- +- getSandboxSetting(settingId: string): T | undefined { +- // The agent host stores sandbox settings nested under a single +- // top-level `sandbox` object with prefix-free sub-keys (e.g. +- // `sandbox.enabled` rather than `chat.agent.sandbox.enabled`). Map +- // from the engine's modern setting ID into that sub-key namespace; +- // unknown IDs (which include all deprecated keys — handled host-side +- // by the workbench client) resolve to undefined. +- const innerKey = sandboxSettingIdToAgentHostKey[settingId]; +- if (innerKey === undefined) { +- return undefined; +- } +- const sandbox = this._agentConfigurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); +- return sandbox?.[innerKey] as T | undefined; +- } +-} +- +-/** +- * Construct a per-session {@link TerminalSandboxEngine} for the agent host. +- * The returned engine is registered with the caller's instantiation service +- * but the caller is responsible for disposing it (typically by registering it +- * alongside the per-session {@link ShellManager}). +- */ +-export function createAgentHostSandboxEngine( +- instantiationService: IInstantiationService, +- environmentService: IEnvironmentService, +- productService: IProductService, +- agentConfigurationService: IAgentConfigurationService, +- sandboxHelper: ISandboxHelperService, +- sessionId: string, +- workingDirectory: URI | undefined, +-): TerminalSandboxEngine { +- const host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); +- return instantiationService.createInstance(TerminalSandboxEngine, host); +-} +- diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts deleted file mode 100644 -index 23d008d0f91..00000000000 +index 3f37722b..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ /dev/null -@@ -1,2554 +0,0 @@ +@@ -1,3187 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -489,51 +616,77 @@ index 23d008d0f91..00000000000 - -import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; -import * as fs from 'fs/promises'; --import { Limiter, SequencerByKey, Throttler } from '../../../../base/common/async.js'; --import { CancellationTokenSource, type CancellationToken } from '../../../../base/common/cancellation.js'; --import { rgDiskPath } from '../../../../base/node/ripgrep.js'; +-import * as os from 'os'; +-import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; +-import { type CancellationToken } from '../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../base/common/errors.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { FileAccess } from '../../../../base/common/network.js'; +-import { formatTokenCount } from '../../../../base/common/numbers.js'; -import { equals } from '../../../../base/common/objects.js'; -import { observableValue } from '../../../../base/common/observable.js'; --import { basename, delimiter, dirname } from '../../../../base/common/path.js'; --import { basename as resourceBasename, dirname as resourceDirname } from '../../../../base/common/resources.js'; +-import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; +-import { basename as resourceBasename } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; +-import { rgDiskPath } from '../../../../base/node/ripgrep.js'; -import { localize } from '../../../../nls.js'; --import { IParsedPlugin, parseAgentFile, parsePlugin, parseSkillFile } from '../../../agentPlugins/common/pluginParsers.js'; +-import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile } from '../../../agentPlugins/common/pluginParsers.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; --import { ILogService } from '../../../log/common/log.js'; +-import { ILogService, LogLevel } from '../../../log/common/log.js'; +-import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +-import { createAgentModelPricingMeta } from '../../common/agentModelPricing.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; --import { AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformRootSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +-import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; --import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +-import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification } from '../../common/agentService.js'; +-import { getEffectiveAgents } from '../../common/customAgents.js'; +-import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; +-import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +-import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.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 ChildCustomizationType, type ConfigSchema, type ModelSelection, type AgentSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +-import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; --import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; --import { CustomizationLoadStatus, CustomizationType, ResponsePartKind, SessionInputResponseKind, customizationId, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +-import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +-import { ActiveClientState } from '../activeClientState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; --import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { IAgentHostCompletions } from '../agentHostCompletions.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; --import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +-import { findMcpChildId } from '../shared/mcpCustomizationController.js'; +-import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; -import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; -import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; +-import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; -import { ShellManager } from './copilotShellTools.js'; --import { CopilotSessionLauncher, ThinkingLevelConfigKey, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; --import { DiscoveredType, SessionCustomizationDiscovery, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; --import { SessionPluginBundler } from '../shared/sessionPluginBundler.js'; +-import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; -import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; --import { getEffectiveAgents } from '../../common/customAgents.js'; +-import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; +-import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; +- +-/** +- * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` +- * option so the spawned CLI logs (written to `~/.copilot/logs/process-*.log`) +- * match the agent host's configured verbosity. `Trace` maps to the CLI's most +- * verbose `'all'` level so renderer-side trace logging surfaces the CLI's +- * internal diagnostics. +- */ +-function copilotCliLogLevelFor(level: LogLevel): NonNullable { +- switch (level) { +- case LogLevel.Off: return 'none'; +- case LogLevel.Trace: return 'all'; +- case LogLevel.Debug: return 'debug'; +- case LogLevel.Info: return 'info'; +- case LogLevel.Warning: return 'warning'; +- case LogLevel.Error: return 'error'; +- } +-} - -interface ICreatedWorktree { - readonly repositoryRoot: URI; @@ -578,7 +731,9 @@ index 23d008d0f91..00000000000 - readonly project: IAgentSessionProjectInfo | undefined; -} - --export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './copilotSessionLauncher.js'; +-export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; +- +-type ModelInfo = Awaited>['models'][number]; - -interface ISerializedModelSelection { - id?: unknown; @@ -586,8 +741,43 @@ index 23d008d0f91..00000000000 -} - -/** -- * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's -- * private `connection` field to wire plan mode. See {@link CopilotAgent._enablePlanModeOnClient}. +- * A persisted additional (non-default) peer chat. Records the SDK conversation +- * id that backs the chat so it can be resumed after a process restart, along +- * with any model override chosen at creation time. +- */ +-interface IPersistedChat { +- readonly sdkSessionId: string; +- readonly model?: ModelSelection; +-} +- +-/** +- * Augments the published `@vscode/copilot-api` `ModelBilling` with the `tokenPrices` field the runtime CAPI `/models` +- * payload already carries but the SDK type doesn't yet declare. Mirror of `IClaudeModelSupports` in `claudeAgent.ts`. +- */ +-interface ICopilotModelBilling { +- readonly multiplier?: number; +- /** Coarse price bucket surfaced as a tag in the model picker hover. */ +- readonly priceCategory?: string; +- readonly tokenPrices?: { +- /** Default-tier prices, expressed as credits per 1M tokens. */ +- readonly contextMax?: number; +- readonly inputPrice?: number; +- readonly cachePrice?: number; +- readonly cacheWritePrice?: number; +- readonly outputPrice?: number; +- readonly longContext?: { +- readonly contextMax?: number; +- readonly inputPrice?: number; +- readonly cachePrice?: number; +- readonly cacheWritePrice?: number; +- readonly outputPrice?: number; +- }; +- }; +-} +- +-/** +- * 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; @@ -627,32 +817,6 @@ index 23d008d0f91..00000000000 - return branchName.replace(/\//g, '-'); -} - --export function getCopilotWorktreeBranchName(sessionId: string, branchNameHint: string | undefined): string { -- return `agents/${branchNameHint ? `${branchNameHint}-${sessionId.substring(0, 8)}` : sessionId}`; --} -- --/** -- * Derive a slug-style branch-name hint from the user's first message. Used -- * by the worktree isolation flow so the generated branch name reflects the -- * intent of the session instead of being just a session id. -- * -- * Returns `undefined` if the message has no slug-able content (e.g. only -- * punctuation), in which case the caller falls back to a session-id-only -- * branch name. -- */ --export function getCopilotBranchNameHintFromMessage(message: string): string | undefined { -- const words = message -- .toLowerCase() -- .normalize('NFKD') -- .replace(/[^a-z0-9]+/g, '-') -- .replace(/^-+|-+$/g, '') -- .split('-') -- .filter(word => word.length > 0) -- .slice(0, 8); -- const hint = words.join('-').slice(0, 48).replace(/-+$/g, ''); -- return hint.length > 0 ? hint : undefined; --} -- -/** - * Builds the localized "Created isolated worktree for branch X" markdown - * shown at the top of the first response in worktree-isolated sessions. @@ -711,14 +875,49 @@ index 23d008d0f91..00000000000 - readonly onDidSessionProgress = this._onDidSessionProgress.event; - private readonly _onDidMaterializeSession = this._register(new Emitter()); - readonly onDidMaterializeSession = this._onDidMaterializeSession.event; +- /** +- * Per-session MCP notifications, fanned in from every active +- * {@link CopilotAgentSession}. Each session contributes a single +- * subscription, disposed alongside the session. +- */ +- private readonly _onMcpNotification = this._register(new Emitter()); +- readonly onMcpNotification = this._onMcpNotification.event; - private readonly _models = observableValue(this, []); - readonly models = this._models; - - private _client: CopilotClient | undefined; - private _clientStarting: Promise | undefined; - private _githubToken: string | undefined; +- private _serverToolHost: IAgentServerToolHost | undefined; +- +- setServerToolHost(host: IAgentServerToolHost): void { +- this._serverToolHost = host; +- } +- +- /** Reflects the `rt=1` field on the GitHub Copilot bearer token; gates enhanced GH telemetry. */ +- private _restrictedTelemetryEnabled = false; +- private readonly _onDidChangeRestrictedTelemetry = this._register(new Emitter()); +- readonly onDidChangeRestrictedTelemetry = this._onDidChangeRestrictedTelemetry.event; +- +- get restrictedTelemetryEnabled(): boolean { +- return this._restrictedTelemetryEnabled; +- } +- - private readonly _sessions = this._register(new DisposableMap()); - /** +- * Additional (non-default) chats within a session, keyed by chat channel +- * URI string. Each entry is its own Copilot SDK conversation sharing the +- * owning session's working directory/model scope. The default chat is not +- * tracked here — it maps to the primary {@link _sessions} entry. +- */ +- private readonly _chatSessions = this._register(new DisposableMap()); +- /** +- * Per-session MCP-notification subscriptions, keyed by `sessionId`. +- * Disposed in lockstep with the matching {@link _sessions} entry so +- * the fan-in does not leak listeners as sessions come and go. +- */ +- private readonly _mcpNotificationSubs = this._register(new DisposableMap()); +- /** - * In-flight {@link _resumeSession} promises, keyed by sessionId. Used to - * deduplicate concurrent resume requests for the same session so that - * we never construct two {@link CopilotAgentSession} entries for the @@ -760,6 +959,7 @@ index 23d008d0f91..00000000000 - @IAgentHostGitService private readonly _gitService: IAgentHostGitService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, +- @ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator, - @IAgentHostCompletions completions: IAgentHostCompletions, - @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, - ) { @@ -770,6 +970,7 @@ index 23d008d0f91..00000000000 - this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { - hasHistory: (sessionId) => !this._provisionalSessions.has(sessionId) && this._sessions.has(sessionId), - isRubberDuckEnabled: () => this._isRubberDuckEnabled(), +- hasRuntimeSlashCommand: async (sessionId, command) => this._sessions.get(sessionId)?.hasRuntimeSlashCommand(command) ?? false, - }))); - - // Restart the CLI client when a setting baked into the client/subprocess at @@ -818,6 +1019,7 @@ index 23d008d0f91..00000000000 - if (this._client) { - this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); - this._sessions.clearAndDisposeAll(); +- this._mcpNotificationSubs.clearAndDisposeAll(); - await this._stopClient(); - } - } @@ -831,13 +1033,16 @@ index 23d008d0f91..00000000000 - getDescriptor(): IAgentDescriptor { - return { - provider: 'copilotcli', -- displayName: 'Copilot CLI', +- displayName: 'Copilot', - description: 'Copilot SDK agent running in a dedicated process', - }; - } - - getProtectedResources(): ProtectedResourceMetadata[] { -- return [GITHUB_COPILOT_PROTECTED_RESOURCE]; +- return [ +- GITHUB_COPILOT_PROTECTED_RESOURCE, +- GITHUB_REPO_PROTECTED_RESOURCE +- ]; - } - - getCustomizations(): readonly Customization[] { @@ -847,7 +1052,23 @@ index 23d008d0f91..00000000000 - async getSessionCustomizations(session: URI): Promise { - const directory = await this._getSessionCustomizationDirectory(session); - const activeClient = this._getOrCreateActiveClient(session, directory); -- return activeClient.pluginController.getCustomizationsSettled(); +- const fromPlugins = await activeClient.pluginController.getCustomizationsSettled(); +- const sessionId = AgentSession.id(session); +- const entry = this._sessions.get(sessionId); +- const topLevelMcp = entry?.topLevelMcpCustomizations() ?? []; +- if (topLevelMcp.length === 0) { +- return fromPlugins; +- } +- return [...fromPlugins, ...topLevelMcp]; +- } +- +- async handleMcpRequest(session: URI, serverName: string, method: string, params: Record | undefined): Promise { +- const sessionId = AgentSession.id(session); +- const entry = this._sessions.get(sessionId); +- if (!entry) { +- throw new Error(`Method not found: no active session ${sessionId}`); +- } +- return entry.handleMcpRequest(serverName, method, params); - } - - private async _getSessionCustomizationDirectory(session: URI): Promise { @@ -862,22 +1083,31 @@ index 23d008d0f91..00000000000 - } - - async authenticate(resource: string, token: string): Promise { +- if (resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { +- return true; +- } - if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { - return false; - } - const tokenChanged = this._githubToken !== token; - this._githubToken = token; +- this._updateRestrictedTelemetry(token); - this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); -- if (tokenChanged && this._client && this._sessions.size === 0) { -- this._logService.info('[Copilot] Restarting CopilotClient with new token'); -- await this._stopClient(); -- } - if (tokenChanged) { - void this._refreshModels(); - } - return true; - } - +- private _updateRestrictedTelemetry(token: string | undefined): void { +- const rtEnabled = isRestrictedTelemetryEnabled(token); +- if (rtEnabled !== this._restrictedTelemetryEnabled) { +- this._restrictedTelemetryEnabled = rtEnabled; +- this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`); +- this._onDidChangeRestrictedTelemetry.fire(); +- } +- } +- - private async _refreshModels(): Promise { - const tokenAtRefreshStart = this._githubToken; - if (!tokenAtRefreshStart) { @@ -885,7 +1115,7 @@ index 23d008d0f91..00000000000 - return; - } - try { -- const models = await this._listModels(); +- const models = await this._listModels(tokenAtRefreshStart); - if (this._githubToken === tokenAtRefreshStart) { - this._models.set(models, undefined); - } @@ -945,10 +1175,6 @@ index 23d008d0f91..00000000000 - // ---- client lifecycle --------------------------------------------------- - - private async _ensureClient(): Promise { -- const tokenAtStartup = this._githubToken; -- if (!tokenAtStartup) { -- throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot', this.getProtectedResources()); -- } - if (this._client) { - return this._client; - } @@ -961,7 +1187,7 @@ index 23d008d0f91..00000000000 - const sessionSyncAtStartup = this._isSessionSyncEnabled(); - const rubberDuckAtStartup = this._isRubberDuckEnabled(); - const clientStarting = (async () => { -- this._logService.info('[Copilot] Starting CopilotClient... (with token)'); +- this._logService.info('[Copilot] Starting CopilotClient...'); - - // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars - // that can interfere with the Node.js process the SDK spawns. @@ -980,6 +1206,28 @@ index 23d008d0f91..00000000000 - } - env['COPILOT_CLI_RUN_AS_NODE'] = '1'; - env['USE_BUILTIN_RIPGREP'] = 'false'; +- env['COPILOT_MCP_APPS'] = 'true'; +- +- // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into +- // the container, so the CLI's default PTY-backed interactive shell can +- // never start bash under the sandbox: the inner shell sees a non-tty +- // stdin, runs non-interactively, reads EOF and exits immediately, which +- // surfaces as "Failed to start bash process". Force the CLI's pipe-based +- // spawn shell backend (`SHELL_SPAWN_BACKEND`), which runs each command as +- // a one-shot child process and works correctly under bubblewrap. The CLI +- // already force-enables this on Alpine/musl; glibc Linux needs it too for +- // sandboxed shells. This becomes a no-op once the bundled CLI defaults the +- // spawn backend on for all of Linux. +- if (process.platform === 'linux') { +- const enabledFlags = env['COPILOT_CLI_ENABLED_FEATURE_FLAGS']; +- const flags = new Set((enabledFlags ?? '').split(',').map(f => f.trim()).filter(Boolean)); +- flags.add('SHELL_SPAWN_BACKEND'); +- env['COPILOT_CLI_ENABLED_FEATURE_FLAGS'] = [...flags].join(','); +- } +- +- // Identify VS Code's agent host traffic in CAPI +- env['GITHUB_COPILOT_INTEGRATION_ID'] = COPILOT_INTEGRATION_ID; +- this._logService.info(`[Copilot] Set CLI env: GITHUB_COPILOT_INTEGRATION_ID=${COPILOT_INTEGRATION_ID}`); - - // Enable the rubber duck critic subagent in the CLI when the agent host - // config opts in. `RUBBER_DUCK_AGENT` is the SDK's required interface for @@ -993,7 +1241,15 @@ index 23d008d0f91..00000000000 - // Resolve the CLI entry point from node_modules. We can't use require.resolve() - // because @github/copilot's exports map blocks direct subpath access. - // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. -- const cliPath = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@github', 'copilot', 'index.js').fsPath; +- const nodeModulesUri = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); +- const cliPath = URI.joinPath(nodeModulesUri, '@github', 'copilot', 'index.js').fsPath; +- +- // The SDK's sandbox auto-detection looks for `//wxc-exec.exe` +- // (and the Linux/macOS equivalents). VS Code core ships the MXC sandbox binaries +- // at `node_modules/@microsoft/mxc-sdk/bin//`, so point `MXC_BIN_DIR` there. +- // The @github/copilot package's own `mxc-bin/` is excluded from the product build +- // (see build/.moduleignore), mirroring `CopilotCLISDK.getPackage` in the extension. +- env['MXC_BIN_DIR'] = URI.joinPath(nodeModulesUri, '@microsoft', 'mxc-sdk', 'bin').fsPath; - - // Add VS Code's built-in ripgrep to PATH so the CLI subprocess can find it. - const resolvedRgDiskPath = await rgDiskPath(); @@ -1008,19 +1264,15 @@ index 23d008d0f91..00000000000 - const telemetry = await this._otelService.getSdkTelemetryConfig(); - - const clientOptions: CopilotClientOptions = { -- gitHubToken: tokenAtStartup, - useLoggedInUser: false, - connection: RuntimeConnection.forStdio({ path: cliPath }), - env, - telemetry, +- logLevel: copilotCliLogLevelFor(this._logService.getLevel()), - enableRemoteSessions: this._isSessionSyncEnabled(), - }; - const client = this._createCopilotClient(clientOptions); - await client.start(); -- if (this._githubToken !== tokenAtStartup) { -- await client.stop(); -- throw new Error('Copilot authentication changed while the client was starting'); -- } - if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { - await client.stop(); - throw new Error('Copilot startup config changed while the client was starting'); @@ -1040,36 +1292,105 @@ index 23d008d0f91..00000000000 - - // ---- session management ------------------------------------------------- - -- private _createThinkingLevelConfigSchema(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigSchema | undefined { +- private _createThinkingLevelConfigSchemaProperty(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigPropertySchema | undefined { - if (!supportedReasoningEfforts?.length) { - return undefined; - } - -- const enumLabels = supportedReasoningEfforts.map(value => { -- switch (value) { -- case 'low': return localize('copilot.modelThinkingLevel.low', "Low"); -- case 'medium': return localize('copilot.modelThinkingLevel.medium', "Medium"); -- case 'high': return localize('copilot.modelThinkingLevel.high', "High"); -- case 'xhigh': return localize('copilot.modelThinkingLevel.xhigh', "Extra High"); -- default: return value; -- } -- }); +- return { +- type: 'string', +- title: localize('copilot.modelThinkingLevel.title', "Thinking Level"), +- description: localize('copilot.modelThinkingLevel.description', "Controls how much reasoning effort the model uses."), +- default: defaultReasoningEffort, +- enum: [...supportedReasoningEfforts], +- enumLabels: supportedReasoningEfforts.map(getReasoningEffortLabel), +- enumDescriptions: supportedReasoningEfforts.map(value => getReasoningEffortDescription(value) ?? ''), +- }; +- } +- +- /** +- * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct +- * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in +- * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. +- * +- * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK +- * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. +- */ +- private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { +- const tokenPrices = billing?.tokenPrices; +- const defaultMax = tokenPrices?.contextMax; +- const longContextMax = tokenPrices?.longContext?.contextMax; +- if (!defaultMax || !longContextMax || defaultMax >= longContextMax) { +- return undefined; +- } +- +- const hasLongContextSurcharge = typeof tokenPrices?.longContext?.inputPrice === 'number' +- || typeof tokenPrices?.longContext?.outputPrice === 'number'; - - return { -- type: 'object', -- properties: { -- [ThinkingLevelConfigKey]: { -- type: 'string', -- title: localize('copilot.modelThinkingLevel.title', "Thinking Level"), -- description: localize('copilot.modelThinkingLevel.description', "Controls how much reasoning effort the model uses."), -- default: defaultReasoningEffort, -- enum: [...supportedReasoningEfforts], -- enumLabels, -- }, -- }, +- type: 'string', +- title: localize('copilot.modelContextTier.title', "Context Size"), +- description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), +- default: 'default', +- enum: ['default', 'long_context'], +- enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], +- enumDescriptions: [ +- localize('copilot.modelContextTier.default', "Default"), +- hasLongContextSurcharge +- ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") +- : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), +- ], - }; - } - +- /** +- * Builds the open `_meta` pricing bag for a model from its billing info so the chat model picker can render its +- * cost hover. Cost values are credits per 1M tokens. +- * +- * Long-context costs are only emitted when they differ from the default tier, mirroring `normalizeTokenPrices` in +- * `extensions/copilot/src/extension/conversation/common/languageModelAccess.ts`. +- * +- * `billing.tokenPrices` / `billing.priceCategory` are present on the runtime CAPI `/models` payload but not yet +- * declared on the published SDK `ModelBilling` type — narrow through {@link ICopilotModelBilling}. +- */ +- private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { +- const billing = modelInfo?.billing; +- const tokenPrices = billing?.tokenPrices; +- const longContext = tokenPrices?.longContext; +- +- const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => +- longValue !== undefined && longValue !== defaultValue ? longValue : undefined; +- +- return createAgentModelPricingMeta({ +- multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, +- inputCost: tokenPrices?.inputPrice, +- cacheCost: tokenPrices?.cachePrice, +- cacheWriteCost: tokenPrices?.cachePrice, +- outputCost: tokenPrices?.outputPrice, +- longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), +- longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), +- longContextCacheWriteCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), +- longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), +- priceCategory: typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined, +- }); +- } +- +- private _createModelConfigSchema(m: ModelInfo): ConfigSchema | undefined { +- const properties: ConfigSchema['properties'] = {}; +- const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort); +- if (thinkingLevel) { +- properties[ThinkingLevelConfigKey] = thinkingLevel; +- } +- const contextTier = this._createContextTierConfigSchemaProperty(m.billing); +- if (contextTier) { +- properties[ContextTierConfigKey] = contextTier; +- } +- if (Object.keys(properties).length === 0) { +- return undefined; +- } +- return { type: 'object', properties }; +- } +- - private _serializeModelSelection(model: ModelSelection): string { - return JSON.stringify(model); - } @@ -1213,10 +1534,10 @@ index 23d008d0f91..00000000000 - }; - } - -- private async _listModels(): Promise { +- private async _listModels(gitHubToken: string): Promise { - this._logService.info('[Copilot] Listing models...'); - const client = await this._ensureClient(); -- const models = await client.listModels(); +- const { models } = await client.rpc.models.list({ gitHubToken }); - const result = models.map((m): IAgentModelInfo => ({ - provider: this.id, - id: m.id, @@ -1225,42 +1546,55 @@ index 23d008d0f91..00000000000 - // no fixed context window — surface them with maxContextWindow undefined. - maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, - supportsVision: !!m.capabilities?.supports?.vision, -- configSchema: this._createThinkingLevelConfigSchema(m.supportedReasoningEfforts, m.defaultReasoningEffort), +- configSchema: this._createModelConfigSchema(m), - policyState: m.policy?.state as PolicyState | undefined, -- _meta: typeof m.billing?.multiplier === 'number' ? { -- multiplierNumeric: m.billing.multiplier, -- } : undefined, +- _meta: this._createModelPricingMeta(m), - })); -- this._logService.info(`[Copilot] Found ${result.length} models`); +- this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); - return result; - } - -- async createSession(config?: IAgentCreateSessionConfig): Promise { -- if (!config?.workingDirectory) { -- throw new Error('workingDirectory is required to create a Copilot session'); +- /** +- * Resolves the working directory for a {@link createSession} call: the caller-supplied folder, else a +- * still-provisional session's folder for an idempotent re-create, else a freshly created empty directory under the +- * OS temp dir (used when the editor has no workspace open). +- */ +- private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string): Promise { +- const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; +- if (existing) { +- return existing; - } +- const tmpPath = await fs.mkdtemp(join(os.tmpdir(), 'agent-host-session-')); +- const workingDirectory = URI.file(tmpPath); +- this._logService.trace(`[Copilot] No workingDirectory provided, defaulting to temp directory: ${workingDirectory.fsPath}`); +- return workingDirectory; +- } - -- this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model.id}` : ''}`); -- const client = await this._ensureClient(); +- async createSession(config?: IAgentCreateSessionConfig): Promise { +- const sessionConfig = config ?? {}; - +- this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); +- const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); +- const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); +- const client = await this._ensureClient(); - // When forking, use the SDK's sessions.fork RPC. Forking from a source - // session that has no turns is equivalent to creating a fresh session; - // in that case the agent service drops `config.fork` before calling us, - // so we never enter this branch with a provisional source. -- if (config?.fork) { -- const sourceSessionId = AgentSession.id(config.fork.session); +- if (sessionConfig.fork) { +- const sourceSessionId = AgentSession.id(sessionConfig.fork.session); - - // Serialize against the source session to prevent concurrent - // modifications while we read its state. - return this._sessionSequencer.queue(sourceSessionId, async () => { -- this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${config.fork!.turnId}`); +- this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${sessionConfig.fork!.turnId}`); - - const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId); - - // Look up the SDK event ID for the turn *after* the fork point. - // toEventId is exclusive — events before it are included. - // If there's no next turn, omit toEventId to include all events. -- const toEventId = await sourceEntry.getNextTurnEventId(config.fork!.turnId); +- const toEventId = await sourceEntry.getNextTurnEventId(sessionConfig.fork!.turnId); - - const forkResult = await client.rpc.sessions.fork({ - sessionId: sourceSessionId, @@ -1274,7 +1608,7 @@ index 23d008d0f91..00000000000 - const targetDbDir = this._sessionDataService.getSessionDataDirById(newSessionId); - const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME); - try { -- const sourceDbRef = await this._sessionDataService.tryOpenDatabase(config.fork!.session); +- const sourceDbRef = await this._sessionDataService.tryOpenDatabase(sessionConfig.fork!.session); - if (sourceDbRef) { - try { - await fs.mkdir(targetDbDir.fsPath, { recursive: true }); @@ -1291,18 +1625,18 @@ index 23d008d0f91..00000000000 - const agentSession = await this._resumeSession(newSessionId); - - // Remap turn IDs to match the new protocol turn IDs -- if (config.fork!.turnIdMapping) { -- await agentSession.remapTurnIds(config.fork!.turnIdMapping); +- if (sessionConfig.fork!.turnIdMapping) { +- await agentSession.remapTurnIds(sessionConfig.fork!.turnIdMapping); - } - - const session = agentSession.sessionUri; - this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); -- const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }, this._gitService); -- await this._storeSessionMetadata(session, config.model, config.workingDirectory, config.workingDirectory, project, true); -- if (config.agent !== undefined) { -- await this._storeSessionAgentMetadata(session, config.agent); +- const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); +- await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true); +- if (sessionConfig.agent !== undefined) { +- await this._storeSessionAgentMetadata(session, sessionConfig.agent); - } -- return { session, workingDirectory: config.workingDirectory, ...(project ? { project } : {}) }; +- return { session, workingDirectory, ...(project ? { project } : {}) }; - }); - } - @@ -1312,7 +1646,6 @@ index 23d008d0f91..00000000000 - // {@link _materializeProvisional}. Until then this session occupies - // only an in-memory slot plus a state-manager entry, so a workspace - // switch (or quick close) costs nothing on disk. -- const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); - const sessionUri = AgentSession.uri(this.id, sessionId); - - // Idempotency for already-materialized sessions: a duplicate @@ -1323,8 +1656,8 @@ index 23d008d0f91..00000000000 - // message. - if (this._sessions.has(sessionId)) { - this._logService.info(`[Copilot] createSession is a no-op: session already materialized: ${sessionUri.toString()}`); -- const project = await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService); -- return { session: sessionUri, workingDirectory: config.workingDirectory, ...(project ? { project } : {}) }; +- const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); +- return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; - } - - // Idempotent: a duplicate `createSession` for a still-provisional URI @@ -1340,36 +1673,36 @@ index 23d008d0f91..00000000000 - // Seed active-client snapshot if the client claimed it eagerly. This - // runs identically for provisional and real sessions; the SDK side - // of activeClient state isn't engaged until materialization. -- if (config.activeClient) { -- const ac = this._getOrCreateActiveClient(sessionUri, config.workingDirectory); -- ac.updateTools(config.activeClient.clientId, config.activeClient.tools); -- if (config.activeClient.customizations !== undefined) { +- if (sessionConfig.activeClient) { +- const ac = this._getOrCreateActiveClient(sessionUri, workingDirectory); +- ac.updateTools(sessionConfig.activeClient.clientId, sessionConfig.activeClient.tools); +- if (sessionConfig.activeClient.customizations !== undefined) { - // Provisional eager-create: no session-state listener is - // hooked up yet, so suppress action events. The session - // reads the final view via its initial snapshot once it - // materializes. -- await ac.pluginController.sync(config.activeClient.clientId, config.activeClient.customizations, { quiet: true }); +- await ac.pluginController.sync(sessionConfig.activeClient.clientId, sessionConfig.activeClient.customizations, { quiet: true }); - } - } - - // Compute project metadata cheaply from the original working dir. - // Worktrees aren't created until materialization, so the project is - // reported relative to the user's chosen folder. -- const project = await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService); +- const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); - - if (!alreadyProvisional) { - this._provisionalSessions.set(sessionId, { - sessionId, - sessionUri, -- workingDirectory: config.workingDirectory, -- model: config.model, -- agent: config.agent, +- workingDirectory, +- model: sessionConfig.model, +- agent: sessionConfig.agent, - project, - }); - } - - this._logService.info(`[Copilot] Session created (provisional): ${sessionUri.toString()}`); -- return { session: sessionUri, workingDirectory: config.workingDirectory, provisional: true, ...(project ? { project } : {}) }; +- return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) }; - } - - /** @@ -1428,11 +1761,12 @@ index 23d008d0f91..00000000000 - workingDirectory, - resolvedAgentName, - snapshot, +- activeClientState: activeClient.state, - shellManager, - githubToken: this._githubToken, - model: provisional.model, - }; -- agentSession = this._createAgentSession(launchPlan, customizationDirectory); +- agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); - await agentSession.initializeSession(); - this._registerInitializedSession(sessionId, agentSession); - } catch (error) { @@ -1450,7 +1784,7 @@ index 23d008d0f91..00000000000 - } - - // Capture the per-session baseline (turn/0) git checkpoint so -- // per-turn diffs computed on `SessionTurnComplete` can reflect the +- // per-turn diffs computed on `ChatTurnComplete` can reflect the - // full working-tree delta — including terminal-tool edits that are - // invisible to the FileEditTracker pipeline. Best-effort: a - // non-git folder or capture failure leaves the session running @@ -1510,7 +1844,7 @@ index 23d008d0f91..00000000000 - ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), - }); - -- const values = sessionSchema.validateOrDefault(params.config, { +- const values = sessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { - [SessionConfigKey.Isolation]: isolationValue, - [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, @@ -1542,11 +1876,11 @@ index 23d008d0f91..00000000000 - return activeClient.pluginController.sync(clientId, customizations); - } - -- setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { +- setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void { - const sessionId = AgentSession.id(session); - const activeClient = this._getOrCreateActiveClient(session, undefined); - const hasCachedEntry = this._sessions.has(sessionId); -- this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`); +- this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId ?? '(none)'}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`); - activeClient.updateTools(clientId, tools); - } - @@ -1558,8 +1892,10 @@ index 23d008d0f91..00000000000 - while ((parsed = parseSubagentSessionUri(target))) { - target = parsed.parentSession; - } -- const sessionId = AgentSession.id(target); -- const entry = this._sessions.get(sessionId); +- // The completion may belong to a peer chat (tracked in `_chatSessions` +- // keyed by chat URI) rather than the default/parent session. +- const entry = this._sessions.get(AgentSession.id(target)) +- ?? this._chatSessions.get(target.toString()); - entry?.handleClientToolCallComplete(toolCallId, result); - } - @@ -1572,7 +1908,20 @@ index 23d008d0f91..00000000000 - } - } - -- async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise { +- async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { +- // Additional (non-default) chats are backed by their own SDK +- // conversation tracked in `_chatSessions`, keyed by the chat URI. +- if (chat && !isDefaultChatUri(chat)) { +- const entry = await this._ensureChatSession(session, chat); +- if (!entry) { +- throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); +- } +- if (turnId) { +- entry.resetTurnState(turnId); +- } +- await entry.send(prompt, attachments, turnId, this._resolveSdkMode(session)); +- return; +- } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - await this._activeClients.get(session)?.pluginController.retryFailedClientSyncIfNeeded(); @@ -1593,14 +1942,14 @@ index 23d008d0f91..00000000000 - const activeClient = this._activeClients.get(session); - const hadCachedEntry = !!entry; - this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); -- if (entry && activeClient && await activeClient.isOutdated(entry.appliedSnapshot)) { -- this._logService.info(`[Copilot:${sessionId}] Session config changed (isOutdated=true), refreshing session. snapshotClientId=${entry.appliedSnapshot.clientId}`); +- if (entry && activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { +- this._logService.info(`[Copilot:${sessionId}] Session config changed (requiresRestart=true), refreshing session. clientId=${activeClient.state.clientId ?? '(none)'}`); - this._sessions.deleteAndDispose(sessionId); - entry = undefined; - } - - if (!entry) { -- this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by isOutdated)' : ''}, calling _resumeSession`); +- this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); - } - entry ??= await this._resumeSession(sessionId); - @@ -1635,17 +1984,18 @@ index 23d008d0f91..00000000000 - } - - /** -- * Translates the AHP-side `(mode, autoApprove)` pair to the Copilot -- * SDK's three-mode space (`interactive` / `plan` / `autopilot`): +- * Translates the AHP-side `mode` to the Copilot SDK's three-mode space +- * (`interactive` / `plan` / `autopilot`). With Autopilot living on the +- * `mode` axis the mapping is now direct: +- * +- * - `mode='plan'` → SDK `plan`. +- * - `mode='autopilot'` → SDK `autopilot` (autonomous, continue-until-done). +- * - `mode='interactive'` → SDK `interactive`. - * -- * - `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`). +- * Tool auto-approval is governed independently by the orthogonal +- * `autoApprove` axis (Default / Bypass), enforced by the agent +- * host's own permission handler — which the SDK still invokes even under +- * autopilot mode. - * - * Returns `undefined` when no mode is configured for the session, so - * the SDK's current mode is left untouched. @@ -1653,14 +2003,16 @@ index 23d008d0f91..00000000000 - 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'; +- switch (mode) { +- case 'plan': +- return 'plan'; +- case 'autopilot': +- return 'autopilot'; +- case 'interactive': +- return 'interactive'; +- default: +- return undefined; - } -- return undefined; - } - - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { @@ -1677,11 +2029,20 @@ index 23d008d0f91..00000000000 - } - - // Queued messages are consumed by the server (AgentSideEffects) -- // which dispatches SessionTurnStarted and calls sendMessage directly. +- // which dispatches ChatTurnStarted and calls sendMessage directly. - // No SDK-level enqueue is needed. - } - - async getSessionMessages(session: URI): Promise { +- // An additional (non-default) peer chat is addressed by its `ahp-chat` +- // channel URI. Resume its backing SDK conversation and return its turns. +- const chatInfo = parseChatUri(session); +- if (chatInfo && !isDefaultChatUri(session)) { +- const parentSession = URI.parse(chatInfo.session); +- const entry = await this._ensureChatSession(parentSession, session); +- return entry ? entry.getMessages() : []; +- } +- - // If the URI describes a subagent child session (`/subagent/`), - // load the parent's events once and extract the child's filtered turns. - const subagentInfo = parseSubagentSessionUri(session); @@ -1738,6 +2099,13 @@ index 23d008d0f91..00000000000 - async disposeSession(session: URI): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { +- // Remove the session from the SDK's on-disk store first so it doesn't reappear in `listSessions()` after a +- // restart, and so that any final persist triggered by in-memory teardown can't recreate it. Provisional +- // sessions were never persisted, so there is nothing to delete on the SDK side. +- if (!this._provisionalSessions.has(sessionId)) { +- const client = await this._ensureClient(); +- await client.deleteSession(sessionId); +- } - await this._destroyAndDisposeSession(sessionId); - }); - } @@ -1826,7 +2194,11 @@ index 23d008d0f91..00000000000 - } - } - -- async abortSession(session: URI): Promise { +- async abortSession(session: URI, chat?: URI): Promise { +- if (chat && !isDefaultChatUri(chat)) { +- await this._chatSessions.get(chat.toString())?.abort(); +- return; +- } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const entry = this._sessions.get(sessionId); @@ -1836,66 +2208,263 @@ index 23d008d0f91..00000000000 - }); - } - -- async truncateSession(session: URI, turnId?: string): Promise { -- const sessionId = AgentSession.id(session); -- if (this._provisionalSessions.has(sessionId)) { +- async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { +- if (isDefaultChatUri(chat)) { +- return; +- } +- const chatKey = chat.toString(); +- if (this._chatSessions.has(chatKey)) { - return; - } +- const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { -- this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); -- -- // Ensure the session is loaded so we can use the SDK RPC -- const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); -- -- // Look up the SDK event ID for the truncation boundary. -- // The protocol semantics: turnId is the last turn to KEEP. -- // The SDK semantics: eventId and all events after it are removed. -- // So we need the event ID of the *next* turn after turnId. -- // For "remove all", we need the first turn's event ID. -- let eventId: string | undefined; -- if (turnId) { -- eventId = await entry.getNextTurnEventId(turnId); -- } else { -- eventId = await entry.getFirstTurnEventId(); +- // Re-check inside the per-session sequencer: the outer `has` check +- // above is only a fast early-out. If two `createChat` calls for the +- // same chat URI race, both can pass that outer check; the sequencer +- // serializes them, so the second task must re-check here to avoid +- // overwriting (and disposing) the conversation the first one set. +- if (this._chatSessions.has(chatKey)) { +- return; - } -- -- if (eventId) { -- await entry.truncateAtEventId(eventId, turnId); -- } else { -- this._logService.info(`[Copilot:${sessionId}] No event ID found for truncation, nothing to truncate`); +- // Resolve the owning session so the new chat inherits its working +- // directory scope. The parent may be provisional (no SDK session +- // yet); in that case use its provisional working directory. +- const parentEntry = this._sessions.get(sessionId); +- const workingDirectory = parentEntry?.workingDirectory +- ?? this._provisionalSessions.get(sessionId)?.workingDirectory; +- const client = await this._ensureClient(); +- const chatSdkId = generateUuid(); +- // Peer chats share the owning session's ActiveClient so that +- // client tool / customization updates (which are keyed by the +- // session URI via `setClientTools` / `setClientCustomizations`) +- // reach the additional chat's SDK conversation. Keying it by the +- // chat URI instead would snapshot empty/stale tools and never see +- // subsequent updates, and would also leak (nothing disposes a +- // chat-keyed ActiveClient). +- const activeClient = this._getOrCreateActiveClient(session, workingDirectory); +- const snapshot = await activeClient.snapshot(); +- const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); +- const launchPlan: CopilotSessionLaunchPlan = { +- kind: 'create', +- client, +- sessionId: chatSdkId, +- workingDirectory, +- resolvedAgentName: undefined, +- snapshot, +- activeClientState: activeClient.state, +- shellManager, +- githubToken: this._githubToken, +- model: options?.model, +- }; +- let agentSession: CopilotAgentSession | undefined; +- try { +- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); +- await agentSession.initializeSession(); +- this._chatSessions.set(chatKey, agentSession); +- const parsed = parseChatUri(chat); +- if (parsed) { +- const persisted = await this._readPersistedChats(session); +- persisted.set(parsed.chatId, { sdkSessionId: chatSdkId, ...(options?.model ? { model: options.model } : {}) }); +- await this._writePersistedChats(session, persisted); +- } +- this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}`); +- } catch (error) { +- agentSession?.dispose(); +- throw error; - } -- -- this._logService.info(`[Copilot:${sessionId}] Session truncated`); - }); - } - -- async changeModel(session: URI, model: ModelSelection): Promise { -- const sessionId = AgentSession.id(session); -- const provisional = this._provisionalSessions.get(sessionId); -- if (provisional) { -- provisional.model = model; +- async disposeChat(session: URI, chat: URI): Promise { +- if (isDefaultChatUri(chat)) { - return; - } -- const entry = this._sessions.get(sessionId); -- if (entry) { -- await entry.setModel(model.id, getCopilotReasoningEffort(model)); +- const chatKey = chat.toString(); +- // Resolve the chat's backing SDK conversation id — from the in-memory +- // session if present, otherwise from the persisted catalog — so we can +- // delete it from the SDK's on-disk store. Without this a fresh process +- // could re-resume an orphaned conversation that no longer has a catalog +- // entry. Best-effort: a missing id still drops the catalog entry below. +- const parsed = parseChatUri(chat); +- let sdkSessionId = this._chatSessions.get(chatKey)?.sessionId; +- if (parsed) { +- const persisted = await this._readPersistedChats(session); +- sdkSessionId ??= persisted.get(parsed.chatId)?.sdkSessionId; +- if (persisted.delete(parsed.chatId)) { +- await this._writePersistedChats(session, persisted); +- } +- } +- this._chatSessions.deleteAndDispose(chatKey); +- if (sdkSessionId) { +- try { +- const client = await this._ensureClient(); +- await client.deleteSession(sdkSessionId); +- } catch (err) { +- this._logService.warn(`[Copilot] Failed to delete SDK session for chat ${chatKey}: ${err instanceof Error ? err.message : String(err)}`); +- } - } -- await this._storeSessionMetadata(session, model, undefined, undefined, undefined); - } - -- async changeAgent(session: URI, agent: AgentSelection | undefined): Promise { -- const sessionId = AgentSession.id(session); -- const provisional = this._provisionalSessions.get(sessionId); -- if (provisional) { -- provisional.agent = agent; -- return; +- /** +- * Returns the catalog of additional (non-default) peer chats persisted for a +- * session, as `ahp-chat` channel URIs. Used by the agent service to +- * re-register peer chats (and seed their history) when a session is restored +- * after a process restart. +- */ +- async getChats(session: URI): Promise { +- const persisted = await this._readPersistedChats(session); +- const result: URI[] = []; +- for (const chatId of persisted.keys()) { +- result.push(URI.parse(buildChatUri(session.toString(), chatId))); - } -- const entry = this._sessions.get(sessionId); -- if (entry) { -- // Resolve the URI → SDK name from the session's currently-applied -- // plugin snapshot. If the agent is no longer present (plugin -- // removed, never loaded), pass `undefined` so the SDK clears its -- // selection rather than silently keeping the previous one. +- return result; +- } +- +- /** +- * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer +- * chat, resuming its persisted SDK conversation if it is not already in +- * memory (e.g. after a process restart). Returns `undefined` when the chat +- * has no persisted backing conversation. +- */ +- private async _ensureChatSession(session: URI, chat: URI): Promise { +- const chatKey = chat.toString(); +- const existing = this._chatSessions.get(chatKey); +- if (existing) { +- return existing; +- } +- const parsed = parseChatUri(chat); +- if (!parsed) { +- return undefined; +- } +- const sessionId = AgentSession.id(session); +- return this._sessionSequencer.queue(sessionId, async () => { +- const again = this._chatSessions.get(chatKey); +- if (again) { +- return again; +- } +- const persisted = await this._readPersistedChats(session); +- const info = persisted.get(parsed.chatId); +- if (!info) { +- return undefined; +- } +- const parentEntry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); +- const workingDirectory = parentEntry?.workingDirectory +- ?? this._provisionalSessions.get(sessionId)?.workingDirectory; +- if (!workingDirectory) { +- this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); +- return undefined; +- } +- const client = await this._ensureClient(); +- const activeClient = this._getOrCreateActiveClient(session, workingDirectory); +- const snapshot = await activeClient.snapshot(); +- const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); +- const launchPlan: CopilotSessionLaunchPlan = { +- kind: 'resume', +- client, +- sessionId: info.sdkSessionId, +- workingDirectory, +- resolvedAgentName: undefined, +- snapshot, +- activeClientState: activeClient.state, +- shellManager, +- githubToken: this._githubToken, +- fallback: { model: info.model }, +- }; +- let agentSession: CopilotAgentSession | undefined; +- try { +- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); +- await agentSession.initializeSession(); +- this._chatSessions.set(chatKey, agentSession); +- this._logService.info(`[Copilot] Resumed additional chat ${chatKey} in session ${session.toString()}`); +- return agentSession; +- } catch (error) { +- agentSession?.dispose(); +- this._logService.warn(`[Copilot] Failed to resume additional chat ${chatKey}: ${error instanceof Error ? error.message : String(error)}`); +- return undefined; +- } +- }); +- } +- +- async truncateSession(session: URI, turnId?: string): Promise { +- const sessionId = AgentSession.id(session); +- if (this._provisionalSessions.has(sessionId)) { +- return; +- } +- await this._sessionSequencer.queue(sessionId, async () => { +- this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); +- +- // Ensure the session is loaded so we can use the SDK RPC +- const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); +- +- // Look up the SDK event ID for the truncation boundary. +- // The protocol semantics: turnId is the last turn to KEEP. +- // The SDK semantics: eventId and all events after it are removed. +- // So we need the event ID of the *next* turn after turnId. +- // For "remove all", we need the first turn's event ID. +- let eventId: string | undefined; +- if (turnId) { +- eventId = await entry.getNextTurnEventId(turnId); +- } else { +- eventId = await entry.getFirstTurnEventId(); +- } +- +- if (eventId) { +- await entry.truncateAtEventId(eventId, turnId); +- } else { +- this._logService.info(`[Copilot:${sessionId}] No event ID found for truncation, nothing to truncate`); +- } +- +- this._logService.info(`[Copilot:${sessionId}] Session truncated`); +- }); +- } +- +- async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { +- // Additional (non-default) chats are backed by their own SDK +- // conversation tracked in `_chatSessions`; apply the change there and +- // skip the session-level metadata store (peer chats are not persisted +- // per-chat). +- if (chat && !isDefaultChatUri(chat)) { +- await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); +- return; +- } +- const sessionId = AgentSession.id(session); +- const provisional = this._provisionalSessions.get(sessionId); +- if (provisional) { +- provisional.model = model; +- return; +- } +- const entry = this._sessions.get(sessionId); +- if (entry) { +- await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); +- } +- await this._storeSessionMetadata(session, model, undefined, undefined, undefined); +- } +- +- async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise { +- // Additional (non-default) chats own their SDK conversation in +- // `_chatSessions`. Apply the agent to that conversation (resolving the +- // URI → SDK name against its own applied snapshot) and skip the +- // session-level metadata store. +- if (chat && !isDefaultChatUri(chat)) { +- const chatEntry = this._chatSessions.get(chat.toString()); +- if (chatEntry) { +- const resolvedAgentName = agent ? await this._resolveAgentName(session, chatEntry.appliedSnapshot, agent) : undefined; +- await chatEntry.setAgent(resolvedAgentName); +- } +- return; +- } +- const sessionId = AgentSession.id(session); +- const provisional = this._provisionalSessions.get(sessionId); +- if (provisional) { +- provisional.agent = agent; +- return; +- } +- const entry = this._sessions.get(sessionId); +- if (entry) { +- // Resolve the URI → SDK name from the session's currently-applied +- // plugin snapshot. If the agent is no longer present (plugin +- // removed, never loaded), pass `undefined` so the SDK clears its +- // selection rather than silently keeping the previous one. - const resolvedAgentName = agent ? await this._resolveAgentName(session, entry.appliedSnapshot, agent) : undefined; - await entry.setAgent(resolvedAgentName); - } @@ -1921,14 +2490,24 @@ index 23d008d0f91..00000000000 - return; - } - } +- for (const [, chat] of this._chatSessions) { +- if (chat.respondToPermissionRequest(requestId, approved)) { +- return; +- } +- } - } - -- respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void { +- respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void { - for (const [, session] of this._sessions) { - if (session.respondToUserInputRequest(requestId, response, answers)) { - return; - } - } +- for (const [, chat] of this._chatSessions) { +- if (chat.respondToUserInputRequest(requestId, response, answers)) { +- return; +- } +- } - } - - /** @@ -1942,11 +2521,25 @@ index 23d008d0f91..00000000000 - - // ---- helpers ------------------------------------------------------------ - +- /** +- * Disposes every peer chat (tracked in {@link _chatSessions}) whose +- * owning session matches `sessionId`. The chat URI encodes its parent +- * session, so we recover it via {@link parseChatUri}. +- */ +- private _disposeChildChats(sessionId: string): void { +- for (const chatKey of [...this._chatSessions.keys()]) { +- const parsed = parseChatUri(URI.parse(chatKey)); +- if (parsed && AgentSession.id(parsed.session) === sessionId) { +- this._chatSessions.deleteAndDispose(chatKey); +- } +- } +- } +- - private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient { - let client = this._activeClients.get(session); - if (!client) { - const pluginController = this._plugins.createSessionController(directory); -- client = new ActiveClient(session, pluginController, this._onDidSessionProgress); +- client = this._instantiationService.createInstance(ActiveClient, session, pluginController, this._onDidSessionProgress); - this._activeClients.set(session, client); - } else if (directory) { - client.pluginController.setDirectory(directory); @@ -1962,8 +2555,8 @@ index 23d008d0f91..00000000000 - * {@link _resumeSession} for the same id cannot dispose this entry mid-init - * via {@link DisposableMap.set}. - */ -- private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined): CopilotAgentSession { -- const sessionUri = AgentSession.uri(this.id, launchPlan.sessionId); +- private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, channelUri?: URI): CopilotAgentSession { +- const sessionUri = channelUri ?? AgentSession.uri(this.id, launchPlan.sessionId); - - const agentSession = this._instantiationService.createInstance( - CopilotAgentSession, @@ -1977,9 +2570,14 @@ index 23d008d0f91..00000000000 - workingDirectory: launchPlan.workingDirectory, - customizationDirectory, - clientSnapshot: launchPlan.snapshot, +- activeClientState: launchPlan.activeClientState, +- resolveMcpChildId: name => findMcpChildId(activeClient.pluginController.getCustomizations(), name), +- serverToolHost: this._serverToolHost, - }, - ); - +- this._mcpNotificationSubs.set(launchPlan.sessionId, agentSession.onMcpNotification(n => this._onMcpNotification.fire(n))); +- - return agentSession; - } - @@ -2003,6 +2601,10 @@ index 23d008d0f91..00000000000 - } - - private async _destroyAndDisposeSession(sessionId: string): Promise { +- // Tear down any peer chats owned by this session first so their SDK +- // conversations don't leak when the parent is deleted/disposed +- // without each chat being individually disposed via `disposeChat`. +- this._disposeChildChats(sessionId); - // Provisional sessions have no SDK session, no worktree, and no - // on-disk metadata — drop the in-memory record and clean up the - // active-client snapshot. The state-manager entry is removed by the @@ -2024,6 +2626,7 @@ index 23d008d0f91..00000000000 - } - } - this._sessions.deleteAndDispose(sessionId); +- this._mcpNotificationSubs.deleteAndDispose(sessionId); - this._activeClients.get(sessionUri)?.dispose(); - this._activeClients.delete(sessionUri); - await this._removeCreatedWorktree(sessionId); @@ -2075,6 +2678,7 @@ index 23d008d0f91..00000000000 - workingDirectory, - resolvedAgentName, - snapshot, +- activeClientState: activeClient.state, - shellManager, - githubToken: this._githubToken, - fallback: { @@ -2082,7 +2686,7 @@ index 23d008d0f91..00000000000 - }, - }; - -- const agentSession = this._createAgentSession(launchPlan, customizationDirectory); +- const agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); - try { - await agentSession.initializeSession(); - } catch (err) { @@ -2119,8 +2723,7 @@ index 23d008d0f91..00000000000 - } - - const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); -- const branchNameHint = prompt ? getCopilotBranchNameHintFromMessage(prompt) : undefined; -- const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint); +- const branchName = await this._branchNameGenerator.generateBranchName({ sessionId, message: prompt, githubToken: this._githubToken }); - const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); - const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; @@ -2167,6 +2770,66 @@ index 23d008d0f91..00000000000 - private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; - private static readonly _META_WORKTREE_PATH = 'copilot.worktree.path'; - private static readonly _META_WORKTREE_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; +- /** Persisted catalog of additional (non-default) peer chats, keyed by chatId. */ +- private static readonly _META_CHATS = 'copilot.chats'; +- +- /** +- * Reads the persisted peer-chat catalog for a session. Each entry maps a +- * chatId (the `ahp-chat` authority) to the SDK conversation that backs it +- * (and its optional model override), so the chat can be resumed after a +- * restart even though {@link _chatSessions} is empty in a fresh process. +- */ +- private async _readPersistedChats(session: URI): Promise> { +- const ref = await this._sessionDataService.tryOpenDatabase(session); +- if (!ref) { +- return new Map(); +- } +- try { +- const raw = await ref.object.getMetadata(CopilotAgent._META_CHATS); +- if (!raw) { +- return new Map(); +- } +- const parsed = JSON.parse(raw) as Record; +- const result = new Map(); +- for (const [chatId, value] of Object.entries(parsed)) { +- // The metadata blob is client-influenced and may be corrupted or +- // tampered: drop entries that don't carry a usable SDK session id +- // rather than letting an invalid id reach `client.deleteSession`. +- if (!value || typeof value !== 'object') { +- continue; +- } +- const { sdkSessionId, model } = value as { sdkSessionId?: unknown; model?: unknown }; +- if (typeof sdkSessionId !== 'string' || !sdkSessionId) { +- continue; +- } +- result.set(chatId, { sdkSessionId, ...(model ? { model: model as ModelSelection } : {}) }); +- } +- return result; +- } catch (err) { +- this._logService.warn(`[Copilot] Failed to read persisted chats for ${session.toString()}: ${err instanceof Error ? err.message : String(err)}`); +- return new Map(); +- } finally { +- ref.dispose(); +- } +- } +- +- /** Writes the persisted peer-chat catalog for a session. */ +- private async _writePersistedChats(session: URI, chats: Map): Promise { +- const dbRef = this._sessionDataService.openDatabase(session); +- try { +- // Use a null-prototype object: chatIds derive from a client-chosen +- // chat URI authority, so a value like `__proto__` would otherwise +- // pollute the prototype / corrupt the serialized payload. +- const obj: Record = Object.create(null); +- for (const [chatId, info] of chats) { +- obj[chatId] = info; +- } +- await dbRef.object.setMetadata(CopilotAgent._META_CHATS, JSON.stringify(obj)); +- } finally { +- dbRef.dispose(); +- } +- } +- - - private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { - const dbRef = this._sessionDataService.openDatabase(session); @@ -2360,13 +3023,15 @@ index 23d008d0f91..00000000000 - readonly input?: ClientPluginCustomization; -} - +-const REFRESH_DEBOUNCE_MS = 100; +- -/** - * A per-working-directory bundle of customizations the agent host - * discovered itself from disk (workspace + user-home conventions). - * - * Owns a {@link SessionCustomizationDiscovery} (filesystem scan + -- * watchers) and a {@link SessionPluginBundler} (in-memory synthetic -- * Open Plugin under the `vscode-synced-customization:` scheme). +- * watchers) and maps discovered files into an in-memory +- * {@link IParsedPlugin} while preserving original file URIs. - * - * Refreshes itself when the discovery fires `onDidChange`. The owning - * {@link PluginController} is notified via the supplied `onDidRefresh` @@ -2376,27 +3041,26 @@ index 23d008d0f91..00000000000 - */ -class SessionDiscoveredEntry extends Disposable { - +- - private readonly _discovery: SessionCustomizationDiscovery; -- private readonly _bundler: SessionPluginBundler; -- private readonly _refreshThrottler = this._register(new Throttler()); -- private _refreshCancellationSource: CancellationTokenSource | undefined; +- private readonly _refreshDelayer = this._register(new Delayer(REFRESH_DEBOUNCE_MS)); +- private _refreshPromise: CancelablePromise | null = null; +- private _pendingRefreshNotify = false; - - private _customizations: readonly DirectoryCustomization[] = []; -- private _plugin: IParsedPlugin | undefined; +- private _directories: readonly IDiscoveredDirectory[] | undefined; - private _settled: Promise; - private readonly _fileService: IFileService; - - constructor( - workingDirectory: URI, - userHome: URI, -- private readonly _resolvePlugin: (uri: URI) => Promise, - private readonly _onDidRefresh: () => void, - private readonly _logService: ILogService, - instantiationService: IInstantiationService, - ) { - super(); - this._discovery = this._register(instantiationService.createInstance(SessionCustomizationDiscovery, workingDirectory, userHome)); -- this._bundler = this._register(instantiationService.createInstance(SessionPluginBundler, workingDirectory)); - this._fileService = instantiationService.invokeFunction(accessor => accessor.get(IFileService)); - this._settled = this._queueRefresh(false); - this._register(this._discovery.onDidChange(() => { @@ -2405,8 +3069,8 @@ index 23d008d0f91..00000000000 - } - - override dispose(): void { -- this._refreshCancellationSource?.dispose(true); -- this._refreshCancellationSource = undefined; +- this._refreshPromise?.cancel(); +- this._refreshPromise = null; - super.dispose(); - } - @@ -2418,73 +3082,75 @@ index 23d008d0f91..00000000000 - return this._customizations; - } - -- currentPlugin(): IParsedPlugin | undefined { -- return this._plugin; -- } -- - private _queueRefresh(notify: boolean): Promise { -- this._refreshCancellationSource?.cancel(); -- return this._refreshThrottler.queue(async throttlerToken => { -- const refreshCancellationSource = new CancellationTokenSource(throttlerToken); -- this._refreshCancellationSource = refreshCancellationSource; -- try { -- const didRefresh = await this._refresh(refreshCancellationSource.token); -- if (didRefresh && notify && !refreshCancellationSource.token.isCancellationRequested) { +- this._refreshPromise?.cancel(); +- this._refreshPromise = null; +- this._pendingRefreshNotify = this._pendingRefreshNotify || notify; +- +- return this._refreshDelayer.trigger(() => { +- const shouldNotify = this._pendingRefreshNotify; +- this._pendingRefreshNotify = false; +- +- const refreshPromise = this._refreshPromise = createCancelablePromise(async token => { +- const didRefresh = await this._refresh(token); +- if (didRefresh && shouldNotify) { - this._onDidRefresh(); - } -- } finally { -- if (this._refreshCancellationSource === refreshCancellationSource) { -- this._refreshCancellationSource = undefined; +- }); +- +- return refreshPromise.then(() => { +- if (this._refreshPromise === refreshPromise) { +- this._refreshPromise = null; - } -- refreshCancellationSource.dispose(); -- } +- }, err => { +- if (this._refreshPromise === refreshPromise) { +- this._refreshPromise = null; +- } +- if (err instanceof CancellationError) { +- return; +- } +- throw err; +- }); - }); - } - - private async _refresh(token: CancellationToken): Promise { - try { -- const directories = await this._discovery.directories(); +- const directories = await this._discovery.scan(token); - if (token.isCancellationRequested) { - return false; - } - -- const customizations = await toDiscoveredDirectoryCustomizations(directories, this._fileService); -- if (token.isCancellationRequested) { +- if (this._directories && areDiscoveredDirectoriesEqual(this._directories, directories)) { - return false; - } - -- const bundleResult = await this._bundler.bundle(directories, token); +- const customizations = await toDiscoveredDirectoryCustomizations(directories, this._fileService); - if (token.isCancellationRequested) { - return false; - } - -- // Don't update `_customizations` / `_plugin` when cancelled. +- // Don't update `_customizations` / `_directories` when cancelled. - // Otherwise a cancelled refresh could temporarily clear them and cause callers to see empty customizations. -- if (!bundleResult) { -- this._customizations = customizations; -- this._plugin = undefined; -- } else { -- const pluginDir = URI.parse(bundleResult.ref.uri); -- const plugin = await this._resolvePlugin(pluginDir); -- this._customizations = customizations; -- this._plugin = plugin; -- } +- this._customizations = customizations; +- this._directories = directories; - return true; - } catch (err) { -- // Don't update `_customizations` / `_plugin` when cancelled. +- // Don't update `_customizations` / `_directories` when cancelled. - // Otherwise a cancelled refresh could temporarily clear them and cause callers to see empty customizations. - if (token.isCancellationRequested) { - return false; - } - this._logService.warn(`[Copilot:SessionDiscoveredEntry] Discovery/bundle failed: ${err instanceof Error ? err.message : String(err)}`); +- const hadState = this._customizations.length > 0 || this._directories !== undefined; - this._customizations = []; -- this._plugin = undefined; -- return true; +- this._directories = undefined; +- return hadState; - } - } -} - --function toDiscoveredDirectoryCustomizations(directories: readonly IDiscoveredDirectory[], fileService: IFileService): Promise { +-export function toDiscoveredDirectoryCustomizations(directories: readonly IDiscoveredDirectory[], fileService: IFileService): Promise { - return Promise.all(directories.map(async directory => { - const protocolUri = directory.uri.toString(); - return { @@ -2496,7 +3162,7 @@ index 23d008d0f91..00000000000 - contents: toDirectoryContentsType(directory.type), - writable: false, - load: { kind: CustomizationLoadStatus.Loaded }, -- children: await Promise.all(directory.files.map(file => toDiscoveredChildCustomization(file, directory.type, fileService))), +- children: await Promise.all(directory.files.map(file => toDiscoveredChildCustomization(file.uri, directory.type, fileService))), - }; - })); -} @@ -2510,6 +3176,8 @@ index 23d008d0f91..00000000000 - case DiscoveredType.Instruction: - case DiscoveredType.AgentInstruction: - return CustomizationType.Rule; +- case DiscoveredType.Hook: +- return CustomizationType.Hook; - } -} - @@ -2518,31 +3186,50 @@ index 23d008d0f91..00000000000 - const id = customizationId(uri); - if (type === DiscoveredType.Agent) { - const agentInfo = await parseAgentFile(file, fileService); -- return { +- const agentCustomization: AgentCustomization = { - type: CustomizationType.Agent, - id, - uri, - name: agentInfo.name, -- ...(agentInfo.description ? { description: agentInfo.description } : {}), -- }; +- description: agentInfo.description, +- } satisfies AgentCustomization; +- if (agentInfo.userInvocable !== undefined) { +- agentCustomization._meta = { userInvocable: agentInfo.userInvocable }; +- } +- return agentCustomization; - } - if (type === DiscoveredType.Skill) { - const skillInfo = await parseSkillFile(file, fileService); -- return { +- const skillCustomization: SkillCustomization = { - type: CustomizationType.Skill, - id, - uri, -- name: resourceBasename(resourceDirname(file)), -- ...(skillInfo.description ? { description: skillInfo.description } : {}), +- name: skillInfo.name, +- description: skillInfo.description, - }; +- return skillCustomization; - } - if (type === DiscoveredType.Instruction) { -- return { +- const ruleInfo = await parseRuleFile(file, fileService); +- const ruleCustomization: RuleCustomization = { - type: CustomizationType.Rule, - id, - uri, +- name: ruleInfo.name, +- description: ruleInfo.description, +- globs: ruleInfo.globs, +- alwaysApply: ruleInfo.alwaysApply, +- }; +- return ruleCustomization; +- } +- if (type === DiscoveredType.Hook) { +- const hookCustomization: HookCustomization = { +- type: CustomizationType.Hook, +- id, +- uri, - name: resourceBasename(file), - }; +- return hookCustomization; - } - // agent instruction - return { @@ -2554,6 +3241,69 @@ index 23d008d0f91..00000000000 - }; -} - +- +-/** +- * Projects already-parsed discovered customizations into an in-memory +- * {@link IParsedPlugin} while preserving original source URIs. +- */ +-export function mapToParsedPlugin(customizations: readonly DirectoryCustomization[]): IParsedPlugin | undefined { +- if (customizations.length === 0) { +- return undefined; +- } +- +- const agents: IParsedAgent[] = []; +- const skills: IParsedSkill[] = []; +- const instructions: IParsedRule[] = []; +- +- for (const directory of customizations) { +- for (const child of directory.children ?? []) { +- if (child.type === CustomizationType.Agent) { +- agents.push({ +- uri: URI.parse(child.uri), +- name: child.name, +- description: child.description, +- customization: child, +- }); +- continue; +- } +- +- if (child.type === CustomizationType.Skill) { +- skills.push({ +- uri: URI.parse(child.uri), +- name: child.name, +- description: child.description, +- customization: child, +- }); +- continue; +- } +- +- if (child.type === CustomizationType.Rule) { +- if (child.alwaysApply && child.name.match(/\.md$/i)) { +- continue; // agent instruction +- } +- instructions.push({ +- uri: URI.parse(child.uri), +- name: child.name, +- description: child.description, +- customization: child, +- }); +- } +- } +- } +- +- if (agents.length === 0 && skills.length === 0 && instructions.length === 0) { +- return undefined; +- } +- +- return { +- hooks: [], +- mcpServers: [], +- skills: skills, +- agents: agents, +- instructions: instructions, +- }; +-} +- -/** - * Process-wide plugin state shared across all sessions. - * @@ -2823,7 +3573,7 @@ index 23d008d0f91..00000000000 - ]); - - const discovered = entry?.currentCustomizations() ?? []; -- const sessionPlugin = discovered.some(customization => this._isEnabled(customization)) ? entry?.currentPlugin() : undefined; +- const sessionPlugin = discovered.some(customization => this._isEnabled(customization)) ? mapToParsedPlugin(discovered) : undefined; - const sessionPlugins: IParsedPlugin[] = sessionPlugin ? [sessionPlugin] : []; - - return [ @@ -2956,7 +3706,6 @@ index 23d008d0f91..00000000000 - this._sessionDiscovered.value = new SessionDiscoveredEntry( - this._directory, - URI.file(this._parent.getUserHome()), -- uri => this._parent.tryParsePlugin(uri), - () => this._onDidPublish.fire({ - type: ActionType.SessionCustomizationsChanged, - customizations: [...this.getCustomizations()], @@ -2986,8 +3735,13 @@ index 23d008d0f91..00000000000 - * tears down the controller and any disk watchers it created. - */ -class ActiveClient extends Disposable { -- private _tools: readonly ToolDefinition[] = []; -- private _clientId = ''; +- /** +- * Live holder of the owning `clientId` and contributed tools. Shared by +- * reference with the session's {@link CopilotAgentSession} so a window +- * reload (new `clientId`, identical tools) is reflected at tool-call +- * stamp time without restarting the SDK session. +- */ +- readonly state = new ActiveClientState(); - - public readonly pluginController: SessionPluginController; - @@ -2995,6 +3749,7 @@ index 23d008d0f91..00000000000 - private readonly _sessionUri: URI, - pluginController: SessionPluginController, - onDidSessionProgress: Emitter, +- @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - ) { - super(); - this.pluginController = this._register(pluginController); @@ -3005,59 +3760,65 @@ index 23d008d0f91..00000000000 - })); - } - -- updateTools(clientId: string, tools: readonly ToolDefinition[]): void { -- this._clientId = clientId; -- this._tools = tools; +- updateTools(clientId: string | undefined, tools: readonly ToolDefinition[]): void { +- this.state.update(clientId, tools); - } - - async snapshot(): Promise { -- return { clientId: this._clientId, tools: this._tools, plugins: await this.pluginController.getAppliedPlugins() }; +- return { +- tools: this.state.tools, +- plugins: await this.pluginController.getAppliedPlugins(), +- mcpServers: this._getMcpServers(), +- }; +- } +- +- private _getMcpServers(): AgentHostMcpServers { +- const servers = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey) ?? {}; +- +- return structuredClone(servers); - } - -- async isOutdated(snap: IActiveClientSnapshot): Promise { +- /** +- * Returns `true` when the SDK session must be disposed and resumed to +- * pick up a changed config. Compares ONLY plugins and the structural +- * tool set (name + description + inputSchema). The `clientId` is +- * deliberately excluded — a clientId-only change is reflected live via +- * {@link state} and never requires a restart. +- */ +- async requiresRestart(snap: IActiveClientSnapshot): Promise { - const plugins = await this.pluginController.getAppliedPlugins(); - if (!parsedPluginsEqual(snap.plugins, plugins)) { - return true; - } -- if (snap.tools.length !== this._tools.length) { +- if (!equals(snap.mcpServers, this._getMcpServers())) { - return true; - } -- // Compare tool definitions by name, description, and schema — -- // not just names — so schema/description changes trigger a refresh. -- const snapByName = new Map(snap.tools.map(t => [t.name, t])); -- for (const tool of this._tools) { -- const prev = snapByName.get(tool.name); -- if (!prev -- || prev.description !== tool.description -- || !equals(prev.inputSchema, tool.inputSchema)) { -- return true; -- } -- } -- return false; +- return !this.state.structuralEquals({ tools: snap.tools }); - } -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts deleted file mode 100644 -index 770817327b0..00000000000 +index a0c0a765..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ /dev/null -@@ -1,2389 +0,0 @@ +@@ -1,3141 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - --import type { ExitPlanModeRequest, MessageOptions, PermissionRequestResult, SessionConfig, Tool, ToolResultObject } from '@github/copilot-sdk'; +-import type { CopilotSession, ExitPlanModeRequest, MessageOptions, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; --import { CancellationError } from '../../../../base/common/errors.js'; +-import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; +-import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { isAbsolute, join } from '../../../../base/common/path.js'; -import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; -import { splitLinesIncludeSeparators } from '../../../../base/common/strings.js'; --import { hasKey, isDefined, isObject, isString } from '../../../../base/common/types.js'; +-import { hasKey, isDefined, isObject, isString, type Mutable } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { localize } from '../../../../nls.js'; @@ -3067,40 +3828,69 @@ index 770817327b0..00000000000 -import { ILogService } from '../../../log/common/log.js'; -import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +-import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { platformSessionSchema } from '../../common/agentHostSchema.js'; --import { AgentSignal } from '../../common/agentService.js'; +-import { AgentSignal, IMcpNotification } from '../../common/agentService.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; +-import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; +-import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; -import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../../telemetry/common/languageModelToolTelemetry.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +-import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; -import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; -import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; --import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; --import { MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputOption, type SessionInputQuestion, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +-import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; +-import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import type { IExitPlanModeResponse } from './copilotAgent.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import type { CopilotSessionLaunchPlan, IActiveClientSnapshot, ICopilotSessionLauncher, ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +-import { ActiveClientState } from '../activeClientState.js'; +-import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; --import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; +-import { isPromptInvokedCopilotSlashCommand, isRuntimeCopilotSlashCommand, parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; -import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; --import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +-import { buildSandboxConfigForSdk } from './sandboxConfigForSdk.js'; +-import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; +-import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; -import { FileEditTracker } from '../shared/fileEditTracker.js'; +-import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; +-import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; -import { mapSessionEvents } from './mapSessionEvents.js'; -import { buildPendingEditContentUri } from './pendingEditContentStore.js'; +-import { McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.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. +- * The full set of agent modes the Copilot SDK accepts. AHP now exposes the +- * same three modes (`interactive` / `plan` / `autopilot`) on its `mode` axis, +- * so the Copilot agent maps between the two views directly in +- * {@link CopilotAgentSession.send} and the `session.mode_changed` listener. - */ -export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; -type CopilotSdkAttachment = Required['attachments'][number]; +-type CopilotCommandInvocationResult = Awaited>; +-type RuntimeSlashCommandCache = { readonly expiresAt: number; readonly promise: Promise> }; - -const COPILOT_HOME_DIRECTORY = '.copilot'; -const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); +-const EMPTY_TOOL_RESULT_TEXT = ''; +-const RUNTIME_SLASH_COMMAND_CACHE_TTL_MS = 30_000; +- +-function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string { +- if (!binaryResults?.length) { +- return EMPTY_TOOL_RESULT_TEXT; +- } +- +- const hasImage = binaryResults.some(result => result.type === 'image'); +- const hasFile = binaryResults.some(result => result.type === 'resource'); +- if (hasImage && hasFile) { +- return 'Tool produced the attached image and file'; +- } +- if (hasImage) { +- return 'Tool produced the attached image'; +- } +- return 'Tool produced the attached file'; +-} - -/** - * Display labels and descriptions for the SDK's `exit_plan_mode` action ids. @@ -3152,12 +3942,23 @@ index 770817327b0..00000000000 - return isString(command) ? command : undefined; -} - +-function toCopilotSdkMode(mode: string | undefined): CopilotSdkMode | undefined { +- switch (mode) { +- case 'interactive': +- case 'plan': +- case 'autopilot': +- return mode; +- default: +- return undefined; +- } +-} +- -/** - * Projects an {@link ElicitationSchema} field into a -- * {@link SessionInputQuestion}. The schema's property key becomes the +- * {@link ChatInputQuestion}. The schema's property key becomes the - * question id so we can route the answer back by field name. - */ --function elicitationFieldToQuestion(fieldName: string, field: ElicitationSchemaField, required: boolean): SessionInputQuestion { +-function elicitationFieldToQuestion(fieldName: string, field: ElicitationSchemaField, required: boolean): ChatInputQuestion { - const base = { - id: fieldName, - title: field.title ?? fieldName, @@ -3167,23 +3968,23 @@ index 770817327b0..00000000000 - - switch (field.type) { - case 'boolean': -- return { ...base, kind: SessionInputQuestionKind.Boolean, defaultValue: field.default }; +- return { ...base, kind: ChatInputQuestionKind.Boolean, defaultValue: field.default }; - case 'integer': - case 'number': - return { - ...base, -- kind: field.type === 'integer' ? SessionInputQuestionKind.Integer : SessionInputQuestionKind.Number, +- kind: field.type === 'integer' ? ChatInputQuestionKind.Integer : ChatInputQuestionKind.Number, - min: field.minimum, - max: field.maximum, - defaultValue: field.default, - }; - case 'array': { -- const options: SessionInputOption[] = hasKey(field.items, { enum: true }) +- const options: ChatInputOption[] = hasKey(field.items, { enum: true }) - ? field.items.enum.map(value => ({ id: value, label: value })) - : field.items.anyOf.map(option => ({ id: option.const, label: option.title })); - return { - ...base, -- kind: SessionInputQuestionKind.MultiSelect, +- kind: ChatInputQuestionKind.MultiSelect, - options, - min: field.minItems, - max: field.maxItems, @@ -3192,16 +3993,16 @@ index 770817327b0..00000000000 - case 'string': { - if (hasKey(field, { enum: true })) { - const enumNames = field.enumNames; -- const options: SessionInputOption[] = field.enum.map((value, idx) => ({ id: value, label: enumNames?.[idx] ?? value })); -- return { ...base, kind: SessionInputQuestionKind.SingleSelect, options }; +- const options: ChatInputOption[] = field.enum.map((value, idx) => ({ id: value, label: enumNames?.[idx] ?? value })); +- return { ...base, kind: ChatInputQuestionKind.SingleSelect, options }; - } - if (hasKey(field, { oneOf: true })) { -- const options: SessionInputOption[] = field.oneOf.map(option => ({ id: option.const, label: option.title })); -- return { ...base, kind: SessionInputQuestionKind.SingleSelect, options }; +- const options: ChatInputOption[] = field.oneOf.map(option => ({ id: option.const, label: option.title })); +- return { ...base, kind: ChatInputQuestionKind.SingleSelect, options }; - } - return { - ...base, -- kind: SessionInputQuestionKind.Text, +- kind: ChatInputQuestionKind.Text, - format: field.format, - min: field.minLength, - max: field.maxLength, @@ -3212,19 +4013,19 @@ index 770817327b0..00000000000 -} - -/** -- * Projects a {@link SessionInputAnswer} back into the +- * Projects a {@link ChatInputAnswer} back into the - * {@link ElicitationFieldValue} shape expected by the SDK for the given - * schema field. Returns `undefined` when the answer is missing/skipped or - * cannot be coerced to the field's declared type. - */ --function elicitationAnswerToFieldValue(field: ElicitationSchemaField, answer: SessionInputAnswer | undefined): ElicitationFieldValue | undefined { -- if (!answer || answer.state === SessionInputAnswerState.Skipped) { +-function elicitationAnswerToFieldValue(field: ElicitationSchemaField, answer: ChatInputAnswer | undefined): ElicitationFieldValue | undefined { +- if (!answer || answer.state === ChatInputAnswerState.Skipped) { - return undefined; - } - const value = answer.value; - if (field.type === 'boolean') { -- if (value.kind === SessionInputAnswerValueKind.Boolean) { return value.value; } -- if (value.kind === SessionInputAnswerValueKind.Text) { +- if (value.kind === ChatInputAnswerValueKind.Boolean) { return value.value; } +- if (value.kind === ChatInputAnswerValueKind.Text) { - if (value.value === 'true') { return true; } - if (value.value === 'false') { return false; } - return undefined; @@ -3232,10 +4033,10 @@ index 770817327b0..00000000000 - return undefined; - } - if (field.type === 'number' || field.type === 'integer') { -- if (value.kind === SessionInputAnswerValueKind.Number) { +- if (value.kind === ChatInputAnswerValueKind.Number) { - return field.type === 'integer' ? Math.trunc(value.value) : value.value; - } -- if (value.kind === SessionInputAnswerValueKind.Text) { +- if (value.kind === ChatInputAnswerValueKind.Text) { - if (value.value.trim() === '') { return undefined; } - const n = Number(value.value); - return Number.isFinite(n) ? (field.type === 'integer' ? Math.trunc(n) : n) : undefined; @@ -3243,20 +4044,20 @@ index 770817327b0..00000000000 - return undefined; - } - if (field.type === 'array') { -- if (value.kind === SessionInputAnswerValueKind.SelectedMany) { +- if (value.kind === ChatInputAnswerValueKind.SelectedMany) { - return [...value.value, ...(value.freeformValues ?? [])]; - } -- if (value.kind === SessionInputAnswerValueKind.Selected) { +- if (value.kind === ChatInputAnswerValueKind.Selected) { - return value.value ? [value.value, ...(value.freeformValues ?? [])] : [...(value.freeformValues ?? [])]; - } -- if (value.kind === SessionInputAnswerValueKind.Text) { +- if (value.kind === ChatInputAnswerValueKind.Text) { - return value.value ? [value.value] : []; - } - return undefined; - } - // field.type === 'string' -- if (value.kind === SessionInputAnswerValueKind.Text) { return value.value; } -- if (value.kind === SessionInputAnswerValueKind.Selected) { return value.value; } +- if (value.kind === ChatInputAnswerValueKind.Text) { return value.value; } +- if (value.kind === ChatInputAnswerValueKind.Selected) { return value.value; } - return undefined; -} - @@ -3307,6 +4108,89 @@ index 770817327b0..00000000000 - readonly customizationDirectory?: URI; - /** Snapshot of the active client's tools and plugins at session creation time. */ - readonly clientSnapshot?: IActiveClientSnapshot; +- /** +- * Looks up the AHP id of an existing child MCP customization by +- * server name, so SDK MCP state events can target plugin-derived +- * entries narrowly. Returns `undefined` for SDK servers that have +- * no corresponding plugin entry — the session surfaces those as +- * bare top-level customizations via {@link CopilotAgentSession.topLevelMcpCustomizations}. +- */ +- readonly resolveMcpChildId: (serverName: string) => string | undefined; +- /** +- * Live holder of the owning client's identity, shared by reference with +- * the agent's per-session {@link ActiveClient}. Read at tool-call stamp +- * time so a window reload (new `clientId`, identical tools) stamps with +- * the current id. When omitted, a fresh state seeded from +- * {@link clientSnapshot} is used (test / standalone path). +- */ +- readonly activeClientState?: ActiveClientState; +- /** +- * Server-side host for the agent host's server tools. When provided, the +- * session advertises the server tools (feedback "comments" today, more in +- * the future) and exposes SDK tool handlers that execute them in-process. +- */ +- readonly serverToolHost?: IAgentServerToolHost; +-} +- +-/** +- * Lifecycle state of a {@link CopilotTurn}. +- * +- * - `pending` — the host has dispatched the message (`send()`), but the SDK +- * has not yet emitted any event for this turn's agentic loop. +- * - `running` — the SDK has emitted at least one event for this turn. +- * - `completed` — the turn finished normally (the loop went idle). +- * - `aborted` — the turn's loop was cancelled via an abort. +- */ +-type CopilotTurnState = 'pending' | 'running' | 'completed' | 'aborted'; +- +-/** +- * Encapsulates all per-turn bookkeeping for a single protocol turn, plus an +- * explicit lifecycle {@link CopilotTurn.state}. Holding this state on one +- * object (created fresh per turn) rather than as a handful of mutable session +- * fields means there is a single, atomic notion of "the current turn": there +- * is no set of counters/maps that must be reset in lockstep, and turn +- * transitions (running/completed/aborted) are explicit and checkable. +- * +- * The `pending → running` distinction guards turn completion against a stray +- * idle: an abort's terminal `session.idle` finds a queued message's turn still +- * `pending` (the SDK has not begun it) and leaves it open, rather than +- * completing it and orphaning its real response. A non-abort idle still +- * completes a `pending` turn defensively, so a degenerate no-op send cannot +- * hang the session. +- */ +-class CopilotTurn { +- +- private _state: CopilotTurnState = 'pending'; +- +- /** Accumulated Copilot usage for this turn, in nano-AIU. */ +- copilotUsageTotalNanoAiu = 0; +- +- /** +- * Current markdown response part IDs for this turn, keyed by +- * `parentToolCallId ?? ''`. Parent and subagent text stream through the +- * same SDK session but land in different AHP sessions, so their markdown +- * part state must not mask or append to each other. +- */ +- readonly markdownPartIds = new Map(); +- +- /** Current reasoning response part IDs for this turn, keyed by `parentToolCallId ?? ''`. */ +- readonly reasoningPartIds = new Map(); +- +- constructor(readonly id: string) { } +- +- get state(): CopilotTurnState { return this._state; } +- get isPending(): boolean { return this._state === 'pending'; } +- get isRunning(): boolean { return this._state === 'running'; } +- +- /** Transition `pending → running` on the first SDK event. No-op once running/finished. */ +- markRunning(): void { +- if (this._state === 'pending') { +- this._state = 'running'; +- } +- } +- +- markCompleted(): void { this._state = 'completed'; } +- markAborted(): void { this._state = 'aborted'; } -} - -/** @@ -3320,21 +4204,32 @@ index 770817327b0..00000000000 - readonly sessionId: string; - readonly sessionUri: URI; - +- /** Working directory this session operates in, if any. */ +- get workingDirectory(): URI | undefined { return this._workingDirectory; } +- +- /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - /** Tracks active tool invocations so we can produce past-tense messages on completion. */ -- private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; startTimeMs: number; mcpServerName: string | undefined }>(); +- private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; startTimeMs: number; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); +- /** +- * Maps a running subagent's `agentId` to its parent tool call id. Session- +- * scoped rather than per-turn: a subagent's lifetime is bounded by its +- * `subagent.started` / `subagent.completed` events (and background +- * subagents can outlive the parent tool call), so this routing must not be +- * cleared on turn boundaries. +- */ - private readonly _parentToolCallIdsByAgentId = new Map(); - /** Pending permission requests awaiting a renderer-side decision. */ - private readonly _pendingPermissions = new Map>(); - /** Pending user input requests awaiting a renderer-side answer. */ -- private readonly _pendingUserInputs = new Map }>; questionId: string }>(); +- private readonly _pendingUserInputs = new Map }>; questionId: string }>(); - /** - * Pending elicitation requests awaiting a renderer-side answer. Keyed - * by request id; the schema is retained so the completion handler can -- * project the submitted {@link SessionInputAnswer}s back into the +- * project the submitted {@link ChatInputAnswer}s back into the - * SDK's {@link ElicitationResult.content} shape. - */ - private readonly _pendingElicitations = new Map }>; +- readonly deferred: DeferredPromise<{ response: ChatInputResponseKind; answers?: Record }>; - readonly schema: ElicitationSchema | undefined; - }>(); - /** @@ -3357,10 +4252,31 @@ index 770817327b0..00000000000 - private readonly _databaseRef: IReference; - /** On-disk root for per-session data (database, attachments, …). */ - private readonly _sessionDataDir: URI; -- /** Protocol turn ID set by {@link send}, used for file edit tracking. */ -- private _turnId = ''; +- /** +- * The current protocol turn and its per-turn bookkeeping, or `undefined` +- * when the session is idle (no active turn). Replaces the former set of +- * loosely-coupled per-turn fields (`_turnId`, usage counter, streaming +- * part-id maps) with a single object carrying an explicit +- * {@link CopilotTurn.state} lifecycle. Created (`pending`) by +- * {@link resetTurnState}, finalized by {@link _completeActiveTurn}. +- */ +- private _currentTurn: CopilotTurn | undefined; +- /** +- * Protocol turn ID of the active turn, or `''` when idle. Used by file +- * edit tracking and emitted on per-turn actions. +- */ +- private get _turnId(): string { return this._currentTurn?.id ?? ''; } +- /** +- * Last model id seen on the SDK's per-LLM-call `Usage` event (or a +- * direct {@link setModel} call). We rely on the +- * `Usage` event rather than the tool-call event itself because +- * tool-call events don't carry the model id; the `Usage` event for +- * an LLM turn precedes that turn's `tool_use` events. +- */ +- private _lastSeenModelId: string | undefined; - /** SDK session wrapper, set by {@link initializeSession}. */ - private _wrapper!: CopilotSessionWrapper; +- private _runtimeSlashCommandCache: RuntimeSlashCommandCache | undefined; - /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ - private _lastAppliedMode: CopilotSdkMode | undefined; - private readonly _steeringMessagesInFlight = new Set(); @@ -3369,7 +4285,7 @@ index 770817327b0..00000000000 - * surfaced to the chat UI as a separate user message. When the SDK - * echoes a steering through a `user.message` event whose `content` - * matches one of these entries, we finalize the in-flight turn and -- * dispatch a new {@link ActionType.SessionTurnStarted} whose +- * dispatch a new {@link ActionType.ChatTurnStarted} whose - * `userMessage` is the steering content. The reducer also removes - * the pending steering via the action's `queuedMessageId`. - * @@ -3381,10 +4297,17 @@ index 770817327b0..00000000000 - - /** Snapshot captured at session creation for refresh detection. */ - private readonly _appliedSnapshot: IActiveClientSnapshot; +- /** +- * Live owning-client identity, read at tool-call stamp time so a window +- * reload that re-pushes identical tools with a new `clientId` stamps +- * subsequent client tool calls with the current id rather than the one +- * frozen into {@link _appliedSnapshot}. +- */ +- private readonly _activeClientState: ActiveClientState; - /** Tool names that are client-provided, derived from snapshot. */ - private readonly _clientToolNames: ReadonlySet; - /** Deferred promises for pending client tool calls, keyed by toolCallId. */ -- private readonly _pendingClientToolCalls = new Map>(); +- private readonly _pendingClientToolCalls = new PendingRequestRegistry(); - /** `pending-edit-content:` URIs written during permission requests, keyed - * by toolCallId. Cleaned up when the permission resolves or the session - * is disposed. */ @@ -3396,16 +4319,28 @@ index 770817327b0..00000000000 - private readonly _shellManager: ShellManager | undefined; - private readonly _workingDirectory: URI | undefined; - private readonly _customizationDirectory: URI | undefined; +- private readonly _serverToolHost: IAgentServerToolHost | undefined; +- /** Bridges SDK-reported MCP server state into AHP customization actions. */ +- private readonly _mcpCustomizations: McpCustomizationController; - - /** -- * Current markdown response part IDs for the active turn, keyed by -- * `parentToolCallId ?? ''`. Parent and subagent text stream through the -- * same SDK session but land in different AHP sessions, so their markdown -- * part state must not mask or append to each other. +- * Fans MCP server notifications (today: `notifications/tools/list_changed`) +- * up to the agent and on to the protocol server. Fired by the +- * `onToolsUpdated` listener once per ready MCP channel. +- */ +- private readonly _onMcpNotification = this._register(new Emitter()); +- readonly onMcpNotification = this._onMcpNotification.event; +- +- /** +- * Pending MCP `sampling/createMessage` requests received over the +- * AHP `mcp://` channel, keyed by the cancellation handle we passed +- * into {@link rpc.mcp.executeSampling}. Tracked so that session +- * teardown can issue a best-effort +- * {@link rpc.mcp.cancelSamplingExecution} for each one instead of +- * leaving the SDK-side promise (and the upstream App) hanging. - */ -- private readonly _currentMarkdownPartIds = new Map(); -- /** Current reasoning response part IDs for the active turn, keyed by `parentToolCallId ?? ''`. */ -- private readonly _currentReasoningPartIds = new Map(); +- private readonly _pendingMcpSamplings = new Set(); +- - /** Tracks whether a non-empty activity has been published, so we only emit a clear when needed. */ - private _hasReportedActivity = false; - @@ -3428,9 +4363,19 @@ index 770817327b0..00000000000 - this._shellManager = options.shellManager; - this._workingDirectory = options.workingDirectory; - this._customizationDirectory = options.customizationDirectory; +- this._serverToolHost = options.serverToolHost; - -- this._appliedSnapshot = options.clientSnapshot ?? { clientId: '', tools: [], plugins: [] }; +- this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; - this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); +- // Share the agent's live ActiveClientState when provided so clientId +- // changes are observed at stamp time. Standalone / test construction +- // seeds a private instance with the applied tools and no owning client. +- if (options.activeClientState) { +- this._activeClientState = options.activeClientState; +- } else { +- this._activeClientState = new ActiveClientState(); +- this._activeClientState.update(undefined, this._appliedSnapshot.tools); +- } - - this._databaseRef = sessionDataService.openDatabase(options.sessionUri); - this._register(toDisposable(() => this._databaseRef.dispose())); @@ -3438,12 +4383,20 @@ index 770817327b0..00000000000 - - this._editTracker = this._instantiationService.createInstance(FileEditTracker, options.sessionUri.toString(), this._databaseRef.object); - +- this._mcpCustomizations = this._register(new McpCustomizationController({ +- providerId: this.sessionUri.scheme, +- sessionId: this.sessionId, +- resolveChildId: options.resolveMcpChildId, +- emit: action => this._emitAction(action), +- })); +- - this._register(toDisposable(() => this._denyPendingPermissions())); - this._register(toDisposable(() => this._shellManager?.dispose())); - this._register(toDisposable(() => this._cancelPendingUserInputs())); - this._register(toDisposable(() => this._cancelPendingElicitations())); - this._register(toDisposable(() => this._cancelPendingPlanReviews())); - this._register(toDisposable(() => this._drainPendingSteeringFlips())); +- this._register(toDisposable(() => this._cancelPendingMcpSamplings())); - - // 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 @@ -3462,7 +4415,7 @@ index 770817327b0..00000000000 - }); - - this._emitAction({ -- type: ActionType.SessionToolCallContentChanged, +- type: ActionType.ChatToolCallContentChanged, - turnId: this._turnId, - toolCallId, - content: tracked.content, @@ -3475,7 +4428,7 @@ index 770817327b0..00000000000 - // ---- AgentSignal helpers ------------------------------------------------ - - /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ -- private _emitAction(action: SessionAction, parentToolCallId?: string): void { +- private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string): void { - this._onDidSessionProgress.fire({ - kind: 'action', - session: this.sessionUri, @@ -3487,7 +4440,7 @@ index 770817327b0..00000000000 - /** - * Promotes a pending steering message into its own protocol turn: - * closes the in-flight turn (so its responseParts settle into history) -- * and dispatches {@link ActionType.SessionTurnStarted} for a fresh +- * and dispatches {@link ActionType.ChatTurnStarted} for a fresh - * turn whose user message is the steering content. The action's - * `queuedMessageId` atomically clears the corresponding pending - * steering message from the session state. @@ -3505,21 +4458,26 @@ index 770817327b0..00000000000 - const previousTurnId = this._turnId; - if (previousTurnId) { - this._emitAction({ -- type: ActionType.SessionTurnComplete, +- type: ActionType.ChatTurnComplete, - turnId: previousTurnId, - }); - } - const newTurnId = generateUuid(); - this._emitAction({ -- type: ActionType.SessionTurnStarted, +- type: ActionType.ChatTurnStarted, - turnId: newTurnId, - message: steering.message, - queuedMessageId: steering.id, - }); -- // Mirror `resetTurnState` so per-turn counters/mappings (usage -- // total, streaming part ids, subagent agentId map) don't bleed -- // from the preempted turn into the new steering turn. +- // Mirror `resetTurnState` so per-turn counters/mappings (usage total, +- // streaming part ids) don't bleed from the preempted turn into the new +- // steering turn. The steering turn is created mid-loop in response to an +- // SDK `user.message` event, so the SDK is already actively producing its +- // response: mark it `running` immediately rather than leaving it +- // `pending`, otherwise an abort during the steering turn would treat it +- // as a not-yet-started queued turn and leave it open. - this.resetTurnState(newTurnId); +- this._currentTurn?.markRunning(); - return newTurnId; - } - @@ -3580,29 +4538,25 @@ index 770817327b0..00000000000 - } - - /** -- * Resets per-turn streaming state so the next text/reasoning chunk -- * allocates a fresh response part for the new turn. +- * Starts a fresh `pending` turn, discarding any per-turn streaming state +- * from a previous turn so the next text/reasoning chunk allocates a new +- * response part. The turn becomes `running` on the first SDK event. - */ - resetTurnState(turnId: string): void { -- this._turnId = turnId; -- this._currentMarkdownPartIds.clear(); -- this._currentReasoningPartIds.clear(); -- this._parentToolCallIdsByAgentId.clear(); +- this._currentTurn = new CopilotTurn(turnId); - } - - private _completeActiveTurn(): void { -- if (!this._turnId) { +- const turn = this._currentTurn; +- if (!turn) { - return; - } -- const turnId = this._turnId; +- turn.markCompleted(); - this._emitAction({ -- type: ActionType.SessionTurnComplete, -- turnId, +- type: ActionType.ChatTurnComplete, +- turnId: turn.id, - }); -- this._turnId = ''; -- this._currentMarkdownPartIds.clear(); -- this._currentReasoningPartIds.clear(); -- this._parentToolCallIdsByAgentId.clear(); +- this._currentTurn = undefined; - } - - private _getEditFilePaths(parameters: unknown): string[] { @@ -3656,21 +4610,30 @@ index 770817327b0..00000000000 - * markdown response part; subsequent deltas append to it. - */ - private _emitMarkdownDelta(content: string, parentToolCallId?: string): void { +- const turn = this._currentTurn; +- if (!turn) { +- // A markdown delta should only ever arrive while a turn is active. +- // Without a turn we can't persist the part id (so every delta would +- // allocate a fresh part) and the action would carry an empty turnId. +- // Drop it and surface the unexpected state. +- this._logService.error(`[Copilot:${this.sessionId}] Markdown delta emitted with no active turn; dropping`); +- return; +- } - const markdownScope = parentToolCallId ?? ''; -- let partId = this._currentMarkdownPartIds.get(markdownScope); +- let partId = turn.markdownPartIds.get(markdownScope); - if (!partId) { - partId = generateUuid(); -- this._currentMarkdownPartIds.set(markdownScope, partId); +- turn.markdownPartIds.set(markdownScope, partId); - this._emitAction({ -- type: ActionType.SessionResponsePart, -- turnId: this._turnId, +- type: ActionType.ChatResponsePart, +- turnId: turn.id, - part: { kind: ResponsePartKind.Markdown, id: partId, content }, - }, parentToolCallId); - return; - } - this._emitAction({ -- type: ActionType.SessionDelta, -- turnId: this._turnId, +- type: ActionType.ChatDelta, +- turnId: turn.id, - partId, - content, - }, parentToolCallId); @@ -3678,21 +4641,26 @@ index 770817327b0..00000000000 - - /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ - private _emitReasoningDelta(content: string, parentToolCallId?: string): void { +- const turn = this._currentTurn; +- if (!turn) { +- this._logService.error(`[Copilot:${this.sessionId}] Reasoning delta emitted with no active turn; dropping`); +- return; +- } - const reasoningScope = parentToolCallId ?? ''; -- let partId = this._currentReasoningPartIds.get(reasoningScope); +- let partId = turn.reasoningPartIds.get(reasoningScope); - if (!partId) { - partId = generateUuid(); -- this._currentReasoningPartIds.set(reasoningScope, partId); +- turn.reasoningPartIds.set(reasoningScope, partId); - this._emitAction({ -- type: ActionType.SessionResponsePart, -- turnId: this._turnId, +- type: ActionType.ChatResponsePart, +- turnId: turn.id, - part: { kind: ResponsePartKind.Reasoning, id: partId, content }, - }, parentToolCallId); - return; - } - this._emitAction({ -- type: ActionType.SessionReasoning, -- turnId: this._turnId, +- type: ActionType.ChatReasoning, +- turnId: turn.id, - partId, - content, - }, parentToolCallId); @@ -3712,8 +4680,9 @@ index 770817327b0..00000000000 - - /** - * Creates SDK {@link Tool} objects for the client-provided tools in the -- * applied snapshot. The handler creates a {@link DeferredPromise} and waits -- * for the client to dispatch `session/toolCallComplete`. +- * applied snapshot. The handler parks a request in +- * {@link _pendingClientToolCalls} and waits for the client to dispatch +- * `session/toolCallComplete`. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _createClientSdkTools(): Tool[] { @@ -3727,14 +4696,10 @@ index 770817327b0..00000000000 - parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, - handler: async (_args: Record, { toolCallId }) => { - try { -- let deferred = this._pendingClientToolCalls.get(toolCallId); -- if (!deferred) { -- deferred = new DeferredPromise(); -- this._pendingClientToolCalls.set(toolCallId, deferred); -- } -- const result = await deferred.p; -- this._pendingClientToolCalls.delete(toolCallId); -- return result; +- // The completion may legitimately arrive before this handler +- // registers; the registry buffers early results so register() +- // resolves immediately in that case. +- return await this._pendingClientToolCalls.register(toolCallId); - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); - throw error; @@ -3744,16 +4709,42 @@ index 770817327b0..00000000000 - } - - /** -- * Resolves a pending client tool call. Returns `true` if the -- * toolCallId was found and handled. +- * Builds SDK tool handlers for the agent host's server tools. Each handler +- * executes the tool against this session's state via the +- * {@link IAgentServerToolHost} and returns its textual result. Returns an +- * empty list when no server-tool host is wired (e.g. test / standalone +- * construction). - */ -- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { -- let deferred = this._pendingClientToolCalls.get(toolCallId); -- if (!deferred) { -- deferred = new DeferredPromise(); -- this._pendingClientToolCalls.set(toolCallId, deferred); +- // eslint-disable-next-line @typescript-eslint/no-explicit-any +- private _createServerSdkTools(): Tool[] { +- const host = this._serverToolHost; +- if (!host) { +- return []; - } +- const sessionUri = this.sessionUri.toString(); +- return host.definitions.map(def => ({ +- name: def.name, +- description: def.description ?? '', +- parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, +- handler: async (args: Record): Promise => { +- try { +- const text = host.executeTool(sessionUri, def.name, args); +- return { textResultForLlm: text, resultType: 'success' }; +- } catch (error) { +- const message = error instanceof Error ? error.message : String(error); +- this._logService.error(error, `[Copilot:${this.sessionId}] Failed in server tool handler: tool=${def.name}`); +- return { textResultForLlm: message, resultType: 'failure', error: message }; +- } +- }, +- })); +- } - +- /** +- * Resolves a pending client tool call. If the SDK handler has not yet +- * registered for `toolCallId`, the result is buffered so the handler +- * resolves immediately once it does. +- */ +- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { - const textContent = result.content - ?.filter(c => c.type === ToolResultContentType.Text) - .map(c => c.text) @@ -3762,16 +4753,17 @@ index 770817327b0..00000000000 - const binaryResults = result.content - ?.filter(c => c.type === ToolResultContentType.EmbeddedResource) - .map(c => ({ data: c.data, mimeType: c.contentType, type: (/^image(\/|$)/.test(c.contentType) ? 'image' : 'resource') as 'image' | 'resource' })); +- const textResultForLlm = textContent.trim() ? textContent : getEmptyToolResultText(binaryResults); - - if (result.success) { -- deferred.complete({ -- textResultForLlm: textContent, +- this._pendingClientToolCalls.respondOrBuffer(toolCallId, { +- textResultForLlm, - resultType: 'success', - binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, - }); - } else { -- deferred.complete({ -- textResultForLlm: textContent || result.error?.message || 'Tool call failed', +- this._pendingClientToolCalls.respondOrBuffer(toolCallId, { +- textResultForLlm: textContent.trim() ? textContent : result.error?.message || 'Tool call failed', - resultType: 'failure', - error: result.error?.message, - binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, @@ -3796,6 +4788,11 @@ index 770817327b0..00000000000 - this._wrapper = this._register(wrapper); - this._subscribeToEvents(); - this._subscribeForLogging(); +- +- // Advertise the agent host's server tools for this session so clients +- // see them as server-provided. Execution happens in-process via the SDK +- // tool handlers built in `_createServerSdkTools`. +- this._serverToolHost?.advertise(this.sessionUri.toString()); - } - - private _createRuntimeAdapter(): ICopilotSessionRuntime { @@ -3806,6 +4803,7 @@ index 770817327b0..00000000000 - handleElicitationRequest: context => this._handleElicitationRequest(context), - requestUnsandboxedCommandConfirmation: request => this._requestUnsandboxedCommandConfirmation(request), - createClientSdkTools: () => this._createClientSdkTools(), +- createServerSdkTools: () => this._createServerSdkTools(), - handlePreToolUse: input => this._handlePreToolUse(input), - handlePostToolUse: input => this._handlePostToolUse(input), - }; @@ -3814,8 +4812,11 @@ index 770817327b0..00000000000 - // ---- session operations ------------------------------------------------- - - async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise { -- if (turnId) { -- this._turnId = turnId; +- if (turnId && this._currentTurn?.id !== turnId) { +- // Establish the `pending` turn for this message. Callers normally +- // call `resetTurnState` just before `send()`; this covers the +- // direct-send path and is a no-op when the turn already exists. +- this.resetTurnState(turnId); - } - this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); - @@ -3823,14 +4824,62 @@ index 770817327b0..00000000000 - if (slashCommand?.command === 'compact') { - try { - await this._wrapper.session.rpc.history.compact(); +- this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); - } catch (err) { +- if (getErrorMessage(err).toLowerCase().includes('nothing to compact')) { +- this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); +- this._completeActiveTurn(); +- return; +- } - this._logService.error(err, `[Copilot:${this.sessionId}] rpc.history.compact failed`); - throw err; - } +- // `/compact` is handled inline via the history RPC rather than by +- // driving an SDK turn, so the SDK never fires `onIdle` to close the +- // turn. Complete the turn here so the session returns to idle +- // instead of spinning forever. +- this._completeActiveTurn(); - return; - } -- if (slashCommand?.command === 'research') { -- prompt = slashCommand.rest ? `/research ${slashCommand.rest}` : '/research'; +- if (slashCommand && isRuntimeCopilotSlashCommand(slashCommand.command) && await this.hasRuntimeSlashCommand(slashCommand.command)) { +- let result: CopilotCommandInvocationResult; +- try { +- result = await this._wrapper.session.rpc.commands.invoke({ +- name: slashCommand.command, +- }); +- } catch (err) { +- this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${slashCommand.command}) failed`); +- throw err; +- } +- switch (result.kind) { +- case 'text': +- this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); +- break; +- case 'completed': +- if (result.message) { +- this._emitMarkdownDelta(result.message); +- } +- break; +- case 'agent-prompt': { +- const runtimeMode = toCopilotSdkMode(result.mode); +- if (runtimeMode) { +- mode = runtimeMode; +- } +- prompt = result.prompt; +- break; +- } +- default: +- this._logService.warn(`[Copilot:${this.sessionId}] Unexpected /${slashCommand.command} command result kind: ${result.kind}`); +- this._emitMarkdownDelta(localize('copilotSlashCommand.unsupportedRuntimeResult', "The /{0} command returned an unsupported result.", slashCommand.command)); +- break; +- } +- if (result.kind !== 'agent-prompt') { +- this._completeActiveTurn(); +- return; +- } +- } +- if (slashCommand && isPromptInvokedCopilotSlashCommand(slashCommand.command)) { +- prompt = slashCommand.rest ? `/${slashCommand.command} ${slashCommand.rest}` : `/${slashCommand.command}`; - } - if (slashCommand?.command === 'plan') { - mode = 'plan'; @@ -3861,6 +4910,31 @@ index 770817327b0..00000000000 - this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); - } - +- async hasRuntimeSlashCommand(command: string): Promise { +- try { +- return (await this._getRuntimeSlashCommands()).has(command); +- } catch (err) { +- this._logService.warn(`[Copilot:${this.sessionId}] rpc.commands.list failed`, err); +- return false; +- } +- } +- +- private _getRuntimeSlashCommands(): Promise> { +- const now = Date.now(); +- if (this._runtimeSlashCommandCache && this._runtimeSlashCommandCache.expiresAt > now) { +- return this._runtimeSlashCommandCache.promise; +- } +- const promise = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: false, includeClientCommands: false }) +- .then(result => new Set(result.commands.filter(command => command.kind === 'builtin').map(command => command.name))); +- this._runtimeSlashCommandCache = { expiresAt: now + RUNTIME_SLASH_COMMAND_CACHE_TTL_MS, promise }; +- promise.catch(() => { +- if (this._runtimeSlashCommandCache?.promise === promise) { +- this._runtimeSlashCommandCache = undefined; +- } +- }); +- return promise; +- } +- - /** - * Translate a protocol {@link MessageAttachment} into the Copilot CLI - * SDK's `attachments` payload shape. Resource attachments map to the @@ -3871,12 +4945,28 @@ index 770817327b0..00000000000 - * Simple attachments with a model representation map to `text/plain` - * blob attachments. - * +- * Any Resource attachment carrying a {@link TextSelection} (e.g. `displayKind === 'selection'` or `'symbol'`) is +- * mapped to the SDK's `selection` variant so the range survives the round-trip — keying off the `selection` field +- * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). +- * - * For selections we read the resource content from disk and slice it - * by the carried range (the protocol's {@link TextSelection} only - * carries the range, not the inline text). On read failure the - * selection downgrades to a plain file reference. - */ - private async _toSdkAttachment(attachment: MessageAttachment): Promise { +- if (isAgentFeedbackAnnotationsAttachment(attachment)) { +- const rendered = renderAgentFeedbackAnnotationsAttachment(attachment); +- if (!rendered) { +- return undefined; +- } +- return { +- type: 'blob' as const, +- data: encodeBase64(VSBuffer.fromString(rendered)), +- mimeType: 'text/plain', +- displayName: attachment.label, +- }; +- } - if (attachment.type === MessageAttachmentKind.Simple) { - if (attachment.modelRepresentation) { - return { @@ -3897,7 +4987,7 @@ index 770817327b0..00000000000 - const uri = URI.parse(attachment.uri); - const path = uri.scheme === 'file' ? uri.fsPath : uri.toString(); - const displayName = attachment.label ?? path; -- if (attachment.displayKind === 'selection' && attachment.selection) { +- if (attachment.selection) { - try { - const text = await this._readSelectedText(uri, attachment.selection.range); - return { type: 'selection' as const, filePath: path, displayName, text, selection: attachment.selection.range }; @@ -3952,6 +5042,15 @@ index 770817327b0..00000000000 - } - } - +- /** +- * `true` when the session's effective `mode` is `autopilot` — the +- * autonomous, continue-until-done mode in which no user is available to +- * answer questions or fill in elicitation forms. +- */ +- private _isAutopilotMode(): boolean { +- return this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.Mode) === 'autopilot'; +- } +- - async sendSteering(steeringMessage: PendingMessage): Promise { - if (this._steeringMessagesInFlight.has(steeringMessage.id) || this._pendingSteeringFlips.has(steeringMessage.id)) { - return; @@ -4012,9 +5111,96 @@ index 770817327b0..00000000000 - await this._wrapper.session.disconnect(); - } - -- async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort']): Promise { +- async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); -- await this._wrapper.session.setModel(model, { reasoningEffort }); +- this._lastSeenModelId = model; +- await this._wrapper.session.setModel(model, { reasoningEffort, contextTier }); +- } +- +- /** +- * Dispatches an MCP JSON-RPC method received on the `mcp://` side +- * channel to the Copilot SDK's `session.rpc.mcp.*` surface. +- * +- * Mapping: +- * - `tools/list` → `rpc.mcp.apps.listTools` +- * - `tools/call` → `rpc.mcp.apps.callTool` +- * - `resources/read` → `rpc.mcp.apps.readResource` +- * - `resources/list` → `rpc.mcp.apps.listResources` (empty list fallback) +- * - `resources/templates/list` → `rpc.mcp.apps.listResourceTemplates` (empty list fallback) +- * - `sampling/createMessage` → `rpc.mcp.executeSampling` +- * +- * Other MCP methods are rejected with `Method not found` (the caller +- * translates that into a JSON-RPC `-32601`). +- */ +- async handleMcpRequest(serverName: string, method: string, params: Record | undefined): Promise { +- const apps = this._wrapper.session.rpc.mcp.apps; +- switch (method) { +- case 'tools/list': +- return apps.listTools({ serverName, originServerName: serverName }); +- case 'tools/call': { +- const name = params && typeof params['name'] === 'string' ? params['name'] : undefined; +- if (!name) { +- throw new Error(`tools/call missing 'name' parameter`); +- } +- const rawArgs = params ? params['arguments'] : undefined; +- const args = isObject(rawArgs) ? rawArgs as Record : undefined; +- return apps.callTool({ serverName, toolName: name, arguments: args, originServerName: serverName }); +- } +- case 'resources/read': { +- const uri = params && typeof params['uri'] === 'string' ? params['uri'] : undefined; +- if (!uri) { +- throw new Error(`resources/read missing 'uri' parameter`); +- } +- return apps.readResource({ serverName, uri }); +- } +- case 'resources/list': { +- // Not implemented in the SDK yet +- return { resources: [] }; +- } +- case 'resources/templates/list': { +- // Not implemented in the SDK yet +- return { resourceTemplates: [] }; +- } +- case 'sampling/createMessage': +- return this._handleSamplingCreateMessage(serverName, params); +- default: +- throw new Error(`Method not found: ${method}`); +- } +- } +- +- /** +- * Forwards an App→host `sampling/createMessage` request received +- * over the AHP `mcp://` channel to `rpc.mcp.executeSampling`. The +- * Copilot runtime owns the MCP→chat-completion conversion and the +- * sampling response shape, so we pass the raw MCP params through +- * untouched and return the SDK's result directly. +- * +- * Resolves the JSON-RPC request with the `CreateMessageResult` on +- * success and rejects on failure/cancellation, mirroring the +- * `sampling/createMessage` MCP contract. +- */ +- private async _handleSamplingCreateMessage(serverName: string, params: Record | undefined): Promise { +- if (!params) { +- throw new Error(`sampling/createMessage missing params`); +- } +- +- const requestId = generateUuid(); +- const mcpRequestId = generateUuid(); +- this._pendingMcpSamplings.add(requestId); +- try { +- const result = await this._wrapper.session.rpc.mcp.executeSampling({ +- requestId, +- serverName, +- mcpRequestId, +- request: params, +- }); +- if (result.action === 'success') { +- return result.result ?? null; +- } +- throw new Error(`sampling/createMessage ${result.action}${result.error ? `: ${result.error}` : ''}`); +- } finally { +- this._pendingMcpSamplings.delete(requestId); +- } - } - - /** @@ -4093,6 +5279,19 @@ index 770817327b0..00000000000 - } - } - +- // Auto-approve the agent host's server tools. They only read or +- // mutate the session's own server-held state and never touch the +- // workspace, shell, or network, so prompting for them is redundant +- // noise. Tools that explicitly require confirmation (e.g. revealing +- // unreviewed review comments) are excluded so the user is prompted. +- if (request.kind === 'custom-tool' && typeof request.toolName === 'string' +- && this._serverToolHost?.toolNames.includes(request.toolName) +- && !this._serverToolHost.requiresConfirmation(request.toolName) +- ) { +- this._logService.info(`[Copilot:${this.sessionId}] Auto-approving server tool ${request.toolName}`); +- return { kind: 'approve-once' }; +- } +- - const isShellRequest = request.kind === 'shell' - || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); - @@ -4134,9 +5333,9 @@ index 770817327b0..00000000000 - // Fire a pending_confirmation signal to transition the tool to PendingConfirmation - const toolName = request.toolName ?? request.kind; - // Forward the tool's parentToolCallId (if any) so the host can -- // route the resulting SessionToolCallReady to the correct +- // route the resulting ChatToolCallReady to the correct - // subagent session — without it the action would land on the -- // parent session, which has no matching SessionToolCallStart. +- // parent session, which has no matching ChatToolCallStart. - const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; - this._onDidSessionProgress.fire({ - kind: 'pending_confirmation', @@ -4201,25 +5400,31 @@ index 770817327b0..00000000000 - } - - /** -- * Returns true when our custom shell tool is registered and the -- * {@link TerminalSandboxEngine} reports sandboxing is enabled — i.e. -- * shell commands run inside the sandbox by default. The shell tool -- * prompts on its own when escalating to unsandboxed execution, so the -- * SDK's pre-call permission prompt is redundant in that case. +- * Returns true when shell commands run inside a sandbox by default — either +- * through the AgentHost's own {@link TerminalSandboxEngine} (when the custom +- * terminal tool is enabled) or through the SDK's built-in shell tool wrapped +- * by the `sandboxConfig` we pushed via `session.options.update`. +- * +- * In both cases the shell tool prompts on its own when escalating to +- * unsandboxed execution, so the SDK's pre-call `shell` permission prompt +- * is redundant and we auto-approve it. - * -- * Returns false when shell tools are not registered (the SDK's built-in -- * terminal runs unsandboxed unless `AgentHostConfigKey.EnableCustomTerminalTool` -- * is set) -- * so the standard confirmation flow is preserved. +- * Returns false when neither sandbox path is configured, so the standard +- * confirmation flow is preserved. - */ - private async _isShellSandboxedByDefault(): Promise { -- if (!this._shellManager) { -- return false; -- } -- if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) !== true) { -- return false; +- const customTerminalToolEnabled = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; +- if (customTerminalToolEnabled) { +- if (!this._shellManager) { +- return false; +- } +- return this._shellManager.getOrCreateSandboxEngine().isEnabled(); - } -- return this._shellManager.getOrCreateSandboxEngine().isEnabled(); +- // SDK-managed shell path: gate on the same host config that +- // `CopilotSessionLauncher` reads when forwarding `sandboxConfig` to +- // the SDK, so the two stay in lock-step. +- const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); +- return buildSandboxConfigForSdk(process.platform, sandbox) !== undefined; - } - - /** @@ -4345,7 +5550,7 @@ index 770817327b0..00000000000 - request: UserInputRequest, - _invocation: { sessionId: string }, - ): Promise { -- const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot'; +- const isAutopilot = this._isAutopilotMode(); - 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.', @@ -4359,15 +5564,15 @@ index 770817327b0..00000000000 - const questionId = generateUuid(); - this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${questionPreview}"`); - -- const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); +- const deferred = new DeferredPromise<{ response: ChatInputResponseKind; answers?: Record }>(); - this._pendingUserInputs.set(requestId, { deferred, questionId }); - -- // Build the protocol SessionInputRequest from the SDK's simple format -- const inputRequest: SessionInputRequest = { +- // Build the protocol ChatInputRequest from the SDK's simple format +- const inputRequest: ChatInputRequest = { - id: requestId, - questions: [request.choices && request.choices.length > 0 - ? { -- kind: SessionInputQuestionKind.SingleSelect, +- kind: ChatInputQuestionKind.SingleSelect, - id: questionId, - message: request.question, - required: true, @@ -4375,7 +5580,7 @@ index 770817327b0..00000000000 - allowFreeformInput: request.allowFreeform ?? true, - } - : { -- kind: SessionInputQuestionKind.Text, +- kind: ChatInputQuestionKind.Text, - id: questionId, - message: request.question, - required: true, @@ -4384,27 +5589,27 @@ index 770817327b0..00000000000 - }; - - this._emitAction({ -- type: ActionType.SessionInputRequested, +- type: ActionType.ChatInputRequested, - request: inputRequest, - }); - - const result = await deferred.p; - this._logService.info(`[Copilot:${this.sessionId}] User input response: requestId=${requestId}, response=${result.response}`); - -- if (result.response !== SessionInputResponseKind.Accept || !result.answers) { +- if (result.response !== ChatInputResponseKind.Accept || !result.answers) { - return { answer: '', wasFreeform: true }; - } - - // Extract the answer for our single question - const answer = result.answers[questionId]; -- if (!answer || answer.state === SessionInputAnswerState.Skipped) { +- if (!answer || answer.state === ChatInputAnswerState.Skipped) { - return { answer: '', wasFreeform: true }; - } - - const { value: val } = answer; -- if (val.kind === SessionInputAnswerValueKind.Text) { +- if (val.kind === ChatInputAnswerValueKind.Text) { - return { answer: val.value, wasFreeform: true }; -- } else if (val.kind === SessionInputAnswerValueKind.Selected) { +- } else if (val.kind === ChatInputAnswerValueKind.Selected) { - const wasFreeform = !request.choices?.includes(val.value); - return { answer: val.value, wasFreeform }; - } @@ -4423,9 +5628,9 @@ index 770817327b0..00000000000 - * - * - `form` mode requests are projected from the SDK's - * {@link ElicitationSchema} into a list of -- * {@link SessionInputQuestion}s. +- * {@link ChatInputQuestion}s. - * - `url` mode requests surface as a question-less input request whose -- * {@link SessionInputRequest.url} drives the renderer's "open URL" +- * {@link ChatInputRequest.url} drives the renderer's "open URL" - * affordance. - * - * Under autopilot the request is auto-cancelled — there is no user @@ -4433,7 +5638,7 @@ index 770817327b0..00000000000 - * be misleading to the MCP server. - */ - private async _handleElicitationRequest(context: ElicitationContext): Promise { -- const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot'; +- const isAutopilot = this._isAutopilotMode(); - if (isAutopilot) { - return { action: 'cancel' }; - } @@ -4445,14 +5650,14 @@ index 770817327b0..00000000000 - - const schema = context.mode === 'url' ? undefined : context.requestedSchema; - const requiredSet = new Set(schema?.required ?? []); -- const questions: SessionInputQuestion[] | undefined = schema +- const questions: ChatInputQuestion[] | undefined = schema - ? Object.entries(schema.properties).map(([fieldName, field]) => elicitationFieldToQuestion(fieldName, field, requiredSet.has(fieldName))) - : undefined; - -- const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); +- const deferred = new DeferredPromise<{ response: ChatInputResponseKind; answers?: Record }>(); - this._pendingElicitations.set(requestId, { deferred, schema }); - -- const inputRequest: SessionInputRequest = { +- const inputRequest: ChatInputRequest = { - id: requestId, - message: context.message, - ...(context.mode === 'url' && context.url ? { url: context.url } : {}), @@ -4460,23 +5665,23 @@ index 770817327b0..00000000000 - }; - - this._emitAction({ -- type: ActionType.SessionInputRequested, +- type: ActionType.ChatInputRequested, - request: inputRequest, - }); - - const result = await deferred.p; - this._logService.info(`[Copilot:${this.sessionId}] Elicitation response: requestId=${requestId}, response=${result.response}`); - -- if (result.response === SessionInputResponseKind.Decline) { +- if (result.response === ChatInputResponseKind.Decline) { - return { action: 'decline' }; - } -- if (result.response !== SessionInputResponseKind.Accept) { +- if (result.response !== ChatInputResponseKind.Accept) { - return { action: 'cancel' }; - } - const answers = result.answers ?? {}; - if (!schema) { - const freeform = answers.answer; -- if (freeform && freeform.state !== SessionInputAnswerState.Skipped && freeform.value.kind === SessionInputAnswerValueKind.Text) { +- if (freeform && freeform.state !== ChatInputAnswerState.Skipped && freeform.value.kind === ChatInputAnswerValueKind.Text) { - return { action: 'accept', content: { answer: freeform.value.value } }; - } - return { action: 'accept' }; @@ -4495,7 +5700,7 @@ index 770817327b0..00000000000 - } - } - -- respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { +- respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): boolean { - const pendingPlanReview = this._pendingPlanReviews.get(requestId); - if (pendingPlanReview) { - this._pendingPlanReviews.delete(requestId); @@ -4538,14 +5743,14 @@ index 770817327b0..00000000000 - */ - private _resolveExitPlanMode( - pending: { actions: readonly string[]; recommendedAction: string; questionId: string }, -- response: SessionInputResponseKind, -- answers?: Record, +- response: ChatInputResponseKind, +- answers?: Record, - ): IExitPlanModeResponse { -- if (response !== SessionInputResponseKind.Accept) { +- if (response !== ChatInputResponseKind.Accept) { - return { approved: false }; - } - const answer = answers?.[pending.questionId]; -- if (!answer || answer.state === SessionInputAnswerState.Skipped) { +- if (!answer || answer.state === ChatInputAnswerState.Skipped) { - return { approved: false }; - } - const value = answer.value; @@ -4556,11 +5761,11 @@ index 770817327b0..00000000000 - // user types instead of picking). Normalize to one shape. - let candidateAction: string | undefined; - let feedback: string | undefined; -- if (value.kind === SessionInputAnswerValueKind.Selected) { +- if (value.kind === ChatInputAnswerValueKind.Selected) { - candidateAction = value.value; - const freeform = value.freeformValues?.find(s => s.trim().length > 0)?.trim(); - feedback = freeform; -- } else if (value.kind === SessionInputAnswerValueKind.Text) { +- } else if (value.kind === ChatInputAnswerValueKind.Text) { - feedback = value.value.trim() || undefined; - } else { - return { approved: false }; @@ -4592,6 +5797,14 @@ index 770817327b0..00000000000 - return { approved: false }; - } - +- // Reflect the chosen implementation path on the AHP `mode` axis right +- // away so the mode picker updates as soon as the user approves the +- // plan (e.g. Plan → Autopilot when they pick "Implement with +- // Autopilot"). The SDK also fires `session.mode_changed`, but that is +- // async; writing here makes the UI update deterministic. The patch is +- // idempotent, so the later event is a no-op. +- this._syncAhpModeFromExitPlanAction(selectedAction); +- - const isAutopilot = selectedAction === 'autopilot' || selectedAction === 'autopilot_fleet'; - return { - approved: true, @@ -4600,6 +5813,26 @@ index 770817327b0..00000000000 - }; - } - +- /** +- * Translates an approved `exit_plan_mode` action into the AHP `mode` axis +- * and writes it so the mode picker reflects the choice immediately: +- * +- * - `autopilot` / `autopilot_fleet` → `mode='autopilot'`. +- * - `interactive` → `mode='interactive'`. +- * - `exit_only` (approve plan without executing) leaves the mode untouched. +- */ +- private _syncAhpModeFromExitPlanAction(selectedAction: string): void { +- switch (selectedAction) { +- case 'autopilot': +- case 'autopilot_fleet': +- this._syncAhpConfigFromSdkMode('autopilot'); +- break; +- case 'interactive': +- this._syncAhpConfigFromSdkMode('interactive'); +- break; +- } +- } +- - private async _handlePreToolUse(input: PreToolUseHookInput): Promise { - try { - if (isEditTool(input.toolName, getToolCommand(input))) { @@ -4640,7 +5873,7 @@ index 770817327b0..00000000000 - this._logService.info(`[Copilot:${sessionId}] System notification received: kind=${e.data.kind.type}`); - if (this._turnId) { - this._emitAction({ -- type: ActionType.SessionResponsePart, +- type: ActionType.ChatResponsePart, - turnId: this._turnId, - part: { - kind: ResponsePartKind.SystemNotification, @@ -4653,7 +5886,7 @@ index 770817327b0..00000000000 - const turnId = generateUuid(); - this.resetTurnState(turnId); - this._emitAction({ -- type: ActionType.SessionTurnStarted, +- type: ActionType.ChatTurnStarted, - turnId, - message: { - text: notification.messageText, @@ -4685,6 +5918,8 @@ index 770817327b0..00000000000 - if (e.data.source && e.data.source.toLowerCase() !== 'user') { - return; - } +- // First SDK event for the loop: promote the turn out of `pending`. +- this._currentTurn?.markRunning(); - const steering = this._takeMatchingPendingSteering(e.data.content); - if (steering) { - this._beginSteeringTurn(steering); @@ -4721,13 +5956,13 @@ index 770817327b0..00000000000 - } - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - const markdownScope = parentToolCallId ?? ''; -- if (this._currentMarkdownPartIds.has(markdownScope)) { +- if (this._currentTurn?.markdownPartIds.has(markdownScope)) { - return; - } - const partId = generateUuid(); -- this._currentMarkdownPartIds.set(markdownScope, partId); +- this._currentTurn?.markdownPartIds.set(markdownScope, partId); - this._emitAction({ -- type: ActionType.SessionResponsePart, +- type: ActionType.ChatResponsePart, - turnId: this._turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, - }, parentToolCallId); @@ -4769,21 +6004,36 @@ index 770817327b0..00000000000 - return; - } - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); -- this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName }); +- this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName, meta: undefined }); +- if (isTaskCompleteTool(e.data.toolName)) { +- const scope = parentToolCallId ?? ''; +- this._currentTurn?.markdownPartIds.delete(scope); +- this._currentTurn?.reasoningPartIds.delete(scope); +- return; +- } - const toolKind = getToolKind(e.data.toolName); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; -- const toolClientId = this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined; -- const contributor = toolClientId ? { kind: ToolCallContributorKind.Client, clientId: toolClientId } as const : undefined; +- +- let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; +- const isClientTool = this._clientToolNames.has(e.data.toolName); +- if (isClientTool && this._activeClientState.clientId) { +- contributor = { kind: ToolCallContributorKind.Client, clientId: this._activeClientState.clientId }; +- } else if (e.data.mcpServerName) { +- const customizationId = this._mcpCustomizations.customizationIdForServer(e.data.mcpServerName); +- if (customizationId !== undefined) { +- contributor = { kind: ToolCallContributorKind.MCP, customizationId }; +- } +- } - - // A new tool call invalidates the current markdown and reasoning - // parts so the next text/reasoning delta after the tool call - // starts a fresh part. Without invalidating reasoning here, a - // later round of reasoning (after tool_start/tool_complete) - // would silently append to the pre-tool-call reasoning block. -- this._currentMarkdownPartIds.delete(parentToolCallId ?? ''); -- this._currentReasoningPartIds.delete(parentToolCallId ?? ''); +- this._currentTurn?.markdownPartIds.delete(parentToolCallId ?? ''); +- this._currentTurn?.reasoningPartIds.delete(parentToolCallId ?? ''); - -- const meta: Record = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; +- const meta: Mutable = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; - if (subagentMeta?.description) { - meta.subagentDescription = subagentMeta.description; - } @@ -4799,26 +6049,76 @@ index 770817327b0..00000000000 - if (e.data.mcpToolName) { - meta.mcpToolName = e.data.mcpToolName; - } +- // TODO(sdk-gap): the Copilot SDK doesn't yet surface MCP App +- // `_meta.ui.resourceUri` on `tool.execution_start`; we attach +- // it on `tool.execution_complete` below so the App webview +- // mounts on completion. Drop-in once the SDK exposes it here: +- // const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; +- // if (resourceUri) { meta.ui = { resourceUri }; } +- +- // Stash the start-time meta on the tracked tool call so the +- // `tool.execution_complete` emission below can merge any +- // additional namespaces (e.g. `ui`) on top without dropping +- // what we already published at start time. +- const tracked = this._activeToolCalls.get(e.data.toolCallId); +- if (tracked) { +- tracked.meta = meta; +- } - - this._emitAction({ -- type: ActionType.SessionToolCallStart, +- type: ActionType.ChatToolCallStart, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - toolName: e.data.toolName, - displayName, - contributor, -- _meta: meta, +- _meta: toToolCallMeta(meta), - }, parentToolCallId); - +- // No client is connected to run this client tool. Fail it +- // immediately instead of leaving it pending until the +- // server-side disconnect timeout fires. We emit the completion +- // ourselves and drop the active-tool entry so the SDK's own +- // tool.execution_complete for this id is suppressed. +- if (isClientTool && !contributor) { +- this._logService.warn(`[Copilot:${sessionId}] Client tool '${e.data.toolName}' started with no connected client; failing it immediately.`); +- this._activeToolCalls.delete(e.data.toolCallId); +- this._emitAction({ +- type: ActionType.ChatToolCallReady, +- turnId: this._turnId, +- toolCallId: e.data.toolCallId, +- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), +- toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), +- confirmed: ToolCallConfirmationReason.NotNeeded, +- }, parentToolCallId); +- this._emitAction({ +- type: ActionType.ChatToolCallComplete, +- turnId: this._turnId, +- toolCallId: e.data.toolCallId, +- result: { +- success: false, +- pastTenseMessage: `${displayName} failed`, +- error: { message: `No client was connected to run ${displayName}` }, +- }, +- }, parentToolCallId); +- this._pendingClientToolCalls.respondOrBuffer(e.data.toolCallId, { +- textResultForLlm: `No client was connected to run ${displayName}.`, +- resultType: 'failure', +- error: 'No client connected', +- }); +- return; +- } +- - // For client tools, do NOT auto-ready — the tool handler will fire - // a separate tool_ready signal once the deferred is in place (or -- // the permission flow fires it first). -- if (toolClientId) { +- // the permission flow fires it first). MCP tools have no such +- // handler and are auto-readied below alongside built-in tools. +- if (contributor?.kind === ToolCallContributorKind.Client) { - return; - } - - this._emitAction({ -- type: ActionType.SessionToolCallReady, +- type: ActionType.ChatToolCallReady, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), @@ -4842,6 +6142,19 @@ index 770817327b0..00000000000 - const displayName = tracked.displayName; - const toolOutput = e.data.error?.message ?? e.data.result?.content; - +- if (isTaskCompleteTool(tracked.toolName)) { +- this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); +- const summary = getTaskCompleteMarkdown(tracked.parameters, toolOutput); +- if (summary) { +- this._emitAction({ +- type: ActionType.ChatResponsePart, +- turnId: this._turnId, +- part: { kind: ResponsePartKind.Markdown, id: generateUuid(), content: summary }, +- }); +- } +- return; +- } +- - const content: ToolResultContent[] = [...tracked.content]; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); @@ -4851,7 +6164,7 @@ index 770817327b0..00000000000 - const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; - for (const filePath of filePaths) { - try { -- const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath); +- const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, this._lastSeenModelId); - if (fileEdit) { - content.push(fileEdit); - } @@ -4874,8 +6187,25 @@ index 770817327b0..00000000000 - } - - this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); +- // eslint-disable-next-line local/code-no-untyped-meta-access -- Copilot SDK's own typed `_meta`, not the AHP protocol bag. +- const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; +- let completeMeta: IToolCallMeta | undefined = tracked.meta; +- if (resourceUri) { +- const ui: Mutable = { resourceUri }; +- if (tracked.mcpServerName) { +- const channel = this._mcpCustomizations.channelForServer(tracked.mcpServerName); +- if (channel !== undefined) { +- ui.channel = channel; +- } +- } +- // Merge the `ui` namespace on top of whatever meta we +- // emitted at start time (`toolKind`, `subagentDescription`, +- // `toolArguments`, …). Reducers replace the whole `_meta` +- // blob, so we must do the merge here. +- completeMeta = { ...(tracked.meta ?? {}), ui }; +- } - this._emitAction({ -- type: ActionType.SessionToolCallComplete, +- type: ActionType.ChatToolCallComplete, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - result: { @@ -4884,14 +6214,15 @@ index 770817327b0..00000000000 - content: content.length > 0 ? content : undefined, - error: e.data.error, - }, +- _meta: completeMeta ? toToolCallMeta(completeMeta) : undefined, - }, parentToolCallId); - })); - -- this._register(wrapper.onIdle(() => { +- this._register(wrapper.onIdle(e => { - this._logService.info(`[Copilot:${sessionId}] Session idle`); - // Clear any in-progress activity description set during the - // turn (e.g. via the `report_intent` tool) — the agent is no -- // longer doing anything once the turn completes. +- // longer doing anything once the loop settles. - if (this._hasReportedActivity) { - this._hasReportedActivity = false; - this._emitAction({ @@ -4899,6 +6230,36 @@ index 770817327b0..00000000000 - activity: undefined, - }); - } +- const turn = this._currentTurn; +- if (!turn) { +- return; +- } +- // An abort drives the loop to idle. That terminal idle must never +- // complete a turn: +- // - if `turn` is the aborted (running) turn, the client-dispatched +- // `ChatTurnCancelled` finalizes the protocol turn; drop our handle +- // so a later idle can't complete it. +- // - if `turn` is still `pending`, a queued message started it after +- // the abort and the SDK has not run it yet; completing it would +- // emit an empty `ChatTurnComplete` and orphan its real response. +- // Leave it open for its own (non-abort) idle. +- // The structural `pending` guard below already protects the +- // queued-message case; reading `e.data.aborted` is the authoritative +- // SDK signal that lets us also tear down the aborted running turn. +- if (e.data.aborted) { +- if (turn.isRunning) { +- this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down running turn ${turn.id}`); +- turn.markAborted(); +- this._currentTurn = undefined; +- } else { +- this._logService.trace(`[Copilot:${sessionId}] Idle from abort; leaving ${turn.state} turn ${turn.id} open`); +- } +- return; +- } +- // Only a `running` turn is completed by a normal idle. A `pending` +- // turn here means the SDK went idle before emitting any event for it +- // (a degenerate no-op send); complete it defensively so the session +- // does not hang. - this._completeActiveTurn(); - })); - @@ -4910,21 +6271,21 @@ index 770817327b0..00000000000 - this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); - const synth = synthesizeSkillToolCall(e.data, e.id); - this._emitAction({ -- type: ActionType.SessionToolCallStart, +- type: ActionType.ChatToolCallStart, - turnId: this._turnId, - toolCallId: synth.toolCallId, - toolName: synth.toolName, - displayName: synth.displayName, - }); - this._emitAction({ -- type: ActionType.SessionToolCallReady, +- type: ActionType.ChatToolCallReady, - turnId: this._turnId, - toolCallId: synth.toolCallId, - invocationMessage: synth.invocationMessage, - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - this._emitAction({ -- type: ActionType.SessionToolCallComplete, +- type: ActionType.ChatToolCallComplete, - turnId: this._turnId, - toolCallId: synth.toolCallId, - result: { @@ -4951,23 +6312,47 @@ index 770817327b0..00000000000 - - this._register(wrapper.onSessionError(e => { - this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); +- // Prefer the structured SDK fields (the Copilot CLI classifies its own +- // CAPI errors); fall back to decoding a forwarded marker from the message. +- const meta = tryBuildChatErrorMetaFromFields(e.data) ?? tryBuildChatErrorMeta(e.data.message); - this._emitAction({ -- type: ActionType.SessionError, +- type: ActionType.ChatError, - turnId: this._turnId, - error: { - errorType: e.data.errorType, -- message: e.data.message, +- message: stripProxyErrorMarker(e.data.message), - stack: e.data.stack, +- ...(meta ? { _meta: meta } : {}), - }, - }); - })); - - this._register(wrapper.onUsage(e => { -- const metadata: Record = {}; +- const metadata: UsageInfoMeta = {}; - if (typeof e.data.cost === 'number') { - metadata.cost = e.data.cost; - } -- this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}, cost=${e.data.cost ?? '?'}`); +- // TODO: `copilotUsage` is marked `asInternal` in the SDK schema so it is not exposed on the generated +- // `AssistantUsageData` type, but it is present at runtime. Read it dynamically. +- const copilotUsage = (e.data as unknown as Record).copilotUsage as { totalNanoAiu?: number } | undefined; +- const turn = this._currentTurn; +- if (turn && typeof copilotUsage?.totalNanoAiu === 'number') { +- turn.copilotUsageTotalNanoAiu += copilotUsage.totalNanoAiu; +- metadata.copilotUsage = { +- ...copilotUsage, +- totalNanoAiu: turn.copilotUsageTotalNanoAiu, +- }; +- } +- // `quotaSnapshots` is likewise `asInternal` in the SDK schema (not on the generated type) but is +- // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the +- // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. +- const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); +- if (quotaSnapshots) { +- metadata.quotaSnapshots = quotaSnapshots; +- } +- if (typeof e.data.model === 'string' && e.data.model) { +- this._lastSeenModelId = e.data.model; +- } - const usage: UsageInfo = { - inputTokens: e.data.inputTokens, - outputTokens: e.data.outputTokens, @@ -4976,7 +6361,7 @@ index 770817327b0..00000000000 - ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), - }; - this._emitAction({ -- type: ActionType.SessionUsage, +- type: ActionType.ChatUsage, - turnId: this._turnId, - usage, - }); @@ -4992,10 +6377,8 @@ index 770817327b0..00000000000 - - // 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. +- // before sending). The SDK and AHP share the same three modes +- // (`interactive` / `plan` / `autopilot`), so we map directly. - this._register(wrapper.onSessionModeChanged(e => { - this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`); - const newMode = e.data.newMode; @@ -5005,17 +6388,140 @@ index 770817327b0..00000000000 - this._lastAppliedMode = newMode; - this._syncAhpConfigFromSdkMode(newMode); - })); +- +- // Translate SDK-reported MCP server lifecycle into AHP customization +- // actions. The controller decides whether each server is a +- // plugin-derived child (narrow `SessionMcpServerStateChanged`) or a +- // bare top-level entry (`SessionCustomizationUpdated`). +- this._register(wrapper.onMcpServersLoaded(e => { +- this._applyMcpServerList(e.data.servers); +- })); +- this._register(wrapper.onMcpServerStatusChanged(e => { +- const server = this._toSdkMcpServer(e.data.serverName, e.data.status, e.data.error); +- if (!server) { +- this._mcpCustomizations.remove(e.data.serverName); +- return; +- } +- this._mcpCustomizations.applyOne(server); +- })); +- +- this._register(wrapper.onToolsUpdated(() => { +- this._fireMcpToolsListChanged(); +- })); +- +- // Seed the inventory with any servers the SDK has already loaded by +- // the time we attach. The `session.mcp_servers_loaded` event may +- // have fired before our subscription (e.g. for restored sessions or +- // when servers are configured at session-creation time), and there +- // is no replay. Subsequent `applyAll` calls from the event are +- // idempotent, so this safely converges either way. +- this._seedMcpServersFromRpc(); +- } +- +- /** +- * One-shot fetch of `rpc.mcp.list` at subscription time. Best-effort: +- * any failure is logged and the inventory simply stays empty until the +- * next live event arrives. +- */ +- private _seedMcpServersFromRpc(): void { +- const mcpRpc = this._wrapper.session.rpc?.mcp; +- if (!mcpRpc) { +- // Older SDKs (and test mocks) may not expose the MCP RPC surface. +- return; +- } +- mcpRpc.list().then(result => { +- if (this._store.isDisposed) { +- return; +- } +- this._applyMcpServerList(result.servers); +- }, err => { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to seed MCP server inventory`, err); +- }); +- } +- +- private _applyMcpServerList(servers: readonly { readonly name: string; readonly status: SdkMcpServerStatus; readonly error?: string }[]): void { +- const sdkServers = servers +- .map(s => this._toSdkMcpServer(s.name, s.status, s.error)) +- .filter(isDefined); +- this._mcpCustomizations.applyAll(sdkServers); +- } +- +- /** +- * Broadcasts `notifications/tools/list_changed` for every MCP server +- * currently in the `Ready` state. The SDK's `session.tools_updated` +- * event is a coarse "tools refreshed" hint that doesn't identify +- * which server changed, so we fan out to all ready channels. Clients +- * are expected to refetch `tools/list` on each notification. +- */ +- private _fireMcpToolsListChanged(): void { +- for (const { channel } of this._mcpCustomizations.readyChannels()) { +- this._onMcpNotification.fire({ +- channel, +- method: 'notifications/tools/list_changed', +- }); +- } +- } +- +- /** Snapshot of MCP servers that have no plugin-derived child entry. */ +- topLevelMcpCustomizations() { +- return this._mcpCustomizations.topLevelCustomizations(); +- } +- +- /** +- * Translates the SDK's flat MCP status string into AHP's discriminated +- * {@link McpServerState} union. Returns `undefined` for +- * `not_configured`, which has no AHP equivalent — the server is +- * dropped from the inventory. +- * +- * V1 maps `needs-auth` to {@link McpServerStatus.Starting}: OAuth +- * handling is intentionally out of scope, so authRequired transitions +- * are masked as "still connecting" until the auth pipeline lands. +- */ +- private _toSdkMcpServer(name: string, status: SdkMcpServerStatus, error?: string): ISdkMcpServer | undefined { +- const state = this._translateSdkMcpStatus(status, error); +- if (!state) { +- return undefined; +- } +- return { name, state }; +- } +- +- private _translateSdkMcpStatus(status: SdkMcpServerStatus, error?: string): McpServerState | undefined { +- switch (status) { +- case 'connected': +- return { kind: McpServerStatus.Ready }; +- case 'failed': +- return { +- kind: McpServerStatus.Error, +- error: { +- errorType: 'mcp-server-failed', +- message: error ?? 'MCP server failed to start', +- }, +- }; +- case 'pending': +- case 'needs-auth': +- // TODO: surface `needs-auth` as McpServerStatus.AuthRequired +- // once OAuth wiring is in place. +- return { kind: McpServerStatus.Starting }; +- case 'disabled': +- return { kind: McpServerStatus.Stopped }; +- case 'not_configured': +- return undefined; +- default: +- return undefined; +- } - } - - /** - * Translates the SDK's three-mode space (`interactive` / `plan` / -- * `autopilot`) to AHP's two-axis model: +- * `autopilot`) to AHP's `mode` axis directly: - * - * - 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. +- * - SDK `autopilot` → AHP `mode='autopilot'`. +- * +- * Autopilot lives on the `mode` axis; the orthogonal `autoApprove` axis +- * (Default / Bypass) is left untouched so the user's chosen +- * approval level is preserved across SDK mode transitions. - * - * Patches that already match the current AHP values are still - * dispatched (the reducer is a no-op in that case) but written values @@ -5029,8 +6535,7 @@ index 770817327b0..00000000000 - patch[SessionConfigKey.Mode] = 'plan'; - break; - case 'autopilot': -- patch[SessionConfigKey.Mode] = 'interactive'; -- patch[SessionConfigKey.AutoApprove] = 'autopilot'; +- patch[SessionConfigKey.Mode] = 'autopilot'; - break; - case 'interactive': - patch[SessionConfigKey.Mode] = 'interactive'; @@ -5041,7 +6546,7 @@ index 770817327b0..00000000000 - - /** - * Handles the CLI's `exitPlanMode.request` RPC by surfacing it as a -- * {@link SessionInputRequest} and awaiting the client's response. The +- * {@link ChatInputRequest} 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 @@ -5052,16 +6557,6 @@ index 770817327b0..00000000000 - 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; @@ -5091,10 +6586,10 @@ index 770817327b0..00000000000 - }; - }); - -- const inputRequest: SessionInputRequest = { +- const inputRequest: ChatInputRequest = { - id: requestId, - questions: [{ -- kind: SessionInputQuestionKind.SingleSelect, +- kind: ChatInputQuestionKind.SingleSelect, - id: questionId, - title: localize('agentHost.planReview.title', "Review Plan"), - message: localize('agentHost.planReview.questionMessage', "How would you like to proceed?"), @@ -5116,7 +6611,7 @@ index 770817327b0..00000000000 - kind: 'action', - session: this.sessionUri, - action: { -- type: ActionType.SessionInputRequested, +- type: ActionType.ChatInputRequested, - request: inputRequest, - } - }); @@ -5142,7 +6637,21 @@ index 770817327b0..00000000000 - })); - - this._register(wrapper.onSessionInfo(e => { -- this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`); +- const attributes: Record = { infoType: e.data.infoType }; +- if (e.data.tip) { +- attributes.tip = e.data.tip; +- } +- const message = `[Copilot:${sessionId}] [${e.data.infoType}]: ${e.data.message}`; +- const otelData = new OtelData(attributes); +- if (e.data.infoType === 'mcp') { +- this._logService.info(message, otelData); +- } else { +- this._logService.trace(message, otelData); +- } +- })); +- +- this._register(wrapper.onSessionWarning(e => { +- this._logService.warn(`[Copilot:${sessionId}] ${e.data.message}`, new OtelData({ warningType: e.data.warningType })); - })); - - this._register(wrapper.onSessionModelChange(e => { @@ -5186,6 +6695,7 @@ index 770817327b0..00000000000 - })); - - this._register(wrapper.onTurnStart(e => { +- this._currentTurn?.markRunning(); - this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); - })); - @@ -5341,14 +6851,14 @@ index 770817327b0..00000000000 - - private _cancelPendingUserInputs(): void { - for (const [, pending] of this._pendingUserInputs) { -- pending.deferred.complete({ response: SessionInputResponseKind.Cancel }); +- pending.deferred.complete({ response: ChatInputResponseKind.Cancel }); - } - this._pendingUserInputs.clear(); - } - - private _cancelPendingElicitations(): void { - for (const [, pending] of this._pendingElicitations) { -- pending.deferred.complete({ response: SessionInputResponseKind.Cancel }); +- pending.deferred.complete({ response: ChatInputResponseKind.Cancel }); - } - this._pendingElicitations.clear(); - } @@ -5360,53 +6870,19 @@ index 770817327b0..00000000000 - 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' }); +- private _cancelPendingMcpSamplings(): void { +- const pending = Array.from(this._pendingMcpSamplings); +- this._pendingMcpSamplings.clear(); +- for (const requestId of pending) { +- this._wrapper.session.rpc.mcp.cancelSamplingExecution({ requestId }).catch(() => { +- // Best-effort: SDK may have already torn down. +- }); - } -- this._pendingClientToolCalls.clear(); -- } --} -- --/** -- * 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: ExitPlanModeRequest): 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 } : {}), -- }; -- } +- private _cancelPendingClientToolCalls(): void { +- this._pendingClientToolCalls.denyAll({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' }); - } -- -- return { approved: true, autoApproveEdits: true }; -} - -/** @@ -5431,12 +6907,48 @@ index 770817327b0..00000000000 - } - return { added, removed }; -} +- +-/** +- * Normalizes the SDK's internal `quotaSnapshots` field — present on the `assistant.usage` event at +- * runtime but absent from the generated `AssistantUsageData` type — into the serializable shape +- * carried on {@link UsageInfoMeta.quotaSnapshots}. Returns `undefined` when no usable snapshot is present. +- */ +-function normalizeQuotaSnapshots(raw: unknown): UsageInfoMeta['quotaSnapshots'] | undefined { +- if (!raw || typeof raw !== 'object') { +- return undefined; +- } +- const result: NonNullable = {}; +- let hasAny = false; +- for (const [quotaType, value] of Object.entries(raw as Record)) { +- if (!value || typeof value !== 'object') { +- continue; +- } +- const v = value as Record; +- const resetDateRaw = v.resetDate; +- const resetDate = typeof resetDateRaw === 'string' +- ? resetDateRaw +- : resetDateRaw instanceof Date +- ? resetDateRaw.toISOString() +- : undefined; +- result[quotaType] = { +- isUnlimitedEntitlement: typeof v.isUnlimitedEntitlement === 'boolean' ? v.isUnlimitedEntitlement : undefined, +- entitlementRequests: typeof v.entitlementRequests === 'number' ? v.entitlementRequests : undefined, +- usedRequests: typeof v.usedRequests === 'number' ? v.usedRequests : undefined, +- remainingPercentage: typeof v.remainingPercentage === 'number' ? v.remainingPercentage : undefined, +- overage: typeof v.overage === 'number' ? v.overage : undefined, +- overageAllowedWithExhaustedQuota: typeof v.overageAllowedWithExhaustedQuota === 'boolean' ? v.overageAllowedWithExhaustedQuota : undefined, +- resetDate, +- }; +- hasAny = true; +- } +- return hasAny ? result : undefined; +-} diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts deleted file mode 100644 -index 6ac611adf64..00000000000 +index 2c055e48..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ /dev/null -@@ -1,418 +0,0 @@ +@@ -1,502 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -5444,10 +6956,12 @@ index 6ac611adf64..00000000000 - -import { spawn } from 'child_process'; -import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk'; +-import { Schemas } from '../../../../base/common/network.js'; -import { OperatingSystem, OS } from '../../../../base/common/platform.js'; +-import { URI } from '../../../../base/common/uri.js'; -import { parseFrontMatter } from '../../../../base/common/yaml.js'; -import { IFileService } from '../../../files/common/files.js'; --import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +-import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedAgent, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import { type AgentCustomization, type ChildCustomization } from '../../common/state/protocol/state.js'; -import { dirname } from '../../../../base/common/path.js'; @@ -5470,32 +6984,73 @@ index 6ac611adf64..00000000000 -export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record { - const result: Record = {}; - for (const def of defs) { -- const config = def.configuration; -- if (config.type === McpServerType.LOCAL) { -- result[def.name] = { -- type: 'local', -- command: config.command, -- args: config.args ? [...config.args] : [], -- tools: ['*'], -- ...(config.env && { env: toStringEnv(config.env) }), -- ...(config.cwd && { cwd: config.cwd }), -- }; -- } else { -- result[def.name] = { -- type: 'http', -- url: config.url, -- tools: ['*'], -- ...(config.headers && { headers: { ...config.headers } }), -- }; -- } +- result[def.name] = toSdkMcpServer(def.name, def.configuration); - } - return result; -} - -/** -- * Ensures all env values are strings (the SDK requires `Record`). +- * Converts root MCP server config maps into the SDK's `mcpServers` config. +- * +- * The map originates from user-controlled root config, where the schema cannot +- * express per-entry validation (no `additionalProperties`). Entries are +- * therefore treated as `unknown` and silently skipped unless they match one of +- * the two supported shapes (`stdio` with a `command`, or `http` with a `url`), +- * so a malformed entry can't surface as `command`/`url: undefined` in the SDK +- * config. - */ --function toStringEnv(env: Record): Record { +-export function toSdkMcpServersFromConfigMap(servers: Record): Record { +- const result: Record = {}; +- for (const [name, config] of Object.entries(servers)) { +- if (isSupportedMcpServerConfiguration(config)) { +- result[name] = toSdkMcpServer(name, config); +- } +- } +- return result; +-} +- +-/** +- * Narrows an untrusted value to a supported {@link IMcpServerConfiguration}: +- * a `stdio` server with a string `command`, or an `http` server with a string +- * `url`. +- */ +-function isSupportedMcpServerConfiguration(value: unknown): value is IMcpServerConfiguration { +- if (!value || typeof value !== 'object') { +- return false; +- } +- const candidate = value as { type?: unknown; command?: unknown; url?: unknown }; +- if (candidate.type === McpServerType.LOCAL) { +- return typeof candidate.command === 'string'; +- } +- if (candidate.type === McpServerType.REMOTE) { +- return typeof candidate.url === 'string'; +- } +- return false; +-} +- +-function toSdkMcpServer(_name: string, config: IMcpServerConfiguration): MCPServerConfig { +- if (config.type === McpServerType.LOCAL) { +- return { +- type: 'local', +- command: config.command, +- args: config.args ? [...config.args] : [], +- tools: ['*'], +- ...(config.env && { env: toStringEnv(config.env) }), +- ...(config.cwd && { cwd: config.cwd }), +- }; +- } +- return { +- type: 'http', +- url: config.url, +- tools: ['*'], +- ...(config.headers && { headers: { ...config.headers } }), +- }; +-} +- +-/** +- * Ensures all env values are strings (the SDK requires `Record`). +- */ +-function toStringEnv(env: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== null) { @@ -5527,7 +7082,11 @@ index 6ac611adf64..00000000000 - const content = await fileService.readFile(agent.uri); - const raw = content.value.toString(); - const md = parseFrontMatter(raw); -- const name = md?.getStringValue('name') ?? agent.name; +- // Match `parseAgentFile`'s name derivation (trim + falsy fallback) so +- // the SDK config name equals the `resolvedAgentName` resolved from the +- // parsed plugin agent; otherwise a whitespace-padded frontmatter `name` +- // would make the SDK reject the session-start `agent:` as not found. +- const name = md?.getStringValue('name')?.trim() || agent.name; - const description = md?.getStringValue('description'); - const tools = md?.getStringArrayValue('tools'); - const prompt = md?.body ?? raw; @@ -5551,6 +7110,43 @@ index 6ac611adf64..00000000000 - return configs; -} - +-/** A plugin's agents together with its on-disk location (if any). */ +-export interface IPluginAgentsForSdk { +- readonly pluginDir?: URI; +- readonly agents: readonly INamedPluginResource[]; +-} +- +-/** +- * Builds the SDK's `customAgents` config for a session. +- * +- * Agents contributed by plugins materialized into an on-disk (file-scheme) +- * directory are normally left out of `customAgents` and discovered by the SDK +- * through `pluginDirectories` instead, to avoid duplicates. However, the SDK +- * validates the session-start `agent:` option against `customAgents` *by name +- * only* — it does NOT consult `pluginDirectories`. So a selected plugin or +- * extension agent (e.g. one chosen in the agent picker) would otherwise fail +- * with "Custom agent '' not found". This forces the resolved selection +- * into `customAgents` so it can be activated, while every other file-dir agent +- * continues to load via `pluginDirectories`. +- */ +-export async function toSdkSessionCustomAgents( +- plugins: readonly IPluginAgentsForSdk[], +- resolvedAgentName: string | undefined, +- fileService: IFileService, +-): Promise { +- const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file); +- const customAgents = await toSdkCustomAgents(pluginsWithoutDirs.flatMap(p => p.agents), fileService); +- if (resolvedAgentName && !customAgents.some(agent => agent.name === resolvedAgentName)) { +- const selectedAgents = plugins.flatMap(p => p.agents).filter(agent => agent.name === resolvedAgentName); +- for (const config of await toSdkCustomAgents(selectedAgents, fileService)) { +- if (!customAgents.some(agent => agent.name === config.name)) { +- customAgents.push(config); +- } +- } +- } +- return customAgents; +-} +- -/** - * Projects parsed plugin agents into their protocol-level - * {@link AgentCustomization} shape. @@ -5857,10 +7453,10 @@ index 6ac611adf64..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts deleted file mode 100644 -index 043c025c8f6..00000000000 +index 97470ca6..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ /dev/null -@@ -1,276 +0,0 @@ +@@ -1,353 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -5873,21 +7469,30 @@ index 043c025c8f6..00000000000 -import { IFileService } from '../../../files/common/files.js'; -import { ILogService } from '../../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; --import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema } from '../../common/agentHostSchema.js'; +-import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +-import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; +-import type { ActiveClientState } from '../activeClientState.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; --import { toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +-import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; +-import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; -import type { ITypedPermissionRequest } from './copilotToolDisplay.js'; -import type { ICopilotPluginInfo } from './copilotAgent.js'; +-import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; +-import './prompts/allPrompts.js'; - -export const ThinkingLevelConfigKey = 'thinkingLevel'; +-export const ContextTierConfigKey = 'contextTier'; - -const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; -type ReasoningEffort = NonNullable; - +-const ContextTiers = ['default', 'long_context'] as const; +-type ContextTier = NonNullable; +- -type UserInputHandler = NonNullable; -type UserInputRequest = Parameters[0]; -type UserInputInvocation = Parameters[1]; @@ -5903,24 +7508,19 @@ index 043c025c8f6..00000000000 - readonly remoteSession?: 'export'; -}; - --export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { -- mode: 'customize', -- sections: { -- identity: { -- action: 'replace', -- content: 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.', -- }, -- }, --} satisfies NonNullable; -- -/** -- * Immutable snapshot of the active client's contributions at session creation -- * time. Used to detect when the session needs to be refreshed. +- * Immutable snapshot of the active client's structural contributions at +- * session creation time. Used to detect when the session needs to be +- * refreshed. Root MCP servers participate in restart detection because they +- * are merged into the SDK session config. The owning `clientId` is +- * deliberately NOT part of this snapshot: client identity is tracked live via +- * {@link ActiveClientState} so a window +- * reload (new `clientId`, identical tools/plugins) does not force a restart. - */ -export interface IActiveClientSnapshot { -- readonly clientId: string; - readonly tools: readonly ToolDefinition[]; - readonly plugins: readonly ICopilotPluginInfo[]; +- readonly mcpServers: AgentHostMcpServers; -} - -export interface ICopilotSessionRuntime { @@ -5933,6 +7533,8 @@ index 043c025c8f6..00000000000 - handlePostToolUse(input: PostToolUseHookInput): Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createClientSdkTools(): Tool[]; +- // eslint-disable-next-line @typescript-eslint/no-explicit-any +- createServerSdkTools(): Tool[]; -} - -export interface ICopilotSessionLauncher { @@ -5951,6 +7553,13 @@ index 043c025c8f6..00000000000 - readonly workingDirectory: URI | undefined; - readonly resolvedAgentName: string | undefined; - readonly snapshot: IActiveClientSnapshot; +- /** +- * Live, long-lived holder of the owning client's identity. Read at +- * tool-call stamp time so a window reload (new `clientId`, identical +- * tools) stamps subsequent client tool calls with the current id +- * rather than the one frozen into {@link snapshot} at creation. +- */ +- readonly activeClientState: ActiveClientState; - readonly shellManager: ShellManager | undefined; - readonly githubToken: string | undefined; -} @@ -5974,6 +7583,10 @@ index 043c025c8f6..00000000000 - return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); -} - +-function isContextTier(value: string | undefined): value is ContextTier { +- return ContextTiers.some(contextTier => contextTier === value); +-} +- -function getCopilotSdkErrorCode(err: unknown): number | undefined { - if (typeof err !== 'object' || err === null) { - return undefined; @@ -6023,6 +7636,11 @@ index 043c025c8f6..00000000000 - return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; -} - +-export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { +- const contextTier = model?.config?.[ContextTierConfigKey]; +- return isContextTier(contextTier) ? contextTier : undefined; +-} +- -export class CopilotSessionLauncher implements ICopilotSessionLauncher { - - constructor( @@ -6034,8 +7652,9 @@ index 043c025c8f6..00000000000 - - async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { - const config = await this._buildSessionConfig(plan, runtime); +- const sandboxConfig = this._computeSandboxConfig(); - if (plan.kind === 'create') { -- return this._createSession(plan, config); +- return this._createSession(plan, config, sandboxConfig); - } - - try { @@ -6046,6 +7665,7 @@ index 043c025c8f6..00000000000 - ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), - }); - this._logService.info(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded`); +- await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); - return new CopilotSessionWrapper(raw); - } catch (err) { - const errCode = getCopilotSdkErrorCode(err); @@ -6063,25 +7683,69 @@ index 043c025c8f6..00000000000 - ...plan, - kind: 'create', - model: plan.fallback.model, -- }, config); +- }, config, sandboxConfig); - this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); - return wrapper; - } - } - -- private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: CopilotSessionLaunchConfig): Promise { +- private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: CopilotSessionLaunchConfig, sandboxConfig: ISdkSandboxConfig | undefined): Promise { - const raw = await plan.client.createSession({ - ...config, - sessionId: plan.sessionId, - streaming: true, - model: plan.model?.id, - reasoningEffort: getCopilotReasoningEffort(plan.model), +- contextTier: getCopilotContextTier(plan.model), - ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), - workingDirectory: plan.workingDirectory?.fsPath, - }); +- await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); - return new CopilotSessionWrapper(raw); - } - +- /** +- * Compute the SDK-shaped sandbox policy to push to the runtime for the +- * SDK's built-in shell tool. +- * +- * Returns `undefined` when {@link AgentHostConfigKey.EnableCustomTerminalTool} +- * is ON — in that case the AgentHost provides its own shell tools, which +- * wrap commands via the host terminal sandbox engine, so no SDK-side +- * sandbox policy is needed. Otherwise the policy is derived from the +- * host's `sandbox` config bag (forwarded from the workbench's +- * `chat.agent.sandbox.*` settings), mirroring what +- * `buildSandboxConfigForCLI` does for the Copilot extension's CLI path. +- */ +- private _computeSandboxConfig(): ISdkSandboxConfig | undefined { +- const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; +- if (enableCustomTerminalTool) { +- return undefined; +- } +- return buildSandboxConfigForSdk(process.platform, this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox)); +- } +- +- /** +- * Forward the SDK-shaped sandbox policy to the runtime via +- * `session.options.update`, immediately after the session is created or +- * resumed. `SessionUpdateOptionsParams.sandboxConfig` is now typed by the +- * SDK (as `SandboxConfig`), and our {@link ISdkSandboxConfig} shape is +- * structurally assignable to it, so we forward it directly. +- * +- * No-op when {@link _computeSandboxConfig} returned `undefined` (custom +- * terminal tool enabled, or the host sandbox config evaluates to disabled). +- */ +- private async _applySandboxConfig(session: CopilotSessionWrapper['session'], sandboxConfig: ISdkSandboxConfig | undefined, sessionId: string): Promise { +- if (!sandboxConfig) { +- return; +- } +- try { +- await session.rpc.options.update({ sandboxConfig }); +- this._logService.info(`[Copilot:${sessionId}] Applied SDK sandboxConfig via session.options.update`); +- } catch (err) { +- this._logService.warn(`[Copilot:${sessionId}] Failed to apply SDK sandboxConfig`, err); +- } +- } +- - private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { - const plugins = plan.snapshot.plugins; - const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; @@ -6092,12 +7756,21 @@ index 043c025c8f6..00000000000 - } - shellTools = await createShellTools(plan.shellManager, this._terminalManager, this._logService, request => runtime.requestUnsandboxedCommandConfirmation(request)); - } -- // Rely on SDK to find all agents/skills & the like from the plugins instead of us feeding them. -- // Else we could end up with duplicates or the like. +- // Rely on the SDK to discover most agents/skills/etc. from `pluginDirectories` +- // instead of feeding them explicitly, to avoid duplicates. Custom agents are the +- // exception: the SDK validates the session-start `agent:` against `customAgents` +- // by name, so the selected agent is force-included (see `toSdkSessionCustomAgents`). - const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file); -- const customAgents = await toSdkCustomAgents(pluginsWithoutDirs.flatMap(p => p.agents), this._fileService); +- const customAgents = await toSdkSessionCustomAgents(plugins, plan.resolvedAgentName, this._fileService); +- const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); +- const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); +- const model = plan.kind === 'create' ? plan.model : plan.fallback.model; +- const promptContext: IAgentHostPromptContext = { +- getSetting: key => this._configurationService.getRootValue(agentHostCustomizationConfigSchema, key), +- }; - return { - clientName: 'vscode', +- enableMcpApps: true, - onPermissionRequest: request => runtime.handlePermissionRequest(request), - onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), - onElicitationRequest: context => runtime.handleElicitationRequest(context), @@ -6105,16 +7778,16 @@ index 043c025c8f6..00000000000 - onPreToolUse: input => runtime.handlePreToolUse(input), - onPostToolUse: input => runtime.handlePostToolUse(input), - }), -- mcpServers: toSdkMcpServers(pluginsWithoutDirs.flatMap(p => p.mcpServers)), +- mcpServers: { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(pluginsWithoutDirs.flatMap(p => p.mcpServers)) }, - onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), - workingDirectory: plan.workingDirectory?.fsPath, - customAgents, -- skillDirectories: toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)), -- instructionDirectories: toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)), -- systemMessage: COPILOT_AGENT_HOST_SYSTEM_MESSAGE, +- skillDirectories, +- instructionDirectories, +- systemMessage: agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext), - pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) - .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), -- tools: [...shellTools, ...runtime.createClientSdkTools()], +- tools: [...shellTools, ...runtime.createClientSdkTools(), ...runtime.createServerSdkTools()], - // Pass the GitHub token at the session level. The SDK's - // client-level `gitHubToken` authenticates the CLI process, - // but each session also needs its own token resolved into a @@ -6139,10 +7812,10 @@ index 043c025c8f6..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts deleted file mode 100644 -index 0890c64e4b1..00000000000 +index 04eba456..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ /dev/null -@@ -1,227 +0,0 @@ +@@ -1,247 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -6213,6 +7886,11 @@ index 0890c64e4b1..00000000000 - return this._onSessionInfo ??= this._sdkEvent('session.info'); - } - +- private _onSessionWarning: Event> | undefined; +- get onSessionWarning(): Event> { +- return this._onSessionWarning ??= this._sdkEvent('session.warning'); +- } +- - private _onSessionModelChange: Event> | undefined; - get onSessionModelChange(): Event> { - return this._onSessionModelChange ??= this._sdkEvent('session.model_change'); @@ -6363,6 +8041,21 @@ index 0890c64e4b1..00000000000 - return this._onSessionModeChanged ??= this._sdkEvent('session.mode_changed'); - } - +- private _onMcpServersLoaded: Event> | undefined; +- get onMcpServersLoaded(): Event> { +- return this._onMcpServersLoaded ??= this._sdkEvent('session.mcp_servers_loaded'); +- } +- +- private _onMcpServerStatusChanged: Event> | undefined; +- get onMcpServerStatusChanged(): Event> { +- return this._onMcpServerStatusChanged ??= this._sdkEvent('session.mcp_server_status_changed'); +- } +- +- private _onToolsUpdated: Event> | undefined; +- get onToolsUpdated(): Event> { +- return this._onToolsUpdated ??= this._sdkEvent('session.tools_updated'); +- } +- - private _sdkEvent(eventType: K): Event> { - const emitter = this._register(new Emitter>()); - const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); @@ -6372,10 +8065,10 @@ index 0890c64e4b1..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts deleted file mode 100644 -index 14004b8afb1..00000000000 +index d0db4dea..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ /dev/null -@@ -1,1093 +0,0 @@ +@@ -1,1089 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -7094,11 +8787,7 @@ index 14004b8afb1..00000000000 - ); - } - -- const autoApproveUnsandboxed = engine.isAutoApproveUnsandboxedCommands(); - const requestUnsandboxedConfirmation = async (blockedDomains?: readonly string[]): Promise => { -- if (autoApproveUnsandboxed) { -- return true; -- } - if (!confirmUnsandboxedExecution) { - const blocked = blockedDomains?.join(', ') ?? '(unknown)'; - return makeFailureResult( @@ -7471,10 +9160,10 @@ index 14004b8afb1..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts deleted file mode 100644 -index 4943986a0e1..00000000000 +index 25849598..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ /dev/null -@@ -1,132 +0,0 @@ +@@ -1,155 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -7485,6 +9174,7 @@ index 4943986a0e1..00000000000 -import { AgentSession } from '../../common/agentService.js'; -import { CompletionItem, CompletionItemKind, CompletionsParams } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; +-import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; -import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; -import { extractLeadingSlashToken } from '../agentHostSlashCompletion.js'; - @@ -7492,17 +9182,34 @@ index 4943986a0e1..00000000000 - * Slash-command name and the token we surface to the user / round-trip on - * the {@link MessageAttachmentKind.Simple} attachment's `_meta`. - */ --export type CopilotSlashCommandName = 'plan' | 'compact' | 'research' | 'rubber-duck'; +-export type CopilotSlashCommandName = 'plan' | 'compact' | 'research' | 'rubber-duck' | 'env' | 'review' | 'security-review'; - --const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact', 'research', 'rubber-duck']; +-const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact', 'research', 'rubber-duck', 'env', 'review', 'security-review']; +-const RUNTIME_RPC_INVOKED_COMMANDS = new Set(['env']); +-const PROMPT_INVOKED_COMMANDS = new Set(['research', 'review', 'security-review']); -function getCommandDescription(command: CopilotSlashCommandName): string { - switch (command) { - case 'plan': return localize('copilotSlashCommand.plan.description', "Create an implementation plan before coding"); - case 'compact': return localize('copilotSlashCommand.compact.description', "Free up context by compacting the conversation history"); - case 'research': return localize('copilotSlashCommand.research.description', "Run deep research on a topic using search and web sources"); - case 'rubber-duck': return localize('copilotSlashCommand.rubberDuck.description', "Get an independent critique of the current approach"); +- case 'env': return localize('copilotSlashCommand.env.description', "Show loaded environment details"); +- case 'review': return localize('copilotSlashCommand.review.description', "Run code review agent to analyze changes"); +- case 'security-review': return localize('copilotSlashCommand.securityReview.description', "Analyze staged and unstaged changes for security vulnerabilities"); - } -} +- +-export function isRuntimeCopilotSlashCommand(command: CopilotSlashCommandName): boolean { +- return RUNTIME_RPC_INVOKED_COMMANDS.has(command); +-} +- +-export function isPromptInvokedCopilotSlashCommand(command: CopilotSlashCommandName): boolean { +- return PROMPT_INVOKED_COMMANDS.has(command); +-} +- +-function commandExpectsInput(command: CopilotSlashCommandName): boolean { +- return command !== 'compact' && command !== 'env'; +-} -/** - * Lookup hook used by {@link CopilotSlashCommandCompletionProvider} to - * decide whether history-dependent commands (e.g. `/compact`) make sense @@ -7517,6 +9224,8 @@ index 4943986a0e1..00000000000 - * the agent host config. When absent or `false`, `/rubber-duck` is hidden. - */ - isRubberDuckEnabled?(): boolean; +- /** Whether the runtime reports the given slash command as available. */ +- hasRuntimeSlashCommand?(sessionId: string, command: string): Promise; -} - -/** @@ -7531,13 +9240,13 @@ index 4943986a0e1..00000000000 -/** - * Parses a Copilot CLI slash command at the very start of `prompt`. - * -- * The command must be `/plan`, `/compact`, `/research`, or `/rubber-duck`, +- * The command must be `/plan`, `/compact`, `/research`, `/rubber-duck`, `/env`, `/review`, or `/security-review`, - * followed either by end-of-input or by at least one whitespace character. - * `/compact-hello`, `/plans`, or a leading-space `/compact` all return - * `undefined`. Match is case-sensitive. - */ -export function parseLeadingSlashCommand(prompt: string): IParsedLeadingSlashCommand | undefined { -- const match = /^\/(plan|compact|research|rubber-duck)(?:$|\s+([\s\S]*))/.exec(prompt); +- const match = /^\/(plan|compact|research|rubber-duck|env|review|security-review)(?:$|\s+([\s\S]*))/.exec(prompt); - if (!match) { - return undefined; - } @@ -7593,14 +9302,17 @@ index 4943986a0e1..00000000000 - if (command === 'rubber-duck' && !rubberDuckEnabled) { - continue; - } +- if (isRuntimeCopilotSlashCommand(command) && !(await this._sessionInfo?.hasRuntimeSlashCommand?.(sessionId, command))) { +- continue; +- } - items.push({ -- insertText: command === 'compact' ? '/' + command : '/' + command + ' ', +- insertText: commandExpectsInput(command) ? '/' + command + ' ' : '/' + command, - rangeStart: 0, - rangeEnd: leading.rangeEnd, - attachment: { - type: MessageAttachmentKind.Simple, - label: '/' + command, -- _meta: { command, description: getCommandDescription(command) }, +- _meta: toCommandCompletionAttachmentMeta({ command, description: getCommandDescription(command) }), - }, - }); - } @@ -7609,7 +9321,7 @@ index 4943986a0e1..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts deleted file mode 100644 -index a5d5c7836ef..00000000000 +index a5d5c783..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ /dev/null @@ -1,52 +0,0 @@ @@ -7665,323 +9377,817 @@ index a5d5c7836ef..00000000000 - const match = /^\s*([\s\S]*?)\s*<\/system_notification>$/.exec(trimmed); - return (match?.[1] ?? trimmed).trim(); -} +diff --git a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts b/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts +deleted file mode 100644 +index 998236f0..00000000 +--- a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts ++++ /dev/null +@@ -1,26 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-/** Parses the `key=value;...` field map from the leading colon-delimited segment of a Copilot token (e.g. `tid=abc;exp=123;rt=1:HMAC...`). */ +-export function parseCopilotTokenFields(token: string | undefined): ReadonlyMap { +- const result = new Map(); +- if (!token) { +- return result; +- } +- const colonIdx = token.indexOf(':'); +- const header = colonIdx === -1 ? token : token.substring(0, colonIdx); +- for (const field of header.split(';')) { +- const eqIdx = field.indexOf('='); +- if (eqIdx <= 0) { +- continue; +- } +- result.set(field.substring(0, eqIdx), field.substring(eqIdx + 1)); +- } +- return result; +-} +- +-export function isRestrictedTelemetryEnabled(token: string | undefined): boolean { +- return parseCopilotTokenFields(token).get('rt') === '1'; +-} diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts -index bf2d438e32b..967739e3723 100644 +index 3a4c2d71..f596430b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts -@@ -3,14 +3,11 @@ +@@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionRequest } from '@github/copilot-sdk'; ++// `PermissionRequest` originally came from '@github/copilot-sdk' (removed in ++// !!APP_NAME!!). Only its `kind` discriminator is referenced here, so model it ++// as a minimal local shape. ++type PermissionRequest = { kind: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory' | 'extension-management' | 'extension-permission-access' }; import { hasKey } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel, MarkdownString } from '../../../../base/common/htmlContent.js'; - import { hash } from '../../../../base/common/hash.js'; - import { localize } from '../../../../nls.js'; --import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; --import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; - import { StringOrMarkdown } from '../../common/state/protocol/state.js'; - import { basename } from '../../../../base/common/resources.js'; - -@@ -895,152 +892,3 @@ export function tryStringify(value: unknown): string | undefined { - } - } - --/** -- * Loose, optional-field projection of the SDK's {@link PermissionRequest} -- * discriminated union. Lets the rest of the agent host read the well-known -- * fields without `switch (request.kind)` narrowing at every access site. -- * -- * The SDK's `PermissionRequest` (a union with required per-variant fields) is -- * structurally assignable to this interface — every variant carries `kind` -- * and `toolCallId?`, and the variant-specific fields are listed here as -- * optional. Use this type at the agent-host boundary so call sites and tests -- * can rely on a single shape. -- */ --export interface ITypedPermissionRequest { -- /** Permission kind discriminator from the SDK. */ -- kind: PermissionRequest['kind']; -- /** Tool call ID that triggered this permission request, when available. */ -- toolCallId?: string; -- /** File path — set for `read` permission requests. */ -- path?: string; -- /** File path — set for `write` permission requests. */ -- fileName?: string; -- /** Full shell command text — set for `shell` permission requests. */ -- fullCommandText?: string; -- /** Human-readable intention describing the operation. */ -- intention?: string; -- /** MCP server name — set for `mcp` permission requests. */ -- serverName?: string; -- /** Tool name — set for `mcp` and `custom-tool` permission requests. */ -- toolName?: string; -- /** Tool arguments — set for `custom-tool` permission requests. */ -- args?: Record; -- /** URL — set for `url` permission requests. */ -- url?: string; -- /** Unified diff of the proposed change — set for `write` permission requests. */ -- diff?: string; -- /** New file contents that will be written — set for `write` permission requests. */ -- newFileContents?: string; --} -- --/** Safely extract a string value from an SDK field that may be `unknown` at runtime. */ --function str(value: unknown): string | undefined { -- return typeof value === 'string' ? value : undefined; --} -- --/** -- * Derives display fields from a permission request for the tool confirmation UI. -- */ --export function getPermissionDisplay(request: ITypedPermissionRequest, workingDirectory?: URI): { -- confirmationTitle: string; -- invocationMessage: StringOrMarkdown; -- toolInput?: string; -- /** Normalized permission kind for auto-approval routing. */ -- permissionKind: IAgentToolPendingConfirmationSignal['permissionKind']; -- /** File path extracted from the request. */ -- permissionPath?: string; --} { -- const path = str(request.path) ?? str(request.fileName); -- const fullCommandText = str(request.fullCommandText); -- const intention = str(request.intention); -- const serverName = str(request.serverName); -- const toolName = str(request.toolName); -- -- switch (request.kind) { -- case 'shell': { -- // Strip a redundant `cd && …` prefix so the -- // confirmation dialog shows the simplified command. -- const shellParams: Record | undefined = fullCommandText ? { command: fullCommandText } : undefined; -- stripRedundantCdPrefix(CopilotToolName.Bash, shellParams, workingDirectory); -- const cleanedCommand = typeof shellParams?.command === 'string' ? shellParams.command : fullCommandText; -- return { -- confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), -- invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), cleanedCommand ? { command: cleanedCommand } : undefined), -- toolInput: cleanedCommand, -- permissionKind: 'shell', -- permissionPath: path, -- }; -- } -- case 'custom-tool': { -- // Custom tool overrides (e.g. our shell tool). Extract the actual -- // tool args from the SDK's wrapper envelope. -- const args = typeof request.args === 'object' && request.args !== null ? request.args as Record : undefined; -- const sdkToolName = str(request.toolName); -- if (args && sdkToolName && isShellTool(sdkToolName) && typeof args.command === 'string') { -- stripRedundantCdPrefix(sdkToolName, args, workingDirectory); -- const command = args.command as string; -- return { -- confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), -- invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), -- toolInput: command, -- permissionKind: 'shell', -- permissionPath: path, -- }; -- } -- return { -- confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), -- invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), -- toolInput: args ? tryStringify(args) : tryStringify(request), -- permissionKind: request.kind, -- permissionPath: path, -- }; -- } -- case 'write': -- return { -- confirmationTitle: localize('copilot.permission.write.title', "Write file?"), -- invocationMessage: getInvocationMessage(CopilotToolName.Edit, getToolDisplayName(CopilotToolName.Edit), path ? { path } : undefined), -- toolInput: tryStringify(path ? { path } : request) ?? undefined, -- permissionKind: 'write', -- permissionPath: path, -- }; -- case 'mcp': { -- const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); -- return { -- confirmationTitle: serverName -- ? localize('copilot.permission.mcp.title', "Allow tool from {0}?", serverName) -- : localize('copilot.permission.default.title', "Allow tool call?"), -- invocationMessage: serverName ? `${serverName}: ${title}` : title, -- toolInput: tryStringify({ serverName, toolName }) ?? undefined, -- permissionKind: 'mcp', -- permissionPath: path, -- }; -- } -- case 'read': -- return { -- confirmationTitle: localize('copilot.permission.read.title', "Read file?"), -- invocationMessage: intention ?? getInvocationMessage(CopilotToolName.View, getToolDisplayName(CopilotToolName.View), path ? { path } : undefined), -- toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, -- permissionKind: 'read', -- permissionPath: path, -- }; -- case 'url': { -- const url = str(request.url); -- // Parse through URL for punycode escaping, but preserve the raw value if parsing fails. -- const normalizedUrl = url ? (URL.canParse(url) ? new URL(url).href : url) : undefined; -- return { -- confirmationTitle: localize('copilot.permission.url.title', "Fetch URL?"), -- invocationMessage: md(localize('copilot.permission.url.message', "Allow fetching web content?")), -- toolInput: normalizedUrl ? JSON.stringify({ url: normalizedUrl }) : undefined, -- permissionKind: 'url', -- }; -- } -- default: -- return { -- confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), -- invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), -- toolInput: tryStringify(request) ?? undefined, -- permissionKind: request.kind, -- permissionPath: path, -- }; -- } --} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts -index 632c2994430..e25e78b5ca2 100644 +index 93eccbf8..fd4755bc 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts -@@ -3,7 +3,21 @@ +@@ -3,7 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ --import { MessageOptions } from '@github/copilot-sdk'; -+// `MessageOptions` was originally imported from '@github/copilot-sdk'; the SDK -+// is removed in BradfordCode but `mapSessionEvents` is still used by test -+// fixtures + the agent service tests, so define the attachment shape locally -+// from the SDK's public union. Use `any` for the runtime-shaped fields -+// (TextRange, blob payload) so we don't have to re-derive their types. -+type MessageOptions = { -+ attachments?: ReadonlyArray< -+ | { type: 'file'; path: string; displayName?: string } -+ | { type: 'directory'; path: string; displayName?: string } -+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -+ | { type: 'selection'; filePath: string; displayName: string; selection?: any } -+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -+ | { type: 'blob'; mimeType: string; data: any; displayName?: string } -+ >; -+}; +-import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteData } from '@github/copilot-sdk'; ++// These four types originally came from '@github/copilot-sdk' (removed in ++// !!APP_NAME!! — the proprietary Copilot binaries can't be redistributed). They ++// are deep SDK discriminated unions consumed only by the now test-only ++// event-to-Turn mapping below; alias them to `any` so the mapping keeps ++// compiling without re-deriving the SDK's full shape. ++/* eslint-disable @typescript-eslint/no-explicit-any */ ++type AssistantMessageToolRequest = any; ++type Attachment = any; ++type SessionEvent = any; ++type ToolExecutionCompleteData = any; ++/* eslint-enable @typescript-eslint/no-explicit-any */ import { decodeBase64 } from '../../../../base/common/buffer.js'; import { basename } from '../../../../base/common/path.js'; import { isString } from '../../../../base/common/types.js'; -diff --git a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts -index 61cf2cf76c4..e7b0e069468 100644 ---- a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts -+++ b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts -@@ -5,7 +5,12 @@ - - import { mkdir } from 'fs/promises'; - import { dirname, join } from '../../../../base/common/path.js'; --import type { TelemetryConfig } from '@github/copilot-sdk'; -+// `TelemetryConfig` was originally imported from '@github/copilot-sdk'; the SDK -+// is removed in BradfordCode, but agentHostOTelService still exposes a config -+// shape that mirrors it. The common/ side already maintains a structural -+// mirror; this file just needs the type symbol available at compile time. -+// eslint-disable-next-line @typescript-eslint/no-explicit-any -+type TelemetryConfig = any; - import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; - import { URI } from '../../../../base/common/uri.js'; - import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +diff --git a/src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts b/src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts deleted file mode 100644 -index 6bb478dbb36..00000000000 ---- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +index 6c69ad78..00000000 +--- a/src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts +++ /dev/null -@@ -1,1746 +0,0 @@ +@@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - --import type { CopilotClient, CopilotSession, ModelInfo, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; --import assert from 'assert'; --import * as fs from 'fs/promises'; --import * as os from 'os'; --import { VSBuffer } from '../../../../base/common/buffer.js'; --import { DeferredPromise } from '../../../../base/common/async.js'; --import { isCancellationError } from '../../../../base/common/errors.js'; --import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; --import { Event } from '../../../../base/common/event.js'; --import { Schemas } from '../../../../base/common/network.js'; --import { waitForState } from '../../../../base/common/observable.js'; --import { URI } from '../../../../base/common/uri.js'; --import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { INativeEnvironmentService } from '../../../environment/common/environment.js'; --import { FileService } from '../../../files/common/fileService.js'; --import { IFileService } from '../../../files/common/files.js'; --import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; --import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; --import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; --import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; --import { ILogService, NullLogService } from '../../../log/common/log.js'; --import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; --import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; --import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; --import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; --import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; --import { ISessionDataService } from '../../common/sessionDataService.js'; --import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; --import { buildSubagentSessionUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; --import { CustomizationType } from '../../common/state/protocol/state.js'; --import { ActionType, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; -- --import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; --import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; --import { IAgentHostGitService } from '../../node/agentHostGitService.js'; --import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; --import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; --import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; --import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotBranchNameHintFromMessage, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; --import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; --import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; --import type { CopilotSessionLaunchPlan } from '../../node/copilot/copilotSessionLauncher.js'; --import { ShellManager } from '../../node/copilot/copilotShellTools.js'; --import { SessionDatabase } from '../../node/sessionDatabase.js'; --import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +-// Side-effect import hub for per-model Copilot CLI agent-host prompt +-// contributors. Importing this module registers every contributor into the +-// shared `agentHostPromptRegistry`. Mirrors the Copilot extension's +-// `allAgentPrompts.ts`. +-// +-// Add per-model modules here as they are ported over, e.g.: +-// +-// import './geminiPrompt.js'; +-// import './openaiPrompt.js'; - --class TestAgentPluginManager implements IAgentPluginManager { -- declare readonly _serviceBrand: undefined; +-import './anthropicPrompt.js'; +diff --git a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts b/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts +deleted file mode 100644 +index befa9348..00000000 +--- a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts ++++ /dev/null +@@ -1,80 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ - -- readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); +-import type { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; +-import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; +-import type { ModelSelection } from '../../../common/state/protocol/state.js'; +-import { agentHostPromptRegistry, type IAgentHostPrompt, type IAgentHostPromptContext } from './promptRegistry.js'; +-import { COPILOT_AGENT_HOST_IDENTITY } from './systemMessage.js'; - -- async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[], _progress?: (status: PluginCustomization) => void): Promise { -- return []; -- } +-/** +- * `customize`-mode section overrides for Claude Opus 4.8, tuned per Anthropic's +- * "Prompting Claude Opus 4.8" guide: +- * https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-4-8 +- * +- * Opus 4.8 performs well out of the box, so this stays intentionally minimal: +- * it keeps the SDK foundation prompt (and its tool/safety sections) intact and +- * only nudges the two behaviors the guide calls out for tuning — +- * - verbosity/tone: the model calibrates length to task complexity, so steer +- * it toward concision when a consistent style is wanted; and +- * - subagents: the model spawns fewer by default, so give explicit fan-out +- * guidance. +- * The guide warns against forcing interim-progress scaffolding ("summarize +- * after every N tool calls"), so none is added here. The identity is re-stated +- * to keep the agent-host self-description that the default message applies. +- */ +-function opus48SectionOverrides(): Partial> { +- return { +- identity: { +- action: 'replace', +- content: COPILOT_AGENT_HOST_IDENTITY, +- }, +- tone: { +- action: 'append', +- // Leading newline so the appended text starts on its own line rather +- // than running on from the SDK foundation tone section's last sentence. +- content: '\nProvide concise, focused responses. Skip non-essential context, and keep examples minimal. Use a direct style and use emojis sparingly.', +- }, +- guidelines: { +- action: 'append', +- content: [ +- 'Do not spawn a subagent for work you can complete directly in a single response (e.g. refactoring a function you can already see).', +- 'Spawn multiple subagents in the same turn when fanning out across items or reading multiple files.', +- ].join('\n'), +- }, +- }; -} - --class TestAgentHostGitService implements IAgentHostGitService { -- declare readonly _serviceBrand: undefined; +-/** +- * Returns whether `model` is a Claude Opus 4.8 model. Mirrors the Copilot +- * extension's version-specific `isOpus47`, matching both the SDK dashed id +- * (`claude-opus-4-8`) and the CAPI dotted id (`claude-opus-4.8`). +- */ +-function isOpus48(model: ModelSelection): boolean { +- return model.id.startsWith('claude-opus-4-8') || model.id.startsWith('claude-opus-4.8'); +-} - -- repositoryRoot: URI | undefined = undefined; -- addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; -- addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; -- removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; -- existingBranches = new Set(); -- dirtyWorkingDirectories = new Set(); +-/** +- * Resolves the Opus 4.8 agent prompt for Claude Opus 4.8 Copilot SDK sessions. +- * +- * Mirrors the Copilot extension's version-specific opus resolver +- * (`Claude47OpusPrompt`, gated by `isOpus47` + a setting): it matches only Opus +- * 4.8 via {@link isOpus48}, and is opt-in via +- * {@link AgentHostConfigKey.Opus48Prompt}. When the setting is off it returns +- * `undefined` and the registry falls back to the default system message. +- */ +-class Claude48OpusPromptResolver implements IAgentHostPrompt { +- static readonly familyPrefixes: readonly string[] = []; - -- async isInsideWorkTree(): Promise { return false; } -- async getCurrentBranch(): Promise { return undefined; } -- async getDefaultBranch(): Promise { return undefined; } -- async getBranches(): Promise { return []; } -- async getRepositoryRoot(): Promise { return this.repositoryRoot; } -- async getWorktreeRoots(): Promise { return []; } -- async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { -- this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); -- this.existingBranches.add(branchName); -- } -- async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { -- this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); -- } -- async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { -- this.removedWorktrees.push({ repositoryRoot, worktree }); -- } -- async branchExists(_repositoryRoot: URI, branchName: string): Promise { -- return this.existingBranches.has(branchName); +- static matchesModel(model: ModelSelection): boolean { +- return isOpus48(model); - } -- async hasUncommittedChanges(workingDirectory: URI): Promise { -- return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); +- +- resolveSectionOverrides(_model: ModelSelection, context: IAgentHostPromptContext): Partial> | undefined { +- return context.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? opus48SectionOverrides() : undefined; - } -- async commitAll(): Promise { } +-} +- +-agentHostPromptRegistry.registerPrompt(Claude48OpusPromptResolver); +diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +deleted file mode 100644 +index 793cf5e1..00000000 +--- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts ++++ /dev/null +@@ -1,148 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; +-import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCustomizationConfig.js'; +-import type { SchemaValue } from '../../../common/agentHostSchema.js'; +-import type { ModelSelection } from '../../../common/state/protocol/state.js'; +-import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +- +-type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; +- +-/** +- * Read-time context handed to prompt contributors so they can gate behavior on +- * host configuration — the agent-host equivalent of the Copilot extension +- * injecting `IConfigurationService` into a resolver. +- * +- * Scoped to the host customization schema so contributors (and tests) read +- * settings in a fully-typed way without depending on the whole configuration +- * service. +- */ +-export interface IAgentHostPromptContext { +- /** +- * Returns the host-level value for a customization setting, or `undefined` +- * when unset. Mirrors `IAgentConfigurationService.getRootValue` bound to +- * {@link agentHostCustomizationConfigSchema}. +- */ +- getSetting(key: K): SchemaValue | undefined; +-} +- +-/** +- * Per-model system-prompt contributor for Copilot CLI agent-host sessions. +- * +- * Mirrors the Copilot extension's `IAgentPrompt`, but — because the agent host +- * runs in its own process and cannot use prompt-tsx — a contributor returns +- * plain data the SDK accepts directly rather than prompt-tsx elements. +- * +- * A contributor may provide EITHER a full system-prompt override OR a set of +- * section overrides. When it provides a full prompt that wins (`replace` mode); +- * otherwise the section overrides are applied (`customize` mode). +- */ +-export interface IAgentHostPrompt { +- /** +- * Full system-prompt override. Resolved into `{ mode: 'replace' }`, which +- * drops the SDK foundation prompt and its guardrails. +- */ +- resolveFullSystemPrompt?(model: ModelSelection, context: IAgentHostPromptContext): string | undefined; +- +- /** +- * Section-level overrides. Resolved into `{ mode: 'customize' }`, keeping the +- * SDK foundation prompt and guardrails intact. +- */ +- resolveSectionOverrides?(model: ModelSelection, context: IAgentHostPromptContext): Partial> | undefined; +-} +- +-/** +- * Constructor/static shape for a registered prompt contributor. Mirrors the +- * Copilot extension's `IAgentPromptCtor`: a contributor matches a model either +- * by a custom {@link matchesModel} predicate or by a model-id family prefix. +- */ +-export interface IAgentHostPromptCtor { +- /** Model-id prefixes this contributor handles (e.g. `'claude'`, `'gpt-5'`). */ +- readonly familyPrefixes: readonly string[]; +- +- /** Optional custom matcher; takes precedence over {@link familyPrefixes}. */ +- matchesModel?(model: ModelSelection): boolean; +- +- new(): IAgentHostPrompt; +-} +- +-type PromptWithMatcher = IAgentHostPromptCtor & { matchesModel: (model: ModelSelection) => boolean }; +- +-/** +- * Registry of per-model system-prompt contributors for Copilot CLI agent-host +- * sessions. Mirrors the Copilot extension's `PromptRegistry`: contributors +- * register a model match (custom predicate or family prefix) and the session +- * launcher calls {@link resolveSystemMessageConfig} when building a session. +- * +- * Exported as a class for isolated unit testing; a shared singleton +- * ({@link agentHostPromptRegistry}) is what contributors register into and the +- * launcher consumes. +- */ +-export class AgentHostPromptRegistry { +- private readonly _promptsWithMatcher: PromptWithMatcher[] = []; +- private readonly _familyPrefixList: { readonly prefix: string; readonly ctor: IAgentHostPromptCtor }[] = []; +- +- registerPrompt(ctor: IAgentHostPromptCtor): void { +- if (ctor.matchesModel) { +- this._promptsWithMatcher.push(ctor as PromptWithMatcher); +- } +- for (const prefix of ctor.familyPrefixes) { +- this._familyPrefixList.push({ prefix, ctor }); +- } +- } +- +- private _getContributor(model: ModelSelection): IAgentHostPromptCtor | undefined { +- for (const ctor of this._promptsWithMatcher) { +- if (ctor.matchesModel(model)) { +- return ctor; +- } +- } +- for (const { prefix, ctor } of this._familyPrefixList) { +- if (model.id.startsWith(prefix)) { +- return ctor; +- } +- } +- return undefined; +- } +- +- /** +- * Resolves the {@link SystemMessageConfig} for a session's model. +- * +- * Falls back to {@link COPILOT_AGENT_HOST_SYSTEM_MESSAGE} when the model is +- * unknown (e.g. server-side "Auto" selection where no model is chosen at +- * create time), when no contributor matches, or when the matching +- * contributor opts out for the current {@link context} (e.g. a setting that +- * gates it is disabled). +- */ +- resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { +- if (!model) { +- return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; +- } +- const ctor = this._getContributor(model); +- if (!ctor) { +- return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; +- } +- const contributor = new ctor(); +- const fullPrompt = contributor.resolveFullSystemPrompt?.(model, context); +- if (fullPrompt !== undefined) { +- return fullSystemPrompt(fullPrompt); +- } +- const sections = contributor.resolveSectionOverrides?.(model, context); +- // An empty overrides object is treated as "no override" so we keep the +- // default identity customization rather than emitting a +- // `{ mode: 'customize', sections: {} }` that drops it. +- if (sections && Object.keys(sections).length > 0) { +- return sectionOverrides(sections); +- } +- return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; +- } +-} +- +-/** +- * Shared registry instance. Per-model contributors register here (see +- * `allPrompts.ts`) and the session launcher reads from it. +- */ +-export const agentHostPromptRegistry = new AgentHostPromptRegistry(); +diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +deleted file mode 100644 +index 1b52c8cc..00000000 +--- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts ++++ /dev/null +@@ -1,51 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; +- +-/** +- * Identity section content shared by the default agent-host system message and +- * any per-model override that wants to keep the same self-description. Kept as +- * a single constant so the identity text is defined in exactly one place. +- */ +-export const COPILOT_AGENT_HOST_IDENTITY = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.'; +- +-/** +- * Default system-message customization applied to every Copilot CLI agent-host +- * session that has no per-model override registered in the +- * {@link AgentHostPromptRegistry}. +- * +- * Uses `customize` mode so the CLI/SDK foundation prompt (and its built-in +- * guardrails) stay intact — only the `identity` section is replaced. +- */ +-export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { +- mode: 'customize', +- sections: { +- identity: { +- action: 'replace', +- content: COPILOT_AGENT_HOST_IDENTITY, +- }, +- }, +-} satisfies SystemMessageConfig; +- +-/** +- * Builds a {@link SystemMessageConfig} that fully replaces the CLI/SDK system +- * prompt with `content`. +- * +- * ⚠️ `replace` mode drops ALL SDK guardrails (including security restrictions); +- * prefer {@link sectionOverrides} unless the caller intends to own the entire +- * prompt. +- */ +-export function fullSystemPrompt(content: string): SystemMessageConfig { +- return { mode: 'replace', content }; +-} +- +-/** +- * Builds a `customize`-mode {@link SystemMessageConfig} that overrides only the +- * given sections, leaving the rest of the CLI/SDK foundation prompt intact. +- */ +-export function sectionOverrides(sections: Partial>): SystemMessageConfig { +- return { mode: 'customize', sections }; +-} +diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +deleted file mode 100644 +index 5d3d6ce3..00000000 +--- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts ++++ /dev/null +@@ -1,133 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; +-import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; +- +-/** +- * Per-platform filesystem rule bundle accepted under each `fileSystem.` +- * sub-key (`AgentHostSandboxKey.LinuxFileSystem` etc.) in the AgentHost root +- * sandbox config bag. Mirrors the workbench's `chat.agent.sandbox.fileSystem.*` +- * shape so the workbench-side forwarder can copy values verbatim. +- */ +-export interface IAgentSandboxFileSystemSetting { +- allowRead?: string[]; +- allowWrite?: string[]; +- denyRead?: string[]; +- denyWrite?: string[]; +-} +- +-/** +- * SDK-side sandbox configuration produced by {@link buildSandboxConfigForSdk}. +- * +- * Structurally a narrowed form of the SDK's `SandboxConfig` type (from +- * `@github/copilot-sdk`'s `SessionUpdateOptionsParams.sandboxConfig`) — the +- * same shape the Copilot extension produces via its own `buildSandboxConfigForCLI`. +- * Defined locally because `SandboxConfig` is not re-exported from the SDK's +- * public entry point; this shape stays assignable to it. +- */ +-export interface ISdkSandboxConfig { +- enabled: true; +- userPolicy: { +- filesystem: { +- readwritePaths?: string[]; +- readonlyPaths?: string[]; +- deniedPaths?: string[]; +- }; +- network: { +- allowOutbound: boolean; +- allowedHosts?: string[]; +- blockedHosts?: string[]; +- }; +- }; +-} +- +-/** +- * Translate the AgentHost's host-side sandbox configuration into the +- * opaque `sandboxConfig` shape the Copilot SDK forwards to the runtime +- * via `session.options.update`. +- * +- * Used when {@link AgentHostConfigKey.EnableCustomTerminalTool} is OFF — the +- * SDK's built-in shell tool runs the user's commands, so we have to push the +- * sandbox policy down into the SDK itself. When the custom terminal tool is +- * ON, the AgentHost's own {@link TerminalSandboxEngine} wraps commands and +- * this function is not consulted. +- * +- * Mirrors `buildSandboxConfigForCLI` in +- * `extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts` +- * so the two surfaces behave the same: +- * - Path precedence: `denyRead` > `denyWrite` > `allowWrite` > `allowRead`. +- * Each path appears in exactly one of `deniedPaths` / `readonlyPaths` / +- * `readwritePaths`. +- * - Network: `allowNetwork` opens outbound to everything and drops the +- * allow/deny lists. Otherwise the allow/deny lists open outbound when +- * set so they're actually enforced; macOS fails closed because the +- * runtime has no per-host filter (Seatbelt would silently degrade to +- * "allow all outbound"). +- */ +-export function buildSandboxConfigForSdk( +- platform: NodeJS.Platform, +- sandbox: ISandboxConfigValue | undefined, +-): ISdkSandboxConfig | undefined { +- if (!sandbox) { +- return undefined; +- } +- +- const enabledRaw = platform === 'win32' && sandbox[AgentHostSandboxKey.WindowsEnabled] !== undefined +- ? sandbox[AgentHostSandboxKey.WindowsEnabled] +- : sandbox[AgentHostSandboxKey.Enabled]; +- if (enabledRaw !== AgentSandboxEnabledValue.On && enabledRaw !== AgentSandboxEnabledValue.AllowNetwork) { +- return undefined; +- } +- +- const fsRaw = platform === 'win32' +- ? sandbox[AgentHostSandboxKey.WindowsFileSystem] +- : platform === 'darwin' +- ? sandbox[AgentHostSandboxKey.MacFileSystem] +- : sandbox[AgentHostSandboxKey.LinuxFileSystem]; +- const fs = (fsRaw && typeof fsRaw === 'object') ? fsRaw as IAgentSandboxFileSystemSetting : {}; +- +- const denied = new Set(fs.denyRead ?? []); +- const readonly = new Set(); +- const readwrite = new Set(); +- for (const p of fs.denyWrite ?? []) { +- if (!denied.has(p)) { +- readonly.add(p); +- } +- } +- for (const p of fs.allowWrite ?? []) { +- if (!denied.has(p) && !readonly.has(p)) { +- readwrite.add(p); +- } +- } +- for (const p of fs.allowRead ?? []) { +- if (!denied.has(p) && !readonly.has(p) && !readwrite.has(p)) { +- readonly.add(p); +- } +- } +- +- const allowAllNetwork = enabledRaw === AgentSandboxEnabledValue.AllowNetwork; +- const hostListsEnforceable = platform !== 'darwin'; +- const rawAllow = sandbox[AgentHostSandboxKey.AllowedNetworkDomains]; +- const rawBlock = sandbox[AgentHostSandboxKey.DeniedNetworkDomains]; +- const allowedHosts = !allowAllNetwork && hostListsEnforceable && rawAllow?.length ? [...rawAllow] : undefined; +- const blockedHosts = !allowAllNetwork && hostListsEnforceable && rawBlock?.length ? [...rawBlock] : undefined; +- const allowOutbound = allowAllNetwork || !!allowedHosts || !!blockedHosts; +- return { +- enabled: true, +- userPolicy: { +- filesystem: { +- ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), +- ...(readonly.size ? { readonlyPaths: [...readonly] } : {}), +- ...(denied.size ? { deniedPaths: [...denied] } : {}), +- }, +- network: { +- allowOutbound, +- ...(allowOutbound && allowedHosts ? { allowedHosts } : {}), +- ...(allowOutbound && blockedHosts ? { blockedHosts } : {}), +- }, +- }, +- }; +-} +diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +index 9331c71b..e48523e3 100644 +--- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts ++++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +@@ -3,7 +3,19 @@ + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +-import type { CopilotClient } from '@github/copilot-sdk'; ++// `CopilotClient` came from '@github/copilot-sdk' (removed in !!APP_NAME!!). ++// `discover()` only touches the RPC discovery endpoints and is no longer reached ++// by a live Copilot client; model the minimal surface it uses locally. ++type CopilotClient = { ++ rpc: { ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ agents: { discover(params: unknown): Promise }; ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ instructions: { discover(params: unknown): Promise }; ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ skills: { discover(params: unknown): Promise }; ++ }; ++}; + import { CancellationToken } from '../../../../base/common/cancellation.js'; + import { CancellationError } from '../../../../base/common/errors.js'; + import { Emitter, Event } from '../../../../base/common/event.js'; +diff --git a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts +index 61cf2cf7..655f5b97 100644 +--- a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts ++++ b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts +@@ -5,7 +5,6 @@ + + import { mkdir } from 'fs/promises'; + import { dirname, join } from '../../../../base/common/path.js'; +-import type { TelemetryConfig } from '@github/copilot-sdk'; + import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; + import { URI } from '../../../../base/common/uri.js'; + import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +@@ -20,7 +19,7 @@ import { + } from '../../../otel/node/otlp/outboundForwarder.js'; + import { OTelSqliteStore } from '../../../otel/node/sqlite/otelSqliteStore.js'; + import { AgentHostOTelSpansDbSubPath } from '../../common/agentService.js'; +-import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; ++import { IAgentHostOTelService, type TelemetryConfig } from '../../common/otel/agentHostOTelService.js'; + + /** Sub-path under the user data directory where the span DB lives. */ + const SPANS_DB_SUBPATH = AgentHostOTelSpansDbSubPath; +diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +deleted file mode 100644 +index c2421fb6..00000000 +--- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts ++++ /dev/null +@@ -1,125 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import assert from 'assert'; +-import type { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; +-import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +-import type { SchemaValues } from '../../common/agentHostSchema.js'; +-import type { ModelSelection } from '../../common/state/protocol/state.js'; +-import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; +-import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import '../../node/copilot/prompts/allPrompts.js'; +- +-/** Builds a prompt context backed by an in-memory bag of customization settings. */ +-function context(settings: SchemaValues = {}): IAgentHostPromptContext { +- return { getSetting: key => settings[key] }; +-} +- +-suite('AgentHostPromptRegistry', () => { +- +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('falls back to the default system message when no model is provided', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- }); +- +- test('falls back to the default when no contributor matches the model', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- }); +- +- test('a contributor can fully replace the system prompt (replace mode)', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['gpt-5']; +- resolveFullSystemPrompt(): string { +- return 'FULL PROMPT'; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context()), +- { mode: 'replace', content: 'FULL PROMPT' } +- ); +- }); +- +- test('a contributor can override individual sections (customize mode)', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return { guidelines: { action: 'append', content: 'Be concise.' } }; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), +- { mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } } +- ); +- }); +- +- test('treats empty section overrides as no override (falls back to default)', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return {}; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), +- COPILOT_AGENT_HOST_SYSTEM_MESSAGE +- ); +- }); +- +- test('matchesModel takes precedence over family prefixes', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes: readonly string[] = []; +- static matchesModel(model: ModelSelection): boolean { +- return model.id.includes('codex'); +- } +- resolveFullSystemPrompt(): string { +- return 'CODEX'; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'gpt-5-codex' }, context()), +- { mode: 'replace', content: 'CODEX' } +- ); +- }); +- +- test('contributors gate on the prompt context', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(_model: ModelSelection, ctx: IAgentHostPromptContext): Partial> | undefined { +- return ctx.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? { tone: { action: 'append', content: 'GATED' } } : undefined; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [AgentHostConfigKey.Opus48Prompt]: true })), +- { mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } } +- ); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context()), +- COPILOT_AGENT_HOST_SYSTEM_MESSAGE +- ); +- }); +- +- suite('Opus contributor (registered via allPrompts)', () => { +- const opusModel: ModelSelection = { id: 'claude-opus-4-8' }; +- +- function resolveOpus(enabled: boolean | undefined) { +- return agentHostPromptRegistry.resolveSystemMessageConfig(opusModel, context(enabled === undefined ? {} : { [AgentHostConfigKey.Opus48Prompt]: enabled })); +- } +- +- test('applies customize overrides only when enabled', () => { +- assert.strictEqual(resolveOpus(undefined), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.strictEqual(resolveOpus(false), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.strictEqual(resolveOpus(true).mode, 'customize'); +- }); +- }); +-}); +diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +deleted file mode 100644 +index bd411161..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts ++++ /dev/null +@@ -1,2591 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import type { CopilotClient, CopilotSession, ModelInfo, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +-import type Anthropic from '@anthropic-ai/sdk'; +-import type { CCAModel } from '@vscode/copilot-api'; +-import assert from 'assert'; +-import * as fs from 'fs/promises'; +-import * as os from 'os'; +-import { VSBuffer } from '../../../../base/common/buffer.js'; +-import { DeferredPromise } from '../../../../base/common/async.js'; +-import { isCancellationError } from '../../../../base/common/errors.js'; +-import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; +-import { Event } from '../../../../base/common/event.js'; +-import { Schemas } from '../../../../base/common/network.js'; +-import { waitForState } from '../../../../base/common/observable.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +-import { FileService } from '../../../files/common/fileService.js'; +-import { IFileService } from '../../../files/common/files.js'; +-import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +-import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +-import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +-import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +-import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +-import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +-import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +-import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +-import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; +-import { ISessionDataService } from '../../common/sessionDataService.js'; +-import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +-import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; +-import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; +- +-import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +-import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +-import { IAgentHostGitService } from '../../node/agentHostGitService.js'; +-import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +-import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; +-import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; +-import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +-import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +-import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +-import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; +-import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +-import { ShellManager } from '../../node/copilot/copilotShellTools.js'; +-import { SessionDatabase } from '../../node/sessionDatabase.js'; +-import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +-import { ActiveClientState } from '../../node/activeClientState.js'; +-import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +- +-class TestAgentPluginManager implements IAgentPluginManager { +- declare readonly _serviceBrand: undefined; +- +- readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); +- +- async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[], _progress?: (status: PluginCustomization) => void): Promise { +- return []; +- } +-} +- +-class TestAgentHostGitService implements IAgentHostGitService { +- declare readonly _serviceBrand: undefined; +- +- repositoryRoot: URI | undefined = undefined; +- addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; +- addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; +- removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; +- existingBranches = new Set(); +- dirtyWorkingDirectories = new Set(); +- +- async isInsideWorkTree(): Promise { return false; } +- async getCurrentBranch(): Promise { return undefined; } +- async getDefaultBranch(): Promise { return undefined; } +- async getBranches(): Promise { return []; } +- async getRepositoryRoot(): Promise { return this.repositoryRoot; } +- async getWorktreeRoots(): Promise { return []; } +- async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { +- this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); +- this.existingBranches.add(branchName); +- } +- async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { +- this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); +- } +- async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { +- this.removedWorktrees.push({ repositoryRoot, worktree }); +- } +- async branchExists(_repositoryRoot: URI, branchName: string): Promise { +- return this.existingBranches.has(branchName); +- } +- async hasUncommittedChanges(workingDirectory: URI): Promise { +- return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); +- } +- async commitAll(): Promise { } +- async restore(): Promise { } - async hasUpstream(): Promise { return false; } - async pushBranch(): Promise { } - async getSessionGitState(): Promise { return undefined; } @@ -8017,6 +10223,30 @@ index 6bb478dbb36..00000000000 - async getDefaultShell(): Promise { return '/bin/bash'; } -} - +-class TestCopilotApiService implements ICopilotApiService { +- declare readonly _serviceBrand: undefined; +- +- readonly utilityCalls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = []; +- response = 'generated-branch-name'; +- error: Error | undefined; +- +- messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; +- messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; +- messages(): AsyncGenerator | Promise { +- throw new Error('not used'); +- } +- async countTokens(): Promise { throw new Error('not used'); } +- async models(): Promise { return []; } +- async responses(): Promise { throw new Error('not used'); } +- async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { +- this.utilityCalls.push({ token: githubToken, request, options }); +- if (this.error) { +- throw this.error; +- } +- return this.response; +- } +-} +- -class TestSessionDataService extends Disposable implements ISessionDataService { - declare readonly _serviceBrand: undefined; - @@ -8047,6 +10277,7 @@ index 6bb478dbb36..00000000000 - cleanupOrphanedData(): Promise { return Promise.resolve(); } - whenIdle(): Promise { return Promise.resolve(); } -} +-type CopilotModelsList = CopilotClient['rpc']['models']['list']; - -interface ITestCopilotModelInfo { - readonly id: string; @@ -8056,13 +10287,27 @@ index 6bb478dbb36..00000000000 - readonly limits?: { readonly max_context_window_tokens?: number }; - }; - readonly policy?: { readonly state?: NonNullable['state'] }; -- readonly billing?: ModelInfo['billing']; +- readonly billing?: ModelInfo['billing'] & { +- readonly priceCategory?: string; +- readonly tokenPrices?: { +- readonly contextMax?: number; +- readonly inputPrice?: number; +- readonly cachePrice?: number; +- readonly cacheWritePrice?: number; +- readonly outputPrice?: number; +- readonly longContext?: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number }; +- }; +- }; +- readonly modelPickerPriceCategory?: string; - readonly supportedReasoningEfforts?: ModelInfo['supportedReasoningEfforts']; - readonly defaultReasoningEffort?: ModelInfo['defaultReasoningEffort']; -} - --interface ITestCopilotClient extends Pick { -- readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; +-interface ITestCopilotClient extends Pick { +- readonly rpc: { +- readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; +- readonly models: { readonly list: CopilotModelsList }; +- }; -} - -function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { @@ -8080,23 +10325,46 @@ index 6bb478dbb36..00000000000 - }, - ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), - ...(model.billing ? { billing: model.billing } : {}), +- ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), - ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), - ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), - }; -} - -class TestCopilotClient implements ITestCopilotClient { -- readonly rpc: ITestCopilotClient['rpc'] = { sessions: { fork: async () => ({ sessionId: 'forked-session' }) } }; +- readonly rpc: ITestCopilotClient['rpc'] = { +- sessions: { fork: async () => ({ sessionId: 'forked-session' }) }, +- models: { +- list: async params => { +- this.modelListRequests.push(params); +- const error = this.modelListErrors.shift(); +- if (error) { +- throw error; +- } +- return { models: this._models.map(toSdkModelInfo) }; +- } +- }, +- }; +- startCallCount = 0; +- stopCallCount = 0; - listSessionCallCount = 0; +- readonly modelListRequests: Parameters[0][] = []; +- readonly modelListErrors: Error[] = []; - readonly getSessionMetadataCalls: string[] = []; +- readonly deletedSessionIds: string[] = []; - - constructor( - private readonly _sessions: Awaited>, - private readonly _models: readonly ITestCopilotModelInfo[] = [], - ) { } - -- async start(): Promise { } -- async stop(): ReturnType { return []; } +- async start(): Promise { +- this.startCallCount++; +- } +- async stop(): ReturnType { +- this.stopCallCount++; +- return []; +- } - async listSessions(): ReturnType { - this.listSessionCallCount++; - return this._sessions; @@ -8106,6 +10374,9 @@ index 6bb478dbb36..00000000000 - this.getSessionMetadataCalls.push(sessionId); - return this._sessions.find(s => s.sessionId === sessionId); - } +- async deleteSession(sessionId: string): Promise { +- this.deletedSessionIds.push(sessionId); +- } - createSession: ITestCopilotClient['createSession'] = async () => { throw new Error('not implemented'); }; - resumeSession: ITestCopilotClient['resumeSession'] = async () => { throw new Error('not implemented'); }; -} @@ -8158,9 +10429,10 @@ index 6bb478dbb36..00000000000 - @ISessionDataService sessionDataService: ISessionDataService, - @IAgentHostGitService gitService: IAgentHostGitService, - @IAgentConfigurationService configurationService: IAgentConfigurationService, +- @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, - @IAgentHostCompletions completions: IAgentHostCompletions, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), completions, NULL_CHECKPOINT_SERVICE); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); - this._enablePlanModeOnClient(this._copilotClient as CopilotClient); - } - @@ -8180,9 +10452,10 @@ index 6bb478dbb36..00000000000 - @ISessionDataService sessionDataService: ISessionDataService, - @IAgentHostGitService gitService: IAgentHostGitService, - @IAgentConfigurationService configurationService: IAgentConfigurationService, +- @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, - @IAgentHostCompletions completions: IAgentHostCompletions, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), completions, NULL_CHECKPOINT_SERVICE); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); - this._enablePlanModeOnClient(this._copilotClient as CopilotClient); - } - @@ -8217,7 +10490,7 @@ index 6bb478dbb36..00000000000 - kind: 'action', - session: sessionUri, - action: { -- type: ActionType.SessionResponsePart, +- type: ActionType.ChatResponsePart, - turnId, - part: { kind: ResponsePartKind.Markdown, id: `synth-${Date.now()}`, content }, - }, @@ -8232,7 +10505,7 @@ index 6bb478dbb36..00000000000 - } -} - --function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { +-function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { - const services = new ServiceCollection(); - const logService = new NullLogService(); - const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -8252,6 +10525,9 @@ index 6bb478dbb36..00000000000 - flush: async () => undefined, - }); - services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); +- const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); +- services.set(ICopilotApiService, copilotApiService); +- services.set(ICopilotBranchNameGenerator, new CopilotBranchNameGenerator(copilotApiService, logService)); - services.set(ITelemetryService, NullTelemetryService); - if (options?.environmentServiceRegistration !== 'none') { - const environmentService = { @@ -8268,7 +10544,7 @@ index 6bb478dbb36..00000000000 - return { agent, instantiationService, configurationService: configService, fileService }; -} - --function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager }): CopilotAgent { +-function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; copilotApiService?: ICopilotApiService }): CopilotAgent { - return createTestAgentContext(disposables, options).agent; -} - @@ -8287,10 +10563,11 @@ index 6bb478dbb36..00000000000 - }, - resumeSession: async () => new MockCopilotSession() as unknown as CopilotSession, - }, +- activeClientState: new ActiveClientState(), - sessionId: 'test-session-1', - workingDirectory: undefined, - resolvedAgentName: undefined, -- snapshot: { clientId: '', tools: [], plugins: [] }, +- snapshot: { tools: [], plugins: [], mcpServers: {} }, - shellManager, - githubToken: 'token', - model: undefined, @@ -8334,6 +10611,19 @@ index 6bb478dbb36..00000000000 -suite('CopilotAgent', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - +- test('advertises Copilot as its display name', async () => { +- const agent = createTestAgent(disposables); +- try { +- assert.deepStrictEqual(agent.getDescriptor(), { +- provider: 'copilotcli', +- displayName: 'Copilot', +- description: 'Copilot SDK agent running in a dedicated process', +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('uses the Copilot CLI sibling worktrees root convention', () => { - assert.strictEqual( - getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, @@ -8341,54 +10631,228 @@ index 6bb478dbb36..00000000000 - ); - }); - -- test('uses Agents-window Copilot CLI branch prefix', () => { -- assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'add-agent-host-config'), 'agents/add-agent-host-config-12345678'); -- assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', undefined), 'agents/12345678-aaaa-bbbb-cccc-123456789abc'); +- test('uses generated Agents-window Copilot CLI branch names', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = 'add-agent-host-config'; +- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.deepStrictEqual({ +- generated: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), +- fallback: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config' }), +- token: copilotApiService.utilityCalls[0]?.token, +- promptIncludesUserText: copilotApiService.utilityCalls[0]?.request.messages.some(message => message.content.includes('Add agent host config')), +- }, { +- generated: 'agents/add-agent-host-config-12345678', +- fallback: 'agents/add-agent-host-config-12345678', +- token: 'token', +- promptIncludesUserText: true, +- }); - }); - - test('uses Git extension branch-derived worktree folder names', () => { - assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'agents-add-agent-host-config-12345678'); - }); - -- test('keeps hinted branch names short', () => { -- assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'a'.repeat(48)).length, 'agents/'.length + 48 + '-12345678'.length); +- test('keeps generated branch names short', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = 'a'.repeat(100); +- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.strictEqual( +- (await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' })).length, +- 'agents/'.length + 48 + '-12345678'.length, +- ); +- }); +- +- test('normalizes generated branch names', () => { +- assert.deepStrictEqual({ +- simple: normalizeCopilotBranchName('feature-branch'), +- uppercase: normalizeCopilotBranchName('Feature-Branch'), +- special: normalizeCopilotBranchName('Fix: Add new feature! (#42)'), +- unicode: normalizeCopilotBranchName('café-feature'), +- empty: normalizeCopilotBranchName('🚀🎉'), +- }, { +- simple: 'feature-branch', +- uppercase: 'feature-branch', +- special: 'fixaddnewfeature42', +- unicode: 'caf-feature', +- empty: '', +- }); - }); - -- test('derives slug branch hint from first message', () => { -- assert.strictEqual(getCopilotBranchNameHintFromMessage('Add agent host config'), 'add-agent-host-config'); -- assert.strictEqual(getCopilotBranchNameHintFromMessage(' Fix: the bug!! '), 'fix-the-bug'); -- assert.strictEqual(getCopilotBranchNameHintFromMessage('Refactor café ☕ rendering'), 'refactor-cafe-rendering'); -- assert.strictEqual(getCopilotBranchNameHintFromMessage('one two three four five six seven eight nine ten'), 'one-two-three-four-five-six-seven-eight'); -- assert.strictEqual(getCopilotBranchNameHintFromMessage('a'.repeat(100))?.length, 48); -- assert.strictEqual(getCopilotBranchNameHintFromMessage('!!! ??? ...'), undefined); -- assert.strictEqual(getCopilotBranchNameHintFromMessage(''), undefined); +- test('derives slug branch hint from first message for fallback', () => { +- assert.deepStrictEqual({ +- simple: getCopilotBranchNameHintFromMessage('Add agent host config'), +- punctuation: getCopilotBranchNameHintFromMessage(' Fix: the bug!! '), +- unicode: getCopilotBranchNameHintFromMessage('Refactor café ☕ rendering'), +- words: getCopilotBranchNameHintFromMessage('one two three four five six seven eight nine ten'), +- long: getCopilotBranchNameHintFromMessage('a'.repeat(100))?.length, +- empty: getCopilotBranchNameHintFromMessage('!!! ??? ...'), +- }, { +- simple: 'add-agent-host-config', +- punctuation: 'fix-the-bug', +- unicode: 'refactor-cafe-rendering', +- words: 'one-two-three-four-five-six-seven-eight', +- long: 48, +- empty: undefined, +- }); - }); - -- test('returns empty models and throws AuthRequired for sessions before authentication', async () => { -- const agent = createTestAgent(disposables); +- test('falls back to first-message slug when generated branch name cannot be used', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = '!!! ??? ...'; +- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.strictEqual( +- await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), +- 'agents/add-agent-host-config-12345678', +- ); +- }); +- +- test('falls back to first-message slug when branch name generation fails', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.error = new Error('failed'); +- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.strictEqual( +- await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), +- 'agents/add-agent-host-config-12345678', +- ); +- }); +- +- test('falls back to session id when no branch name can be derived', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = '!!! ??? ...'; +- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.strictEqual( +- await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: '!!! ??? ...', githubToken: 'token' }), +- 'agents/12345678-aaaa-bbbb-cccc-123456789abc', +- ); +- }); +- +- test('returns empty models and lists sessions before authentication', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const ownedSession = AgentSession.uri('copilotcli', 'owned-before-auth'); +- const ownedDb = sessionDataService.openDatabase(ownedSession); +- ownedDb.dispose(); +- const client = new TestCopilotClient([sdkSession('owned-before-auth')]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { -- assert.deepStrictEqual(agent.models.get(), []); -- await assert.rejects( -- () => agent.listSessions(), -- (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, -- ); +- const sessions = await agent.listSessions(); +- assert.deepStrictEqual({ +- models: agent.models.get(), +- sessions: sessions.map(session => AgentSession.id(session.session)), +- starts: client.startCallCount, +- listCalls: client.listSessionCallCount, +- }, { +- models: [], +- sessions: ['owned-before-auth'], +- starts: 1, +- listCalls: 1, +- }); - } finally { - await disposeAgent(agent); - } - }); - -- test('requires authentication before creating a session', async () => { -- const agent = createTestAgent(disposables); +- test('starts the client and creates a provisional session before authentication', async () => { +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const session = AgentSession.uri('copilotcli', 'unauth-create'); +- const workingDirectory = URI.file('/workspace'); - try { -- await assert.rejects( -- () => agent.createSession({ workingDirectory: URI.file('/workspace') }), -- (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, -- ); +- const result = await agent.createSession({ session, workingDirectory }); +- assert.ok(result.workingDirectory); +- assert.deepStrictEqual({ +- session: result.session.toString(), +- workingDirectory: result.workingDirectory.toString(), +- provisional: result.provisional, +- starts: client.startCallCount, +- stops: client.stopCallCount, +- }, { +- session: session.toString(), +- workingDirectory: workingDirectory.toString(), +- provisional: true, +- starts: 1, +- stops: 0, +- }); - } finally { - await disposeAgent(agent); - } - }); - +- test('passes the GitHub token when refreshing models', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- }]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'model-token'); +- await waitForState(agent.models, models => models.length > 0); +- +- assert.deepStrictEqual(client.modelListRequests, [{ gitHubToken: 'model-token' }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('does not stop the client when the auth token changes', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- }]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- try { +- await agent.listSessions(); +- await agent.authenticate('https://api.github.com', 'model-token-a'); +- for (let i = 0; i < 200 && client.modelListRequests.length < 1; i++) { +- await new Promise(resolve => setTimeout(resolve, 0)); +- } +- await agent.authenticate('https://api.github.com', 'model-token-b'); +- for (let i = 0; i < 200 && client.modelListRequests.length < 2; i++) { +- await new Promise(resolve => setTimeout(resolve, 0)); +- } +- +- assert.deepStrictEqual({ +- starts: client.startCallCount, +- stops: client.stopCallCount, +- requests: client.modelListRequests, +- }, { +- starts: 1, +- stops: 0, +- requests: [{ gitHubToken: 'model-token-a' }, { gitHubToken: 'model-token-b' }], +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('createSession falls back to an empty temp directory when workingDirectory is omitted', async () => { +- const agent = createTestAgent(disposables); +- let createdWorkingDirectory: URI | undefined; +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const result = await agent.createSession({ +- session: AgentSession.uri('copilotcli', 'temp-fallback'), +- }); +- +- assert.strictEqual(result.provisional, true); +- assert.ok(result.workingDirectory); +- createdWorkingDirectory = result.workingDirectory; +- assert.strictEqual(createdWorkingDirectory.scheme, Schemas.file); +- assert.strictEqual(createdWorkingDirectory.fsPath.toLowerCase().startsWith(os.tmpdir().toLowerCase()), true); +- assert.deepStrictEqual(await fs.readdir(createdWorkingDirectory.fsPath), []); +- } finally { +- if (createdWorkingDirectory) { +- await fs.rm(createdWorkingDirectory.fsPath, { recursive: true, force: true }); +- } +- await disposeAgent(agent); +- } +- }).timeout(30_000); +- - suite('restart on startup config change', () => { - - class StopCountingClient extends TestCopilotClient { @@ -8482,6 +10946,124 @@ index 6bb478dbb36..00000000000 - } - }); - +- test('models include token-price and price-category metadata when billing provides it', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'claude-sonnet', +- name: 'Claude Sonnet', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- inputPrice: 3, +- cachePrice: 1, +- outputPrice: 15, +- longContext: { contextMax: 1_000_000, inputPrice: 6, cachePrice: 1, outputPrice: 22.5 }, +- }, +- }, +- modelPickerPriceCategory: 'medium', +- }]), +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); +- +- assert.deepStrictEqual(models[0]._meta, { +- multiplierNumeric: 1, +- inputCost: 3, +- cacheCost: 1, +- cacheWriteCost: 1, +- outputCost: 15, +- longContextInputCost: 6, +- longContextOutputCost: 22.5, +- priceCategory: 'medium', +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('configSchema emits a thinkingLevel property when the model advertises reasoning efforts', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'o3', +- name: 'o3', +- capabilities: { limits: { max_context_window_tokens: 128000 } }, +- supportedReasoningEfforts: ['low', 'medium', 'high'], +- defaultReasoningEffort: 'medium', +- }]), +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); +- +- const schema = models[0].configSchema; +- assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); +- assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); +- assert.strictEqual(schema?.properties.contextTier, undefined); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('configSchema emits a contextTier property when long_context tier exceeds default', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'claude-sonnet', +- name: 'Claude Sonnet', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000, inputPrice: 2 }, +- }, +- }, +- }]), +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); +- +- const contextTier = models[0].configSchema?.properties.contextTier; +- assert.deepStrictEqual(contextTier?.enum, ['default', 'long_context']); +- assert.strictEqual(contextTier?.default, 'default'); +- assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [ +- { +- id: 'no-long-context', +- name: 'No Long Context', +- billing: { multiplier: 1, tokenPrices: { contextMax: 200_000 } }, +- }, +- { +- id: 'equal-long-context', +- name: 'Equal Long Context', +- billing: { +- multiplier: 1, +- tokenPrices: { contextMax: 200_000, longContext: { contextMax: 200_000 } }, +- }, +- }, +- ]), +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); +- +- assert.strictEqual(models[0].configSchema, undefined); +- assert.strictEqual(models[1].configSchema, undefined); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const { agent, instantiationService } = createTestAgentContext(disposables, { @@ -8702,7 +11284,7 @@ index 6bb478dbb36..00000000000 - const pluginManager = new PluginDirSpyManager(); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, pluginManager, fileService }); - -- const actions: SessionAction[] = []; +- const actions: (SessionAction | ChatAction)[] = []; - disposables.add(agent.onDidSessionProgress(s => { - if (s.kind === 'action') { - actions.push(s.action); @@ -8741,13 +11323,30 @@ index 6bb478dbb36..00000000000 - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - +- const agentContent = [ +- '---', +- 'name: helper', +- 'description: helps out', +- '---', +- 'agent body', +- ]; +- const instructionContent = [ +- '---', +- 'name: nested', +- 'description: nested instructions', +- 'applyTo: *.ts, *.js', +- '---', +- 'instruction body', +- ]; +- +- - const workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); - await fileService.createFolder(URI.joinPath(workspace, '.github', 'agents')); - await fileService.createFolder(URI.joinPath(workspace, '.github', 'instructions', 'team')); - const agentFile = URI.joinPath(workspace, '.github', 'agents', 'helper.agent.md'); - const instructionFile = URI.joinPath(workspace, '.github', 'instructions', 'team', 'nested.instructions.md'); -- await fileService.writeFile(agentFile, VSBuffer.fromString('agent body')); -- await fileService.writeFile(instructionFile, VSBuffer.fromString('instruction body')); +- await fileService.writeFile(agentFile, VSBuffer.fromString(agentContent.join('\n'))); +- await fileService.writeFile(instructionFile, VSBuffer.fromString(instructionContent.join('\n'))); - const agentsMdFile = URI.joinPath(workspace, 'AGENTS.md'); - await fileService.writeFile(agentsMdFile, VSBuffer.fromString('agents md body')); - @@ -8782,6 +11381,7 @@ index 6bb478dbb36..00000000000 - id: customizationId(agentFile.toString()), - uri: agentFile.toString(), - name: 'helper', +- description: 'helps out', - }]); - - const instructionDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'instructions').toString()); @@ -8791,7 +11391,10 @@ index 6bb478dbb36..00000000000 - type: CustomizationType.Rule, - id: customizationId(instructionFile.toString()), - uri: instructionFile.toString(), -- name: 'nested.instructions.md', +- name: 'nested', +- description: 'nested instructions', +- globs: ['*.ts', '*.js'], +- alwaysApply: undefined, - }]); - - const agentInstructionsDirectory = discoveredDirectories.find(customization => customization.uri === workspace.toString()); @@ -8846,6 +11449,142 @@ index 6bb478dbb36..00000000000 - await disposeAgent(agent); - } - }); +- +- test('getSessionCustomizations does not republish discovered directories when watcher changes are discovery-neutral', async () => { +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- +- const workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); +- const agentsRoot = URI.joinPath(workspace, '.github', 'agents'); +- await fileService.createFolder(agentsRoot); +- await fileService.writeFile(URI.joinPath(agentsRoot, 'helper.agent.md'), VSBuffer.fromString('agent body')); +- +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); +- +- const actions: (SessionAction | ChatAction)[] = []; +- disposables.add(agent.onDidSessionProgress(progress => { +- if (progress.kind === 'action') { +- actions.push(progress.action); +- } +- })); +- +- const countDirectoryPublishesForAgentsRoot = (): number => actions.filter(action => { +- if (action.type === ActionType.SessionCustomizationUpdated) { +- const customization = (action as Extract).customization; +- return customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString(); +- } +- if (action.type === ActionType.SessionCustomizationsChanged) { +- const customizations = (action as Extract).customizations; +- return customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString()); +- } +- return false; +- }).length; +- +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'session-discovery-neutral-watcher-change'); +- await agent.createSession({ +- session, +- workingDirectory: workspace, +- }); +- +- await agent.getSessionCustomizations(session); +- await new Promise(resolve => setTimeout(resolve, 50)); +- const publishCountBefore = countDirectoryPublishesForAgentsRoot(); +- +- // README.md is intentionally excluded from discovered agents. +- await fileService.writeFile(URI.joinPath(agentsRoot, 'README.md'), VSBuffer.fromString('ignored')); +- +- for (let i = 0; i < 20; i++) { +- await new Promise(resolve => setTimeout(resolve, 50)); +- assert.strictEqual(countDirectoryPublishesForAgentsRoot(), publishCountBefore, 'expected no republish when discovery output is unchanged'); +- } +- +- const after = await agent.getSessionCustomizations(session); +- assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('getSessionCustomizations coalesces burst watcher changes into one discovered refresh publish', async () => { +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- +- const workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); +- const agentsRoot = URI.joinPath(workspace, '.github', 'agents'); +- const instructionsRoot = URI.joinPath(workspace, '.github', 'instructions'); +- await fileService.createFolder(agentsRoot); +- await fileService.createFolder(instructionsRoot); +- await fileService.writeFile(URI.joinPath(agentsRoot, 'helper-0.agent.md'), VSBuffer.fromString('agent 0')); +- await fileService.writeFile(URI.joinPath(instructionsRoot, 'base.instructions.md'), VSBuffer.fromString('---\napplyTo:\n - src/**\n---\nbase instruction')); +- +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); +- +- const actions: (SessionAction | ChatAction)[] = []; +- disposables.add(agent.onDidSessionProgress(progress => { +- if (progress.kind === 'action') { +- actions.push(progress.action); +- } +- })); +- +- type DiscoveredDirectoryCustomization = PluginCustomization & { children: NonNullable }; +- +- const countDiscoveredRefreshPublishes = (): number => actions.filter(action => { +- if (action.type !== ActionType.SessionCustomizationsChanged) { +- return false; +- } +- const customizations = (action as Extract).customizations; +- return customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString()) +- && customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === instructionsRoot.toString()); +- }).length; +- +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'session-discovery-burst-watcher-change'); +- await agent.createSession({ +- session, +- workingDirectory: workspace, +- }); +- +- await agent.getSessionCustomizations(session); +- await new Promise(resolve => setTimeout(resolve, 50)); +- const publishCountBeforeBurst = countDiscoveredRefreshPublishes(); +- +- await Promise.all([ +- fileService.writeFile(URI.joinPath(agentsRoot, 'helper-1.agent.md'), VSBuffer.fromString('agent 1')), +- fileService.writeFile(URI.joinPath(agentsRoot, 'helper-2.agent.md'), VSBuffer.fromString('agent 2')), +- fileService.writeFile(URI.joinPath(instructionsRoot, 'extra.instructions.md'), VSBuffer.fromString('---\napplyTo:\n - test/**\n---\nextra instruction')), +- ]); +- +- let discoveredAgentCount = 0; +- let discoveredInstructionCount = 0; +- for (let i = 0; i < 20 && (discoveredAgentCount < 3 || discoveredInstructionCount < 2); i++) { +- await new Promise(resolve => setTimeout(resolve, 50)); +- const customizations = await agent.getSessionCustomizations(session); +- const discoveredAgentDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString()); +- const discoveredInstructionDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === instructionsRoot.toString()); +- discoveredAgentCount = discoveredAgentDirectory?.children.filter(child => child.type === CustomizationType.Agent).length ?? 0; +- discoveredInstructionCount = discoveredInstructionDirectory?.children.filter(child => child.type === CustomizationType.Rule).length ?? 0; +- } +- +- assert.strictEqual(discoveredAgentCount, 3, 'expected agent burst changes to be discovered'); +- assert.strictEqual(discoveredInstructionCount, 2, 'expected instruction burst changes to be discovered'); +- assert.strictEqual( +- countDiscoveredRefreshPublishes(), +- publishCountBeforeBurst + 1, +- 'expected burst watcher changes across folders to result in exactly one discovered refresh publish (_onDidRefresh)' +- ); +- } finally { +- await disposeAgent(agent); +- } +- }); - }); - - suite('provisional sessions', () => { @@ -8904,6 +11643,58 @@ index 6bb478dbb36..00000000000 - } - }); - +- test('disposeSession removes the session from the SDK on-disk store', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'persisted-session-1'); +- await agent.disposeSession(session); +- +- assert.deepStrictEqual(client.deletedSessionIds, ['persisted-session-1']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('disposeSession on provisional session does not call client.deleteSession', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const result = await agent.createSession({ +- session: AgentSession.uri('copilotcli', 'prov-3'), +- workingDirectory: URI.file('/workspace'), +- }); +- +- await agent.disposeSession(result.session); +- +- assert.deepStrictEqual(client.deletedSessionIds, []); +- assert.strictEqual(agent.hasSession(result.session), false); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('disposeSession propagates SDK delete errors and preserves in-memory state', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- client.deleteSession = async () => { throw new Error('boom'); }; +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'persisted-session-2'); +- await assert.rejects(() => agent.disposeSession(session), /boom/); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - // Forking a provisional session is no longer a special case: the agent - // service drops `config.fork` for sources with no turns, so the call - // reduces to a plain new-session create. @@ -9048,43 +11839,296 @@ index 6bb478dbb36..00000000000 - const parentUri = AgentSession.uri('copilotcli', 'session-parent'); - const { calls } = installStubSession(agent, AgentSession.id(parentUri)); - -- const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); -- const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; -- agent.onClientToolCallComplete(subagentUri, 'tc-inner', result); +- const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; +- agent.onClientToolCallComplete(subagentUri, 'tc-inner', result); +- +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('routes a nested subagent session URI (depth > 1) to the root session entry', async () => { +- // Regression for depth > 1: a nested subagent URI like +- // `copilot:/root/subagent/tc1/subagent/tc2` must walk all the way +- // to the root session entry in `_sessions`, not stop at the +- // intermediate parent `copilot:/root/subagent/tc1`. +- const agent = createTestAgent(disposables); +- try { +- const rootUri = AgentSession.uri('copilotcli', 'session-root'); +- const { calls } = installStubSession(agent, AgentSession.id(rootUri)); +- +- const subagentUri = URI.parse(buildSubagentSessionUri(rootUri.toString(), 'tc-parent')); +- const nestedUri = URI.parse(buildSubagentSessionUri(subagentUri.toString(), 'tc-nested')); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'nested done' }; +- agent.onClientToolCallComplete(nestedUri, 'tc-inner', result); +- +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('is a no-op when no session entry exists for the resolved id', async () => { +- const agent = createTestAgent(disposables); +- try { +- const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); +- // No stub installed — the call should be silently ignored. +- agent.onClientToolCallComplete(sessionUri, 'tc-x', { success: true, pastTenseMessage: 'noop' }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('routes a peer chat URI to its chat-session entry', async () => { +- // Client-tool completions for tools running inside an additional +- // (non-default) chat are dispatched against the chat channel URI. +- // The agent must resolve that to the `_chatSessions` entry, which +- // is keyed by the chat URI string rather than a session id. +- const agent = createTestAgent(disposables); +- try { +- const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-1')); +- const calls: { toolCallId: string; result: ToolCallResult }[] = []; +- const stub = { +- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, +- dispose() { }, +- }; +- (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); +- +- const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; +- agent.onClientToolCallComplete(chatUri, 'tc-peer', result); +- +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-peer', result }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- }); +- +- suite('peer chat routing and lifecycle', () => { +- +- /** Installs a stub peer chat into `_chatSessions` keyed by the chat URI. */ +- function installStubChat(agent: CopilotAgent, chatUri: URI, options?: { permissionOwner?: string; inputOwner?: string }) { +- const events: string[] = []; +- let disposed = false; +- const stub = { +- respondToPermissionRequest(requestId: string, approved: boolean): boolean { +- if (options?.permissionOwner === requestId) { +- events.push(`perm:${requestId}:${approved}`); +- return true; +- } +- return false; +- }, +- respondToUserInputRequest(requestId: string, response: unknown): boolean { +- if (options?.inputOwner === requestId) { +- events.push(`input:${requestId}`); +- return true; +- } +- return false; +- }, +- handleClientToolCallComplete() { }, +- dispose() { disposed = true; }, +- }; +- (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); +- return { events, isDisposed: () => disposed }; +- } +- +- test('respondToPermissionRequest routes to a peer chat session', async () => { +- const agent = createTestAgent(disposables); +- try { +- const sessionUri = AgentSession.uri('copilotcli', 'session-perm'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-perm')); +- const chat = installStubChat(agent, chatUri, { permissionOwner: 'req-1' }); +- +- agent.respondToPermissionRequest('req-1', true); +- +- assert.deepStrictEqual(chat.events, ['perm:req-1:true']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('respondToUserInputRequest routes to a peer chat session', async () => { +- const agent = createTestAgent(disposables); +- try { +- const sessionUri = AgentSession.uri('copilotcli', 'session-input'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-input')); +- const chat = installStubChat(agent, chatUri, { inputOwner: 'req-2' }); +- +- agent.respondToUserInputRequest('req-2', 'submit' as never); +- +- assert.deepStrictEqual(chat.events, ['input:req-2']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('disposeSession disposes the session\'s peer chats', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- const result = await agent.createSession({ +- session: AgentSession.uri('copilotcli', 'parent-with-peers'), +- workingDirectory: URI.file('/workspace'), +- }); +- const chatUri = URI.parse(buildChatUri(result.session, 'peer-x')); +- const chat = installStubChat(agent, chatUri); +- +- await agent.disposeSession(result.session); +- +- assert.strictEqual(chat.isDisposed(), true, 'peer chat should be disposed with its parent session'); +- const chatSessions = (agent as unknown as { _chatSessions: Map })._chatSessions; +- assert.strictEqual(chatSessions.has(chatUri.toString()), false, 'peer chat entry should be removed'); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('getChats returns the persisted peer chat catalog', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); +- try { +- const session = AgentSession.uri('copilotcli', 'session-getchats'); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-a': { sdkSessionId: 'sdk-a' }, +- 'peer-b': { sdkSessionId: 'sdk-b' }, +- })); +- +- const chats = await agent.getChats(session); +- +- assert.deepStrictEqual( +- chats.map(c => c.toString()).sort(), +- [buildChatUri(session, 'peer-a'), buildChatUri(session, 'peer-b')].sort(), +- ); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('getChats drops corrupted or invalid persisted entries', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); +- try { +- const session = AgentSession.uri('copilotcli', 'session-getchats-invalid'); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-ok': { sdkSessionId: 'sdk-ok' }, +- 'peer-null': null, +- 'peer-missing-id': { model: { id: 'm' } }, +- 'peer-nonstring-id': { sdkSessionId: 42 }, +- 'peer-empty-id': { sdkSessionId: '' }, +- })); +- +- const chats = await agent.getChats(session); +- +- assert.deepStrictEqual( +- chats.map(c => c.toString()), +- [buildChatUri(session, 'peer-ok')], +- ); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('disposeChat removes the persisted entry and deletes its SDK conversation', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-a': { sdkSessionId: 'sdk-a' }, +- })); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- +- await agent.disposeChat(session, chatUri); - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); +- const remaining = await db.object.getMetadata('copilot.chats'); +- assert.deepStrictEqual({ +- remaining: remaining ? JSON.parse(remaining) : {}, +- deleted: client.deletedSessionIds, +- }, { +- remaining: {}, +- deleted: ['sdk-a'], +- }); - } finally { - await disposeAgent(agent); - } - }); +- }); - -- test('routes a nested subagent session URI (depth > 1) to the root session entry', async () => { -- // Regression for depth > 1: a nested subagent URI like -- // `copilot:/root/subagent/tc1/subagent/tc2` must walk all the way -- // to the root session entry in `_sessions`, not stop at the -- // intermediate parent `copilot:/root/subagent/tc1`. -- const agent = createTestAgent(disposables); -- try { -- const rootUri = AgentSession.uri('copilotcli', 'session-root'); -- const { calls } = installStubSession(agent, AgentSession.id(rootUri)); +- // Regression for the #319516 incident: a window reload reconnects with a +- // NEW clientId but an identical tool list. The cached SDK session's +- // staleness check (`ActiveClient.requiresRestart`) must NOT treat a +- // clientId-only change as a config change — otherwise either the session +- // is needlessly restarted, or (the actual bug) the cached session is +- // reused while the live clientId is never updated, so subsequent client +- // tool calls are stamped with the dead window's id and hang forever. +- suite('client tool refresh on reload (#319516)', () => { +- /** Minimal structural view of the agent's private per-session ActiveClient. */ +- type TestActiveClient = { +- readonly state: { readonly clientId: string | undefined }; +- snapshot(): Promise; +- requiresRestart(snap: IActiveClientSnapshot): Promise; +- }; - -- const subagentUri = URI.parse(buildSubagentSessionUri(rootUri.toString(), 'tc-parent')); -- const nestedUri = URI.parse(buildSubagentSessionUri(subagentUri.toString(), 'tc-nested')); -- const result: ToolCallResult = { success: true, pastTenseMessage: 'nested done' }; -- agent.onClientToolCallComplete(nestedUri, 'tc-inner', result); +- function getActiveClient(agent: CopilotAgent, session: URI): TestActiveClient { +- const activeClients = (agent as unknown as { _activeClients: { get(s: URI): TestActiveClient | undefined } })._activeClients; +- const activeClient = activeClients.get(session); +- assert.ok(activeClient, 'expected an ActiveClient to exist after setClientTools'); +- return activeClient; +- } - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); +- const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; +- +- test('clientId-only change (reload) does NOT require a restart and updates the live clientId', async () => { +- const agent = createTestAgent(disposables); +- try { +- const session = AgentSession.uri('copilotcli', 'reload-session'); +- +- // Window A registers its tools; this is the snapshot the SDK +- // session would be created with. +- agent.setClientTools(session, 'client-A', tools); +- const activeClient = getActiveClient(agent, session); +- const appliedSnapshot = await activeClient.snapshot(); +- assert.strictEqual(activeClient.state.clientId, 'client-A'); +- +- // Window A reloads: window B reconnects with a new clientId but +- // the identical tool list. +- agent.setClientTools(session, 'client-B', [...tools]); +- +- // Root-cause assertions: the cached SDK session must be reused +- // (no restart) AND the live clientId must now be window B's, so +- // the next client tool call is stamped with a live owner. +- assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); +- assert.strictEqual(activeClient.state.clientId, 'client-B'); - } finally { - await disposeAgent(agent); - } - }); - -- test('is a no-op when no session entry exists for the resolved id', async () => { +- test('a structural tool change still requires a restart', async () => { - const agent = createTestAgent(disposables); - try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); -- // No stub installed — the call should be silently ignored. -- agent.onClientToolCallComplete(sessionUri, 'tc-x', { success: true, pastTenseMessage: 'noop' }); +- const session = AgentSession.uri('copilotcli', 'tools-change-session'); +- +- agent.setClientTools(session, 'client-A', tools); +- const activeClient = getActiveClient(agent, session); +- const appliedSnapshot = await activeClient.snapshot(); +- +- // A genuinely different tool set (added tool) must restart so the +- // SDK session is rebuilt with the new tools. +- agent.setClientTools(session, 'client-A', [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]); +- +- assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); - } finally { - await disposeAgent(agent); - } @@ -9312,11 +12356,14 @@ index 6bb478dbb36..00000000000 - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = 'add-feature'; - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { - sessionDataService, - copilotClient: new TestCopilotClient([]), - gitService, +- copilotApiService, - }) as TestableCopilotAgent; - - const fakeMessages: Turn[] = [ @@ -9357,8 +12404,7 @@ index 6bb478dbb36..00000000000 - // before constructing the SDK session. Verifies that the - // real production code path persists branch metadata and - // queues the live announcement. -- const branchHint = 'add-feature'; -- const expectedBranchName = getCopilotWorktreeBranchName(sessionId, branchHint); +- const expectedBranchName = `agents/add-feature-${sessionId.substring(0, 8)}`; - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main' }, @@ -9381,13 +12427,13 @@ index 6bb478dbb36..00000000000 - - const markdownSignals = signals.filter((s): s is IAgentActionSignal => - s.kind === 'action' && ( -- (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.SessionDelta +- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || +- s.action.type === ActionType.ChatDelta - ) - ); - assert.strictEqual(markdownSignals.length, 1, 'exactly one markdown announcement signal should be emitted for the worktree announcement'); - const announcement = markdownSignals[0]; -- const announcementContent = announcement.action.type === ActionType.SessionResponsePart +- const announcementContent = announcement.action.type === ActionType.ChatResponsePart - ? (announcement.action.part as MarkdownResponsePart).content - : (announcement.action as IDeltaAction).content; - assert.ok(announcementContent.includes(expectedBranchName), `announcement should contain branch name '${expectedBranchName}', got '${announcementContent}'`); @@ -9397,8 +12443,8 @@ index 6bb478dbb36..00000000000 - await agent.sendMessage(session, 'follow-up'); - const reemittedMarkdown = signals.filter(s => - s.kind === 'action' && ( -- (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.SessionDelta +- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || +- s.action.type === ActionType.ChatDelta - ) - ); - assert.strictEqual(reemittedMarkdown.length, 0, 'announcement must not be re-emitted on subsequent sends'); @@ -9455,8 +12501,8 @@ index 6bb478dbb36..00000000000 - await agent.sendMessage(session, 'hello'); - const markdownSignals = signals.filter(s => - s.kind === 'action' && ( -- (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.SessionDelta +- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || +- s.action.type === ActionType.ChatDelta - ) - ); - assert.deepStrictEqual(markdownSignals, [], 'no announcement should be emitted live'); @@ -9636,10 +12682,10 @@ index 6bb478dbb36..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts deleted file mode 100644 -index 3ba908cae5d..00000000000 +index 7cff7183..00000000 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ /dev/null -@@ -1,3076 +0,0 @@ +@@ -1,3982 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -9647,7 +12693,7 @@ index 3ba908cae5d..00000000000 - -import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; -import assert from 'assert'; --import { DeferredPromise } from '../../../../base/common/async.js'; +-import { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -9663,17 +12709,20 @@ index 3ba908cae5d..00000000000 -import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +-import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; -import { IDiffComputeService } from '../../common/diffComputeService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; --import { ActionType, type SessionDeltaAction, type SessionErrorAction, type SessionInputRequestedAction, type SessionResponsePartAction, type SessionToolCallCompleteAction, type SessionToolCallReadyAction, type SessionToolCallStartAction } from '../../common/state/sessionActions.js'; --import { MessageAttachmentKind, MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; +-import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction } from '../../common/state/sessionActions.js'; +-import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; -import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +-import { ActiveClientState } from '../../node/activeClientState.js'; -import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; -import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; -import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; -import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; +-import { IAgentServerToolHost } from '../../common/agentServerTools.js'; - -// ---- Mock CopilotSession (SDK level) ---------------------------------------- - @@ -9686,6 +12735,13 @@ index 3ba908cae5d..00000000000 - readonly sessionId = 'test-session-1'; - readonly sendRequests: unknown[] = []; - readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; +- readonly compactCalls: unknown[] = []; +- readonly commandListCalls: unknown[] = []; +- readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; +- compactResult: { success: boolean; tokensRemoved: number; messagesRemoved: number } = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; +- compactError: unknown = undefined; +- commandListResult: { commands: Array<{ name: string; kind: 'builtin' | 'skill' | 'client'; description: string; allowDuringAgentExecution: boolean }> } = { commands: [] }; +- commandInvokeResult: { kind: 'text'; text: string; markdown?: boolean } | { kind: 'completed'; message?: string } | { kind: 'agent-prompt'; prompt: string; displayPrompt: string; mode?: 'interactive' | 'plan' | 'autopilot' } = { kind: 'text', text: '' }; - messages: SessionEvent[] = []; - - private readonly _handlers = new Map void>>(); @@ -9734,7 +12790,39 @@ index 3ba908cae5d..00000000000 - update: async (_params: { content: string }) => { /* no-op */ }, - delete: async () => { /* no-op */ }, - }, +- history: { +- compact: async (params?: unknown) => { +- this.compactCalls.push(params ?? null); +- if (this.compactError !== undefined) { +- throw this.compactError; +- } +- return this.compactResult; +- }, +- }, +- commands: { +- list: async (params?: unknown) => { +- this.commandListCalls.push(params ?? null); +- return this.commandListResult; +- }, +- invoke: async (params: { name: string; input?: string }) => { +- this.commandInvokeCalls.push(params); +- return this.commandInvokeResult; +- }, +- }, +- mcp: { +- list: async () => { +- if (this.mcpListError !== undefined) { +- throw this.mcpListError; +- } +- return this.mcpListResult; +- }, +- executeSampling: async () => ({ status: 'completed' as const, result: undefined }), +- cancelSamplingExecution: async () => { /* no-op */ }, +- }, - }; +- +- mcpListResult: { servers: ReadonlyArray<{ name: string; status: 'connected' | 'failed' | 'pending'; error?: string }> } = { servers: [] }; +- mcpListError: unknown = undefined; -} - -class CapturingLogService extends NullLogService { @@ -9800,12 +12888,11 @@ index 3ba908cae5d..00000000000 - _editTracker: { - trackEditStart(path: string): Promise; - completeEdit(path: string): Promise; -- takeCompletedEdit(turnId: string, toolCallId: string, path: string): Promise; +- takeCompletedEdit(turnId: string, toolCallId: string, path: string, toolName: string, toolInput: unknown, modelId: string | undefined): Promise; - }; - _pendingClientToolCalls: { -- get(toolCallId: string): DeferredPromise | undefined; -- set(toolCallId: string, value: DeferredPromise): Map>; -- delete(toolCallId: string): boolean; +- register(toolCallId: string): Promise; +- respondOrBuffer(toolCallId: string, value: ToolResultObject): void; - }; -}; - @@ -9819,15 +12906,16 @@ index 3ba908cae5d..00000000000 - .map(s => s.action); -} - --function getInputRequest(signal: AgentSignal): SessionInputRequestedAction['request'] { +-function getInputRequest(signal: AgentSignal): ChatInputRequestedAction['request'] { - assert.strictEqual(signal.kind, 'action'); - if (signal.kind !== 'action') { throw new Error('unreachable'); } -- assert.strictEqual(signal.action.type, ActionType.SessionInputRequested); -- return (signal.action as SessionInputRequestedAction).request; +- assert.strictEqual(signal.action.type, ActionType.ChatInputRequested); +- return (signal.action as ChatInputRequestedAction).request; -} - -async function createAgentSession(disposables: DisposableStore, options?: { - clientSnapshot?: IActiveClientSnapshot; +- activeClientState?: ActiveClientState; - environmentServiceRegistration?: 'native' | 'none'; - logService?: ILogService; - telemetryService?: ITelemetryService; @@ -9837,6 +12925,10 @@ index 3ba908cae5d..00000000000 - configValues?: Record; - fileContents?: Record; - fileReadErrors?: readonly string[]; +- /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ +- configureMockSession?: (session: MockCopilotSession) => void; +- /** Optional server-tool host wired into the session. */ +- serverToolHost?: IAgentServerToolHost; -}): Promise<{ - session: CopilotAgentSession; - runtime: ICopilotSessionRuntime; @@ -9872,6 +12964,7 @@ index 3ba908cae5d..00000000000 - - const sessionUri = AgentSession.uri('copilot', 'test-session-1'); - const mockSession = new MockCopilotSession(); +- options?.configureMockSession?.(mockSession); - - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'create', @@ -9879,10 +12972,11 @@ index 3ba908cae5d..00000000000 - createSession: async () => mockSession as unknown as CopilotSession, - resumeSession: async () => mockSession as unknown as CopilotSession, - }, +- activeClientState: new ActiveClientState(), - sessionId: 'test-session-1', - workingDirectory: options?.workingDirectory, - resolvedAgentName: undefined, -- snapshot: options?.clientSnapshot ?? { clientId: '', tools: [], plugins: [] }, +- snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, - shellManager: undefined, - githubToken: undefined, - model: undefined, @@ -9928,6 +13022,7 @@ index 3ba908cae5d..00000000000 - getRootValue: () => undefined, - updateRootConfig: () => { /* no-op */ }, - persistRootConfig: () => { /* no-op */ }, +- whenIdle: async () => { /* no-op */ }, - }; - services.set(IAgentConfigurationService, fakeConfigurationService); - const environmentService = { @@ -9950,7 +13045,10 @@ index 3ba908cae5d..00000000000 - launchPlan, - shellManager: undefined, - clientSnapshot: options?.clientSnapshot, +- activeClientState: options?.activeClientState, +- resolveMcpChildId: () => undefined, - workingDirectory: options?.workingDirectory, +- serverToolHost: options?.serverToolHost, - }, - )); - @@ -10012,6 +13110,104 @@ index 3ba908cae5d..00000000000 - }]); - }); - +- test('maps symbol Resource attachments to SDK selection so the range survives (#315193)', async () => { +- // Symbols arrive as a Resource with displayKind 'symbol' AND a populated selection.range. Keying the selection +- // branch off the `selection` field (not displayKind === 'selection') keeps the range instead of degrading the +- // symbol to a plain file reference. +- const symbolUri = URI.file('/workspace/sym.ts'); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileContents: { +- [symbolUri.toString()]: 'line0\nline1\nfunction foo() {}\nline3', +- }, +- }); +- +- await session.send('explain this', [ +- { +- type: MessageAttachmentKind.Resource, +- uri: symbolUri.toString(), +- label: 'foo', +- displayKind: 'symbol', +- selection: { +- range: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- }, +- ]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'explain this', +- attachments: [ +- { +- type: 'selection', +- filePath: symbolUri.fsPath, +- displayName: 'foo', +- text: 'foo', +- selection: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- ], +- }]); +- }); +- +- test('falls back to file reference when reading a symbol Resource attachment fails', async () => { +- const symbolUri = URI.file('/workspace/missing.ts'); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileReadErrors: [symbolUri.toString()], +- }); +- +- await session.send('explain this', [ +- { +- type: MessageAttachmentKind.Resource, +- uri: symbolUri.toString(), +- label: 'foo', +- displayKind: 'symbol', +- selection: { +- range: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- }, +- ]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'explain this', +- attachments: [ +- { type: 'file', path: symbolUri.fsPath, displayName: 'foo' }, +- ], +- }]); +- }); +- +- test('sends agent feedback annotations attachments as text blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('/act-on-feedback', [{ +- type: MessageAttachmentKind.Annotations, +- label: '1 comment', +- displayKind: AgentFeedbackAttachmentDisplayKind, +- resource: 'ahp-session:/s/annotations', +- annotationIds: ['feedback-1'], +- }]); +- +- const expectedText = +- 'The user attached specific feedback comments to act on (comment ids):\n' + +- '- feedback-1\n\n' + +- 'Use the `listComments` tool to read their content and focus on these comments.'; +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: '/act-on-feedback', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString(expectedText)), +- mimeType: 'text/plain', +- displayName: '1 comment', +- }], +- }]); +- }); +- - test('sends simple attachments as text blobs and restores them from SDK blobs', async () => { - const { session, mockSession } = await createAgentSession(disposables); - @@ -10066,6 +13262,283 @@ index 3ba908cae5d..00000000000 - }]); - }); - +- test('sends paste simple attachments as text blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('continue', [{ +- type: MessageAttachmentKind.Simple, +- label: 'Previous conversation', +- displayKind: 'paste', +- modelRepresentation: 'Transcript text', +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'continue', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Transcript text')), +- mimeType: 'text/plain', +- displayName: 'Previous conversation', +- }], +- }]); +- +- mockSession.messages = [{ +- type: 'user.message', +- id: 'event-1', +- parentId: null, +- timestamp: new Date().toISOString(), +- data: { +- interactionId: 'message-1', +- content: 'continue', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Transcript text')), +- mimeType: 'text/plain', +- displayName: 'Previous conversation', +- }], +- }, +- }]; +- +- assert.deepStrictEqual((await session.getMessages())[0].message.attachments, [{ +- type: MessageAttachmentKind.Simple, +- label: 'Previous conversation', +- modelRepresentation: 'Transcript text', +- }]); +- }); +- +- test('`/compact` runs the history compact RPC and completes the turn with output', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- // The compact command is handled inline via the history RPC and must +- // not fall through to a normal SDK `send()` turn. +- assert.strictEqual(mockSession.compactCalls.length, 1); +- assert.deepStrictEqual(mockSession.sendRequests, []); +- +- // The turn opened by the server is closed inline (the SDK never fires +- // `onIdle` for the compact path) after emitting the completion message. +- const actions = getActions(signals); +- const responseParts = actions.filter((a): a is ChatResponsePartAction => a.type === ActionType.ChatResponsePart); +- assert.strictEqual(responseParts.length, 1); +- const responsePart = responseParts[0]; +- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); +- if (responsePart.part.kind !== ResponsePartKind.Markdown) { +- throw new Error('unreachable'); +- } +- assert.deepStrictEqual({ +- turnId: responsePart.turnId, +- kind: responsePart.part.kind, +- content: responsePart.part.content, +- }, { +- turnId: 'turn-compact', +- kind: ResponsePartKind.Markdown, +- content: 'Compaction completed', +- }); +- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(turnComplete, 'expected the turn to complete'); +- assert.strictEqual((turnComplete as ChatTurnCompleteAction).turnId, 'turn-compact'); +- }); +- +- test('`/compact` completes the turn even when compaction reports failure', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.compactResult = { success: false, tokensRemoved: 0, messagesRemoved: 0 }; +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- assert.strictEqual(mockSession.compactCalls.length, 1); +- assert.deepStrictEqual(mockSession.sendRequests, []); +- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(turnComplete, 'expected the turn to complete on a failed compaction'); +- }); +- +- test('`/compact` treats nothing-to-compact errors as completed', async () => { +- const logService = new CapturingLogService(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); +- mockSession.compactError = new Error('NOTHING TO COMPACT for this conversation'); +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- compactCalls: mockSession.compactCalls.length, +- sendRequests: mockSession.sendRequests, +- errors: logService.errors, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { turnId: a.turnId, kind: part.kind, content: part.content } : { turnId: a.turnId, kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- compactCalls: 1, +- sendRequests: [], +- errors: [], +- responseParts: [{ turnId: 'turn-compact', kind: ResponsePartKind.Markdown, content: 'Compaction completed' }], +- turnComplete: ['turn-compact'], +- }); +- }); +- +- test('`/env` runs the runtime command when listed and emits markdown output', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'text', text: '## Environment\n\nLoaded.', markdown: true }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], +- commandInvokeCalls: [{ name: 'env' }], +- sendRequests: [], +- responseParts: [{ kind: ResponsePartKind.Markdown, content: '## Environment\n\nLoaded.' }], +- turnComplete: ['turn-env'], +- }); +- }); +- +- test('`/env` escapes plain text runtime command output before emitting markdown', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'text', text: '*plain*\n- item', markdown: false }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart) as ChatResponsePartAction | undefined; +- assert.strictEqual(responsePart?.part.kind, ResponsePartKind.Markdown); +- if (responsePart?.part.kind === ResponsePartKind.Markdown) { +- assert.strictEqual(responsePart.part.content, '\\*plain\\*\n\\- item'); +- } +- }); +- +- test('`/env` falls through to a normal SDK send when not listed', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { commands: [] }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), +- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], +- commandInvokeCalls: [], +- sendRequests: [{ prompt: '/env', attachments: undefined }], +- responseParts: [], +- turnComplete: [], +- }); +- }); +- +- test('caches runtime slash command availability across checks', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [ +- { +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }, +- { +- name: 'review', +- kind: 'builtin', +- description: 'Run code review agent to analyze changes', +- allowDuringAgentExecution: false, +- }, +- { +- name: 'not-a-builtin', +- kind: 'skill', +- description: 'Skill command', +- allowDuringAgentExecution: false, +- }, +- ], +- }; +- +- assert.deepStrictEqual({ +- env: await session.hasRuntimeSlashCommand('env'), +- review: await session.hasRuntimeSlashCommand('review'), +- skill: await session.hasRuntimeSlashCommand('not-a-builtin'), +- commandListCalls: mockSession.commandListCalls, +- }, { +- env: true, +- review: true, +- skill: false, +- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], +- }); +- }); +- +- test('`/review` forwards as a prompt-invoked command', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- await session.send('/review focus on tests', undefined, 'turn-review'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), +- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), +- }, { +- commandListCalls: [], +- commandInvokeCalls: [], +- sendRequests: [{ prompt: '/review focus on tests', attachments: undefined }], +- responseParts: [], +- turnComplete: [], +- }); +- }); +- +- test('`/security-review` forwards as a prompt-invoked command', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- await session.send('/security-review', undefined, 'turn-security-review'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), +- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), +- }, { +- commandListCalls: [], +- commandInvokeCalls: [], +- sendRequests: [{ prompt: '/security-review', attachments: undefined }], +- responseParts: [], +- turnComplete: [], +- }); +- }); +- - test('emits accumulated Copilot usage metadata', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - @@ -10076,18 +13549,21 @@ index 3ba908cae5d..00000000000 - outputTokens: 20, - cacheReadTokens: 5, - cost: 2, -- }); +- // `copilotUsage` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. +- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); - mockSession.fire('assistant.usage', { - model: 'claude-sonnet-4.6', - inputTokens: 30, - outputTokens: 40, - cost: 2, -- }); +- copilotUsage: { totalNanoAiu: 750_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); - - const usageActions = signals - .filter((s): s is IAgentActionSignal => s.kind === 'action') - .map(s => s.action) -- .filter(a => a.type === ActionType.SessionUsage); +- .filter(a => a.type === ActionType.ChatUsage); - - assert.deepStrictEqual(usageActions.map(a => a.usage), [ - { @@ -10097,6 +13573,7 @@ index 3ba908cae5d..00000000000 - cacheReadTokens: 5, - _meta: { - cost: 2, +- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, - }, - }, - { @@ -10106,6 +13583,50 @@ index 3ba908cae5d..00000000000 - cacheReadTokens: undefined, - _meta: { - cost: 2, +- copilotUsage: { totalNanoAiu: 1_250_000_000, tokenDetails: [] }, +- }, +- }, +- ]); +- }); +- +- test('forwards account quota snapshots on usage metadata', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-quota'); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 10, +- outputTokens: 20, +- // `quotaSnapshots` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. +- quotaSnapshots: { +- premium_interactions: { +- isUnlimitedEntitlement: false, +- entitlementRequests: 300, +- usedRequests: 75, +- usageAllowedWithExhaustedQuota: true, +- remainingPercentage: 75, +- overage: 1.5, +- overageAllowedWithExhaustedQuota: true, +- resetDate: '2026-07-01T00:00:00.000Z', +- }, +- }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- +- const usageActions = signals +- .filter((s): s is IAgentActionSignal => s.kind === 'action') +- .map(s => s.action) +- .filter(a => a.type === ActionType.ChatUsage); +- +- assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.quotaSnapshots), [ +- { +- premium_interactions: { +- isUnlimitedEntitlement: false, +- entitlementRequests: 300, +- usedRequests: 75, +- remainingPercentage: 75, +- overage: 1.5, +- overageAllowedWithExhaustedQuota: true, +- resetDate: '2026-07-01T00:00:00.000Z', - }, - }, - ]); @@ -10563,7 +14084,7 @@ index 3ba908cae5d..00000000000 - - // Sending the steering must not flip turns until the SDK has - // echoed the user message back through the event stream. -- assert.strictEqual(signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.SessionTurnStarted), undefined); +- assert.strictEqual(signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted), undefined); - - mockSession.fire('user.message', { - content: 'focus on tests', @@ -10571,8 +14092,8 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'user.message'>['data']); - - const actions = signals.filter(s => s.kind === 'action').map(s => (s as IAgentActionSignal).action); -- const turnComplete = actions.find(a => a.type === ActionType.SessionTurnComplete); -- const turnStarted = actions.find(a => a.type === ActionType.SessionTurnStarted); +- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); +- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); - assert.ok(turnComplete, 'should complete the in-flight turn before promoting steering'); - assert.strictEqual(turnComplete.turnId, 'turn-original'); - assert.ok(turnStarted, 'should start a new turn for the steering message'); @@ -10594,7 +14115,7 @@ index 3ba908cae5d..00000000000 - const turnStarted = signals - .filter(s => s.kind === 'action') - .map(s => (s as IAgentActionSignal).action) -- .find(a => a.type === ActionType.SessionTurnStarted)!; +- .find(a => a.type === ActionType.ChatTurnStarted)!; - - mockSession.fire('assistant.message_delta', { - deltaContent: 'No problem', @@ -10603,7 +14124,7 @@ index 3ba908cae5d..00000000000 - const responseParts = signals - .filter(s => s.kind === 'action') - .map(s => (s as IAgentActionSignal).action) -- .filter(a => a.type === ActionType.SessionResponsePart); +- .filter(a => a.type === ActionType.ChatResponsePart); - assert.ok(responseParts.length > 0, 'expected delta to allocate a response part'); - assert.strictEqual(responseParts[0].turnId, turnStarted.turnId, 'response part should land in the steering turn, not the original'); - }); @@ -10623,7 +14144,7 @@ index 3ba908cae5d..00000000000 - source: 'skill-pdf', - } as SessionEventPayload<'user.message'>['data']); - -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.SessionTurnStarted); +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); - assert.strictEqual(turnStarted, undefined, 'synthetic user messages should not promote steering to a turn'); - }); - @@ -10636,7 +14157,7 @@ index 3ba908cae5d..00000000000 - content: 'something completely different', - } as SessionEventPayload<'user.message'>['data']); - -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.SessionTurnStarted); +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); - assert.strictEqual(turnStarted, undefined, 'unrelated user messages should not consume the pending steering'); - }); - @@ -10649,15 +14170,44 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(mockSession.sendRequests.length, 1); - }); - -- test('fires steering_consumed on abort when the steering never reached its turn', async () => { -- const { session, signals } = await createAgentSession(disposables); +- test('fires steering_consumed on abort when the steering never reached its turn', async () => { +- const { session, signals } = await createAgentSession(disposables); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- await session.abort(); +- +- const consumed = signals.find(s => s.kind === 'steering_consumed'); +- assert.ok(consumed, 'abort should clean up pending steering UI state'); +- assert.strictEqual((consumed as { id: string }).id, 'steer-1'); +- }); +- +- test('an abort during a steering turn tears it down without completing it', async () => { +- // A steering turn is promoted mid-loop while the SDK is actively +- // producing its response, so it must be `running` (not `pending`). +- // Otherwise an abort's terminal idle would treat it as a not-yet- +- // started queued turn and leave it open, and a later idle would +- // orphan-complete it. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const steeringTurnId = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)?.turnId; +- assert.ok(steeringTurnId && steeringTurnId !== 'turn-original', 'steering should start its own turn'); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- await session.abort(); +- // Abort: the running steering turn is finalized by the client's +- // ChatTurnCancelled, so its terminal idle must tear it down rather +- // than complete it — and a subsequent stray idle must find no turn. +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- const consumed = signals.find(s => s.kind === 'steering_consumed'); -- assert.ok(consumed, 'abort should clean up pending steering UI state'); -- assert.strictEqual((consumed as { id: string }).id, 'steer-1'); +- const steeringCompletions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete && a.turnId === steeringTurnId); +- assert.strictEqual(steeringCompletions.length, 0, 'an aborted steering turn must not be completed'); - }); - - test('does not signal cleanup when send fails', async () => { @@ -10668,7 +14218,7 @@ index 3ba908cae5d..00000000000 - await session.sendSteering({ id: 'steer-fail', message: { text: 'will fail', origin: { kind: MessageKind.User } } }); - - const consumed = signals.find(s => s.kind === 'steering_consumed'); -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.SessionTurnStarted); +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); - assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure'); - assert.strictEqual(turnStarted, undefined, 'should not start a new turn on failure'); - }); @@ -10738,7 +14288,7 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(mockSession.sendRequests.length, 0, 'system notification should not call session.send'); - const actions = getActions(signals); -- const turnStarted = actions.find(a => a.type === ActionType.SessionTurnStarted); +- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); - assert.ok(turnStarted, 'should synthesize a fresh turn'); - assert.deepStrictEqual(turnStarted.message, { text: '`sleep 6` completed', origin: { kind: MessageKind.SystemNotification } }); - }); @@ -10750,15 +14300,15 @@ index 3ba908cae5d..00000000000 - content: 'Shell command completed', - kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, - } as SessionEventPayload<'system.notification'>['data']); -- const turnStarted = getActions(signals).find(a => a.type === ActionType.SessionTurnStarted)!; +- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; - - mockSession.fire('assistant.message_delta', { - deltaContent: 'Reading the shell output now.', - } as SessionEventPayload<'assistant.message_delta'>['data']); - -- const responsePart = getActions(signals).find(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.Markdown); +- const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); - assert.ok(responsePart, 'expected response part for follow-up assistant delta'); -- assert.strictEqual((responsePart as SessionResponsePartAction).turnId, (turnStarted as { turnId: string }).turnId); +- assert.strictEqual((responsePart as ChatResponsePartAction).turnId, (turnStarted as { turnId: string }).turnId); - }); - - test('notification during an active turn appends a SystemNotification response part', async () => { @@ -10771,8 +14321,8 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'system.notification'>['data']); - - const actions = getActions(signals); -- assert.strictEqual(actions.find(a => a.type === ActionType.SessionTurnStarted), undefined, 'should not create a duplicate turn'); -- const systemPart = actions.find(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as SessionResponsePartAction | undefined; +- assert.strictEqual(actions.find(a => a.type === ActionType.ChatTurnStarted), undefined, 'should not create a duplicate turn'); +- const systemPart = actions.find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as ChatResponsePartAction | undefined; - assert.ok(systemPart, 'expected system notification response part'); - assert.strictEqual(systemPart.turnId, 'turn-active'); - assert.strictEqual(systemPart.part.kind, ResponsePartKind.SystemNotification); @@ -10786,17 +14336,18 @@ index 3ba908cae5d..00000000000 - content: 'Shell command completed', - kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, - } as SessionEventPayload<'system.notification'>['data']); -- const turnStarted = getActions(signals).find(a => a.type === ActionType.SessionTurnStarted)!; +- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; - - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- const turnComplete = getActions(signals).find(a => a.type === ActionType.SessionTurnComplete); +- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); - assert.ok(turnComplete, 'expected idle to complete the generated turn'); - assert.strictEqual((turnComplete as { turnId: string }).turnId, turnStarted.turnId); - }); - -- test('events after a completed turn do not target the stale previous turn id', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('a late event after a completed turn is dropped and logged (never targets the stale turn id)', async () => { +- const logService = new CapturingLogService(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); - session.resetTurnState('turn-old'); - - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); @@ -10804,12 +14355,13 @@ index 3ba908cae5d..00000000000 - deltaContent: 'late text', - } as SessionEventPayload<'assistant.message_delta'>['data']); - -- const lateMarkdownActions = getActions(signals) -- .filter(a => a.type === ActionType.SessionResponsePart && a.part.kind === ResponsePartKind.Markdown) -- .map(a => a as SessionResponsePartAction); -- const lateMarkdown = lateMarkdownActions[lateMarkdownActions.length - 1]; -- assert.ok(lateMarkdown, 'late event still emits a no-op action for the reducer'); -- assert.notStrictEqual(lateMarkdown.turnId, 'turn-old'); +- // With no active turn, the late delta is dropped (not emitted even as a +- // no-op), so it can never be attributed to the stale 'turn-old', and the +- // unexpected state is surfaced via an error log. +- const markdownActions = getActions(signals) +- .filter(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); +- assert.strictEqual(markdownActions.length, 0, 'the late delta must be dropped, not emitted'); +- assert.ok(logService.errors.some(e => /no active turn/i.test(String(e.first))), 'the dropped delta should be logged'); - }); - }); - @@ -10827,9 +14379,9 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(signals.length, 2); - const toolStart = signals[0]; -- assert.ok(isAction(toolStart, ActionType.SessionToolCallStart)); -- if (isAction(toolStart, ActionType.SessionToolCallStart)) { -- const action = toolStart.action as SessionToolCallStartAction; +- assert.ok(isAction(toolStart, ActionType.ChatToolCallStart)); +- if (isAction(toolStart, ActionType.ChatToolCallStart)) { +- const action = toolStart.action as ChatToolCallStartAction; - assert.strictEqual(action.toolCallId, 'tc-10'); - assert.strictEqual(action.toolName, 'bash'); - } @@ -10847,16 +14399,16 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(signals.length, 2); - // toolInput on the auto-ready signal (signals[1]) - const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); -- if (isAction(readySignal, ActionType.SessionToolCallReady)) { -- const action = readySignal.action as SessionToolCallReadyAction; +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- const action = readySignal.action as ChatToolCallReadyAction; - assert.strictEqual(action.toolInput, 'npm test'); - } - // toolArguments in _meta on the tool_start signal (signals[0]) - const startSignal = signals[0]; -- assert.ok(isAction(startSignal, ActionType.SessionToolCallStart)); -- if (isAction(startSignal, ActionType.SessionToolCallStart)) { -- const meta = (startSignal.action as SessionToolCallStartAction)._meta; +- assert.ok(isAction(startSignal, ActionType.ChatToolCallStart)); +- if (isAction(startSignal, ActionType.ChatToolCallStart)) { +- const meta = (startSignal.action as ChatToolCallStartAction)._meta; - const toolArgs = meta?.['toolArguments'] as string | undefined; - assert.ok(toolArgs && toolArgs.includes('"npm test"'), `toolArguments should contain rewritten command, was: ${toolArgs}`); - assert.ok(!toolArgs?.includes('cd /repo/project'), 'toolArguments should not contain stripped prefix'); @@ -10881,9 +14433,9 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(signals.length, 3); - const completeSignal = signals[2]; -- assert.ok(isAction(completeSignal, ActionType.SessionToolCallComplete)); -- if (isAction(completeSignal, ActionType.SessionToolCallComplete)) { -- const action = completeSignal.action as SessionToolCallCompleteAction; +- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); +- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { +- const action = completeSignal.action as ChatToolCallCompleteAction; - const past = action.result.pastTenseMessage; - const pastStr = typeof past === 'string' ? past : (past?.markdown ?? ''); - assert.ok(!pastStr.includes('cd /repo/project'), `past-tense message should not contain stripped prefix, got: ${pastStr}`); @@ -10965,13 +14517,17 @@ index 3ba908cae5d..00000000000 - - test('client tool telemetry does not use clientId as toolExtensionId', async () => { - const telemetryService = new RecordingTelemetryService(); +- const tools = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }] as const; +- const activeClientState = new ActiveClientState(); +- activeClientState.update('test-client', tools); - const { mockSession } = await createAgentSession(disposables, { - telemetryService, - clientSnapshot: { -- clientId: 'test-client', -- tools: [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }], +- tools, - plugins: [], +- mcpServers: {}, - }, +- activeClientState, - }); - - mockSession.fire('tool.execution_start', { @@ -11003,6 +14559,35 @@ index 3ba908cae5d..00000000000 - invocationTimeMs: true, - }, - }); +- +- }); +- +- test('live task_complete emits root markdown instead of a tool call', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-task-complete', +- toolName: 'task_complete', +- arguments: { summary: 'Completed the requested work.' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-task-complete', +- success: true, +- result: { content: 'Completed the requested work.' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- +- const actions = getActions(signals); +- assert.deepStrictEqual(actions.map(a => a.type), [ActionType.ChatResponsePart]); +- const responsePart = actions[0] as ChatResponsePartAction; +- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); +- if (responsePart.part.kind !== ResponsePartKind.Markdown) { +- return; +- } +- assert.deepStrictEqual(responsePart.part, { +- kind: ResponsePartKind.Markdown, +- id: responsePart.part.id, +- content: '\n\n**Task completed:** Completed the requested work.', +- }); - }); - - test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { @@ -11016,9 +14601,9 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(signals.length, 2); - const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); -- if (isAction(readySignal, ActionType.SessionToolCallReady)) { -- assert.strictEqual((readySignal.action as SessionToolCallReadyAction).toolInput, 'cd /tmp && ls'); +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /tmp && ls'); - } - }); - @@ -11032,9 +14617,9 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(signals.length, 2); - const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); -- if (isAction(readySignal, ActionType.SessionToolCallReady)) { -- assert.strictEqual((readySignal.action as SessionToolCallReadyAction).toolInput, 'cd /repo/project && npm test'); +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /repo/project && npm test'); - } - }); - @@ -11089,7 +14674,7 @@ index 3ba908cae5d..00000000000 - const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { workingDirectory }); - const sessionInternals = session as unknown as ISessionInternalsForTest; - const taken: string[] = []; -- sessionInternals._editTracker.takeCompletedEdit = async (_turnId, _toolCallId, path) => { +- sessionInternals._editTracker.takeCompletedEdit = async (_turnId, _toolCallId, path, _toolName, _toolInput, _modelId) => { - taken.push(path); - return undefined; - }; @@ -11116,7 +14701,7 @@ index 3ba908cae5d..00000000000 - success: true, - } as SessionEventPayload<'tool.execution_complete'>['data']); - -- await waitForSignal(s => isAction(s, ActionType.SessionToolCallComplete)); +- await waitForSignal(s => isAction(s, ActionType.ChatToolCallComplete)); - - assert.deepStrictEqual(taken, [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts')]); - }); @@ -11174,9 +14759,9 @@ index 3ba908cae5d..00000000000 - - assert.strictEqual(signals.length, 3); - const completeSignal = signals[2]; -- assert.ok(isAction(completeSignal, ActionType.SessionToolCallComplete)); -- if (isAction(completeSignal, ActionType.SessionToolCallComplete)) { -- const action = completeSignal.action as SessionToolCallCompleteAction; +- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); +- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { +- const action = completeSignal.action as ChatToolCallCompleteAction; - assert.strictEqual(action.toolCallId, 'tc-12'); - assert.ok(action.result.success); - assert.ok(action.result.pastTenseMessage); @@ -11199,7 +14784,7 @@ index 3ba908cae5d..00000000000 - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - - assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.SessionTurnComplete)); +- assert.ok(isAction(signals[0], ActionType.ChatTurnComplete)); - }); - - test('idle event without an active turn is ignored', async () => { @@ -11209,6 +14794,94 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(signals.length, 0); - }); - +- test('drops and logs a markdown delta emitted with no active turn', async () => { +- // A delta should only arrive while a turn is active. With none, we +- // can't persist the part id (so every delta would allocate a fresh +- // part) and the action would carry an empty turnId — drop it and log. +- const logService = new CapturingLogService(); +- const { mockSession, signals } = await createAgentSession(disposables, { logService }); +- +- // No resetTurnState → no active turn. +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'orphan text', +- } as SessionEventPayload<'assistant.message_delta'>['data']); +- +- const parts = getActions(signals).filter(a => a.type === ActionType.ChatResponsePart || a.type === ActionType.ChatDelta); +- assert.strictEqual(parts.length, 0, 'no response part/delta should be emitted without an active turn'); +- assert.strictEqual(logService.errors.length, 1, 'should log an error'); +- assert.match(String(logService.errors[0].first), /no active turn/i); +- }); +- +- test('abort-induced idle does not complete a pending queued turn', async () => { +- // Repro for the blank-response-after-abort race: a running turn is +- // aborted while a queued message exists. The queued message's +- // `send()` creates a fresh (pending) turn before the abort's +- // terminal `session.idle` is delivered. That idle must not complete +- // the queued turn — structurally, because it has not started running +- // yet, and because the idle carries `aborted: true`. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- // The queued message has started its (pending) turn by the time the +- // abort's terminal idle arrives — no SDK event has run it yet. +- session.resetTurnState('turn-queued'); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- +- assert.strictEqual( +- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, +- 0, +- 'abort-induced idle must not complete the pending queued turn', +- ); +- +- // The queued turn now actually runs (first SDK event) and then +- // completes on its own (non-abort) idle. +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); +- assert.strictEqual(completions.length, 1, 'the queued turn should complete on its real idle'); +- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-queued'); +- }); +- +- test('abort-induced idle tears down a running turn without completing it', async () => { +- // Plain abort (no queued message): the running turn is finalized by +- // the client-dispatched ChatTurnCancelled, so the abort's idle must +- // not also emit a ChatTurnComplete. The turn handle is dropped so a +- // later stray idle cannot complete it. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-aborted'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- +- assert.strictEqual( +- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, +- 0, +- 'abort-induced idle must not complete the running aborted turn', +- ); +- +- // A subsequent stray idle has no turn to act on. +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); +- }); +- +- test('a running turn after a prior abort still completes on its idle', async () => { +- // No lingering state across turns: the next turn completes normally. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-aborted'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); +- +- session.resetTurnState('turn-next'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-1' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); +- assert.strictEqual(completions.length, 1); +- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-next'); +- }); +- - test('error event is forwarded', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('session.error', { @@ -11218,16 +14891,17 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'session.error'>['data']); - - assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.SessionError)); -- if (isAction(signals[0], ActionType.SessionError)) { -- const action = signals[0].action as SessionErrorAction; +- assert.ok(isAction(signals[0], ActionType.ChatError)); +- if (isAction(signals[0], ActionType.ChatError)) { +- const action = signals[0].action as ChatErrorAction; - assert.strictEqual(action.error.errorType, 'TestError'); - assert.strictEqual(action.error.message, 'something went wrong'); - } - }); - - test('message delta is forwarded', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); - mockSession.fire('assistant.message_delta', { - messageId: 'msg-1', - deltaContent: 'Hello ', @@ -11236,12 +14910,12 @@ index 3ba908cae5d..00000000000 - assert.ok(signals.length >= 1); - const hasDelta = signals.some(s => { - if (s.kind !== 'action') { return false; } -- if (s.action.type === ActionType.SessionResponsePart) { -- const part = (s.action as SessionResponsePartAction).part; +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown && part.content === 'Hello '; - } -- if (s.action.type === ActionType.SessionDelta) { -- return (s.action as SessionDeltaAction).content === 'Hello '; +- if (s.action.type === ActionType.ChatDelta) { +- return (s.action as ChatDeltaAction).content === 'Hello '; - } - return false; - }); @@ -11268,12 +14942,12 @@ index 3ba908cae5d..00000000000 - assert.ok(signals.length >= 1); - const hasPart = signals.some(s => { - if (s.kind !== 'action') { return false; } -- if (s.action.type === ActionType.SessionResponsePart) { -- const part = (s.action as SessionResponsePartAction).part; +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown && part.content === 'Let me help you.'; - } -- if (s.action.type === ActionType.SessionDelta) { -- return (s.action as SessionDeltaAction).content === 'Let me help you.'; +- if (s.action.type === ActionType.ChatDelta) { +- return (s.action as ChatDeltaAction).content === 'Let me help you.'; - } - return false; - }); @@ -11455,10 +15129,10 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'assistant.message'>['data']); - - const markdownParts = signals.flatMap(signal => { -- if (signal.kind !== 'action' || signal.action.type !== ActionType.SessionResponsePart) { +- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { - return []; - } -- const part = (signal.action as SessionResponsePartAction).part; +- const part = (signal.action as ChatResponsePartAction).part; - if (part.kind !== ResponsePartKind.Markdown) { - return []; - } @@ -11472,7 +15146,8 @@ index 3ba908cae5d..00000000000 - }); - - test('reasoning delta after tool_start starts a new reasoning response part', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); - - // First reasoning delta — allocates a fresh reasoning response part. - mockSession.fire('assistant.reasoning_delta', { @@ -11500,12 +15175,12 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - - // Pull the protocol-level reasoning response parts. Both -- // `SessionResponsePart{Reasoning}` (allocates a new part) and -- // `SessionReasoning` (appends to an existing part) translate to +- // `ChatResponsePart{Reasoning}` (allocates a new part) and +- // `ChatReasoning` (appends to an existing part) translate to - // the legacy `'reasoning'` view, so we have to inspect raw - // signals to tell them apart. - const reasoningResponseParts = signals.flatMap(s => { -- if (s.kind !== 'action' || s.action.type !== ActionType.SessionResponsePart) { +- if (s.kind !== 'action' || s.action.type !== ActionType.ChatResponsePart) { - return []; - } - return s.action.part.kind === ResponsePartKind.Reasoning ? [s.action.part] : []; @@ -11540,10 +15215,10 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - - const reasoningParts = signals.flatMap(signal => { -- if (signal.kind !== 'action' || signal.action.type !== ActionType.SessionResponsePart) { +- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { - return []; - } -- const part = (signal.action as SessionResponsePartAction).part; +- const part = (signal.action as ChatResponsePartAction).part; - if (part.kind !== ResponsePartKind.Reasoning) { - return []; - } @@ -11580,10 +15255,10 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'tool.execution_complete'>['data'], { agentId: 'agent-1' }); - - const toolCompletions = signals.flatMap(signal => { -- if (!isAction(signal, ActionType.SessionToolCallComplete)) { +- if (!isAction(signal, ActionType.ChatToolCallComplete)) { - return []; - } -- const action = signal.action as SessionToolCallCompleteAction; +- const action = signal.action as ChatToolCallCompleteAction; - return [{ parentToolCallId: signal.parentToolCallId, toolCallId: action.toolCallId }]; - }); - @@ -11656,10 +15331,10 @@ index 3ba908cae5d..00000000000 - const questionId = request.questions[0].id; - - // Respond to unblock the promise -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' } +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Text, value: 'Alice' } - } - }); - @@ -11680,18 +15355,18 @@ index 3ba908cae5d..00000000000 - const request = getInputRequest(signals[0]); - assert.ok(request.questions); - assert.strictEqual(request.questions.length, 1); -- assert.strictEqual(request.questions[0].kind, SessionInputQuestionKind.SingleSelect); -- if (request.questions[0].kind === SessionInputQuestionKind.SingleSelect) { +- assert.strictEqual(request.questions[0].kind, ChatInputQuestionKind.SingleSelect); +- if (request.questions[0].kind === ChatInputQuestionKind.SingleSelect) { - assert.strictEqual(request.questions[0].options.length, 3); - assert.strictEqual(request.questions[0].options[0].label, 'red'); - } - - // Respond with a selected choice - const questions = request.questions; -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { - [questions[0].id]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'blue' } +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'blue' } - } - }); - @@ -11709,7 +15384,7 @@ index 3ba908cae5d..00000000000 - ); - - const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Cancel); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); - - const result = await resultPromise; - assert.strictEqual(result.answer, ''); @@ -11718,7 +15393,7 @@ index 3ba908cae5d..00000000000 - - test('respondToUserInputRequest returns false for unknown id', async () => { - const { session } = await createAgentSession(disposables); -- assert.strictEqual(session.respondToUserInputRequest('unknown-id', SessionInputResponseKind.Accept), false); +- assert.strictEqual(session.respondToUserInputRequest('unknown-id', ChatInputResponseKind.Accept), false); - }); - - test('handleUserInputRequest returns empty answer on skipped question', async () => { @@ -11731,9 +15406,9 @@ index 3ba908cae5d..00000000000 - - const request = getInputRequest(signals[0]); - const questionId = request.questions![0].id; -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Skipped, +- state: ChatInputAnswerState.Skipped, - } - }); - @@ -11758,7 +15433,7 @@ index 3ba908cae5d..00000000000 - - test('autopilot auto-answers a free-form question without firing a progress event', async () => { - const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, - }); - - const result = await runtime.handleUserInputRequest( @@ -11774,11 +15449,11 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(signals.length, 0); - }); - -- test('autopilot does not auto-answer when autoApprove is not "autopilot"', async () => { -- // Sanity check: with autoApprove=default the question must +- test('autopilot does not auto-answer when mode is not "autopilot"', async () => { +- // Sanity check: with mode=interactive the question must - // still be surfaced as a progress event (the existing behavior). - const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.AutoApprove]: 'default' }, +- configValues: { [SessionConfigKey.Mode]: 'interactive' }, - }); - - runtime.handleUserInputRequest( @@ -11790,7 +15465,7 @@ index 3ba908cae5d..00000000000 - // short-circuit or emit a progress event. - await Promise.resolve(); - assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.SessionInputRequested)); +- assert.ok(isAction(signals[0], ActionType.ChatInputRequested)); - }); - }); - @@ -11823,27 +15498,27 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(request.message, 'Configure deployment'); - assert.ok(request.questions); - assert.deepStrictEqual(request.questions.map(q => ({ id: q.id, kind: q.kind, required: q.required })), [ -- { id: 'environment', kind: SessionInputQuestionKind.SingleSelect, required: true }, -- { id: 'replicas', kind: SessionInputQuestionKind.Integer, required: false }, -- { id: 'confirm', kind: SessionInputQuestionKind.Boolean, required: true }, -- { id: 'region', kind: SessionInputQuestionKind.Text, required: false }, -- { id: 'tags', kind: SessionInputQuestionKind.MultiSelect, required: false }, +- { id: 'environment', kind: ChatInputQuestionKind.SingleSelect, required: true }, +- { id: 'replicas', kind: ChatInputQuestionKind.Integer, required: false }, +- { id: 'confirm', kind: ChatInputQuestionKind.Boolean, required: true }, +- { id: 'region', kind: ChatInputQuestionKind.Text, required: false }, +- { id: 'tags', kind: ChatInputQuestionKind.MultiSelect, required: false }, - ]); - const envQuestion = request.questions[0]; -- assert.strictEqual(envQuestion.kind, SessionInputQuestionKind.SingleSelect); -- if (envQuestion.kind === SessionInputQuestionKind.SingleSelect) { +- assert.strictEqual(envQuestion.kind, ChatInputQuestionKind.SingleSelect); +- if (envQuestion.kind === ChatInputQuestionKind.SingleSelect) { - assert.deepStrictEqual(envQuestion.options, [ - { id: 'dev', label: 'Development' }, - { id: 'prod', label: 'Production' }, - ]); - } - -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { -- environment: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Selected, value: 'prod' } }, -- replicas: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Number, value: 5 } }, -- confirm: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Boolean, value: true } }, -- region: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Text, value: 'eu-west-1' } }, -- tags: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.SelectedMany, value: ['a', 'c'] } }, +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- environment: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Selected, value: 'prod' } }, +- replicas: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Number, value: 5 } }, +- confirm: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Boolean, value: true } }, +- region: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'eu-west-1' } }, +- tags: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.SelectedMany, value: ['a', 'c'] } }, - }); - - assert.deepStrictEqual(await resultPromise, { @@ -11875,8 +15550,8 @@ index 3ba908cae5d..00000000000 - }); - - const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { -- name: { state: SessionInputAnswerState.Skipped }, +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- name: { state: ChatInputAnswerState.Skipped }, - // `count` is missing entirely - }); - @@ -11897,7 +15572,7 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(request.url, 'https://example.com/auth'); - assert.strictEqual(request.questions, undefined); - -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept); - assert.deepStrictEqual(await resultPromise, { action: 'accept' }); - }); - @@ -11914,8 +15589,8 @@ index 3ba908cae5d..00000000000 - const request = getInputRequest(signals[0]); - assert.strictEqual(request.questions, undefined); - -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { -- answer: { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Text, value: 'teal' } }, +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- answer: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'teal' } }, - }); - - assert.deepStrictEqual(await resultPromise, { action: 'accept', content: { answer: 'teal' } }); @@ -11932,7 +15607,7 @@ index 3ba908cae5d..00000000000 - }); - - const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Decline); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); - assert.deepStrictEqual(await resultPromise, { action: 'decline' }); - }); - @@ -11947,13 +15622,13 @@ index 3ba908cae5d..00000000000 - }); - - const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Cancel); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); - assert.deepStrictEqual(await resultPromise, { action: 'cancel' }); - }); - - test('autopilot auto-cancels without firing a progress event', async () => { - const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, - }); - - const result = await runtime.handleElicitationRequest({ @@ -12068,17 +15743,55 @@ index 3ba908cae5d..00000000000 - suite('client tool calls', () => { - - const snapshot: IActiveClientSnapshot = { -- clientId: 'test-client', - tools: [{ - name: 'my_tool', - description: 'A test tool', - inputSchema: { type: 'object', properties: {} }, - }], - plugins: [], +- mcpServers: {}, +- }; +- +- /** Builds a live ActiveClientState seeded with the given owning clientId and the snapshot's tools. */ +- const activeClientStateWith = (clientId: string): ActiveClientState => { +- const state = new ActiveClientState(); +- state.update(clientId, snapshot.tools); +- return state; - }; - +- test('client tool started with no connected client fails immediately', async () => { +- // No activeClientState is provided, so the session seeds one with +- // an undefined clientId — i.e. no client is connected to run the tool. +- const { runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-no-client', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- // tool_start is stamped as a client contributor with no owner... +- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); +- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, undefined); +- +- // ...and is failed immediately (ready + complete) rather than left +- // pending for the server-side disconnect timeout. +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallReady)).length, 1); +- const completeSignal = signals.find(s => isAction(s, ActionType.ChatToolCallComplete)); +- assert.ok(completeSignal && isAction(completeSignal, ActionType.ChatToolCallComplete)); +- assert.strictEqual((completeSignal.action as ChatToolCallCompleteAction).result.success, false); +- +- // When the SDK invokes the handler it resolves immediately with the +- // buffered failure result. +- const tools = runtime.createClientSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-no-client'); +- assert.strictEqual(result.resultType, 'failure'); +- }); +- - test('client tool handler waits for completion without emitting tool_ready', async () => { -- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); - - // SDK emits tool.execution_start — tool_start fires immediately - mockSession.fire('tool.execution_start', { @@ -12088,11 +15801,11 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start fires immediately (client tools don't auto-ready) -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); -- const startSignal = signals.find(s => isAction(s, ActionType.SessionToolCallStart)); -- assert.ok(startSignal && isAction(startSignal, ActionType.SessionToolCallStart)); -- if (isAction(startSignal!, ActionType.SessionToolCallStart)) { -- assert.deepStrictEqual((startSignal.action as SessionToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); +- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); +- if (isAction(startSignal!, ActionType.ChatToolCallStart)) { +- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); - } - - // SDK invokes the handler — it creates a deferred and waits, @@ -12101,7 +15814,7 @@ index 3ba908cae5d..00000000000 - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); - - // No pending_confirmation or tool_ready should have been emitted by the handler -- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation' || isAction(s, ActionType.SessionToolCallReady)).length, 0); +- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation' || isAction(s, ActionType.ChatToolCallReady)).length, 0); - - // Complete the tool call - session.handleClientToolCallComplete('tc-client-1', { @@ -12116,7 +15829,9 @@ index 3ba908cae5d..00000000000 - }); - - test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- const activeClientState = new ActiveClientState(); +- activeClientState.update('client-perm', snapshot.tools); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); - - // SDK emits tool.execution_start — tool_start fires immediately - mockSession.fire('tool.execution_start', { @@ -12126,7 +15841,7 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start fired, no pending_confirmation yet -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); - assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 0); - - // Permission request fires — pending_confirmation from permission flow. @@ -12158,7 +15873,11 @@ index 3ba908cae5d..00000000000 - success: true, - pastTenseMessage: 'did it', - }); -- await handlerPromise; +- assert.deepStrictEqual(await handlerPromise, { +- textResultForLlm: '', +- resultType: 'success', +- binaryResultsForLlm: undefined, +- }); - }); - - test('pending_confirmation forwards parentToolCallId for tools inside subagents', async () => { @@ -12166,10 +15885,10 @@ index 3ba908cae5d..00000000000 - // permission-flow `pending_confirmation` must carry the - // parentToolCallId from the originating tool_start. Without it - // the host has no way to route the resulting -- // SessionToolCallReady to the subagent session and emits a +- // ChatToolCallReady to the subagent session and emits a - // stray ready against the parent session (no preceding -- // SessionToolCallStart). -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- // ChatToolCallStart). +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); - - mockSession.fire('subagent.started', { - toolCallId: 'tc-parent-subagent', @@ -12294,7 +16013,7 @@ index 3ba908cae5d..00000000000 - const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot, logService }); - const tools = runtime.createClientSdkTools(); - const sessionInternals = session as unknown as ISessionInternalsForTest; -- sessionInternals._pendingClientToolCalls.get = () => { +- sessionInternals._pendingClientToolCalls.register = () => { - throw new Error('client tool boom'); - }; - @@ -12320,7 +16039,7 @@ index 3ba908cae5d..00000000000 - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start should have fired -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); - - // Permission before the handler should produce only the confirmation - // pending_confirmation, not a synthetic auto-ready. @@ -12359,6 +16078,199 @@ index 3ba908cae5d..00000000000 - // Text content should be extracted - assert.strictEqual(result.textResultForLlm, 'text part'); - }); +- +- test('handleClientToolCallComplete describes embedded-resource-only content', async () => { +- const testCases = [ +- { +- toolCallId: 'tc-image-only', +- contentType: 'image/png', +- expectedText: 'Tool produced the attached image', +- expectedType: 'image', +- }, +- { +- toolCallId: 'tc-file-only', +- contentType: 'application/pdf', +- expectedText: 'Tool produced the attached file', +- expectedType: 'resource', +- }, +- { +- toolCallId: 'tc-image-and-file', +- contentType: 'image/png', +- additionalContentType: 'application/pdf', +- expectedText: 'Tool produced the attached image and file', +- expectedType: 'image', +- }, +- ] satisfies ReadonlyArray<{ +- readonly toolCallId: string; +- readonly contentType: string; +- readonly additionalContentType?: string; +- readonly expectedText: string; +- readonly expectedType: 'image' | 'resource'; +- }>; +- const embeddedResource = (data: string, contentType: string): ToolResultContent => ({ type: ToolResultContentType.EmbeddedResource, data, contentType }); +- +- for (const testCase of testCases) { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], testCase.toolCallId); +- const content: ToolResultContent[] = [ +- embeddedResource('base64data', testCase.contentType), +- ...(testCase.additionalContentType ? [embeddedResource('base64data2', testCase.additionalContentType)] : []), +- ]; +- +- session.handleClientToolCallComplete(testCase.toolCallId, { +- success: true, +- pastTenseMessage: 'done', +- content, +- }); +- +- assert.deepStrictEqual(await handlerPromise, { +- textResultForLlm: testCase.expectedText, +- resultType: 'success', +- binaryResultsForLlm: [ +- { data: 'base64data', mimeType: testCase.contentType, type: testCase.expectedType }, +- ...(testCase.additionalContentType ? [{ data: 'base64data2', mimeType: testCase.additionalContentType, type: 'resource' }] : []), +- ], +- }); +- disposables.clear(); +- } +- }); +- +- test('client tool start stamps the LIVE clientId from the shared ActiveClientState', async () => { +- const activeClientState = new ActiveClientState(); +- activeClientState.update('client-A', snapshot.tools); +- const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); +- +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-live-1', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- // A window reload re-pushes the same tools under a new clientId. +- activeClientState.update('client-B', snapshot.tools); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-live-2', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const starts = signals.filter((s): s is IAgentActionSignal => isAction(s, ActionType.ChatToolCallStart)); +- assert.deepStrictEqual(starts.map(s => (s.action as ChatToolCallStartAction).contributor), [ +- { kind: ToolCallContributorKind.Client, clientId: 'client-A' }, +- { kind: ToolCallContributorKind.Client, clientId: 'client-B' }, +- ]); +- }); +- +- test('completion arriving before the SDK handler registers still resolves', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- const tools = runtime.createClientSdkTools(); +- +- // Completion races ahead of the handler. +- session.handleClientToolCallComplete('tc-early', { +- success: true, +- pastTenseMessage: 'done', +- content: [{ type: ToolResultContentType.Text, text: 'buffered result' }], +- }); +- +- const result = await invokeClientToolHandler(tools[0], 'tc-early'); +- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(result.textResultForLlm, 'buffered result'); +- }); +- }); +- +- // ---- Server tools ------------------------------------------------------- +- +- suite('server tools', () => { +- +- const fakeToolDefinitions: readonly ToolDefinition[] = [ +- { name: 'serverToolA', description: 'A', inputSchema: { type: 'object', properties: {} } }, +- { name: 'serverToolB', description: 'B', inputSchema: { type: 'object', properties: {} } }, +- ]; +- +- class FakeServerToolHost implements IAgentServerToolHost { +- readonly definitions: readonly ToolDefinition[] = fakeToolDefinitions; +- readonly toolNames: readonly string[] = fakeToolDefinitions.map(def => def.name); +- readonly advertised: string[] = []; +- readonly executions: Array<{ sessionUri: string; toolName: string; rawArgs: unknown }> = []; +- result = 'ok'; +- error: Error | undefined; +- +- advertise(sessionUri: string): void { +- this.advertised.push(sessionUri); +- } +- +- requiresConfirmation(_toolName: string): boolean { return false; } +- +- executeTool(sessionUri: string, toolName: string, rawArgs: unknown): string { +- this.executions.push({ sessionUri, toolName, rawArgs }); +- if (this.error) { +- throw this.error; +- } +- return this.result; +- } +- } +- +- test('advertises the server tools on initialize and exposes them as server SDK tools', async () => { +- const serverToolHost = new FakeServerToolHost(); +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- +- const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); +- assert.deepStrictEqual(serverToolHost.advertised, [sessionUri]); +- +- const tools = runtime.createServerSdkTools(); +- assert.deepStrictEqual(tools.map(t => t.name).sort(), [...serverToolHost.toolNames].sort()); +- }); +- +- test('server tool handler routes to the host and returns a success result', async () => { +- const serverToolHost = new FakeServerToolHost(); +- serverToolHost.result = 'listed 2 comments'; +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- +- const tools = runtime.createServerSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool', { foo: 'bar' }); +- +- const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); +- assert.deepStrictEqual(serverToolHost.executions, [{ sessionUri, toolName: tools[0].name, rawArgs: { foo: 'bar' } }]); +- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(result.textResultForLlm, 'listed 2 comments'); +- }); +- +- test('server tool handler surfaces host failures as a failure result', async () => { +- const serverToolHost = new FakeServerToolHost(); +- serverToolHost.error = new Error('boom'); +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- +- const tools = runtime.createServerSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool'); +- +- assert.strictEqual(result.resultType, 'failure'); +- assert.strictEqual(result.textResultForLlm, 'boom'); +- assert.strictEqual(result.error, 'boom'); +- }); +- +- test('exposes no server SDK tools and advertises nothing when no host is wired', async () => { +- const { runtime } = await createAgentSession(disposables); +- assert.deepStrictEqual(runtime.createServerSdkTools(), []); +- }); +- +- test('auto-approves every server tool without prompting for confirmation', async () => { +- const serverToolHost = new FakeServerToolHost(); +- const { runtime, signals } = await createAgentSession(disposables, { serverToolHost }); +- +- const results = []; +- for (const toolName of serverToolHost.toolNames) { +- results.push(await runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: `tc-${toolName}`, toolName })); +- } +- +- assert.deepStrictEqual({ +- results, +- pendingConfirmations: signals.filter(s => s.kind === 'pending_confirmation').length, +- }, { +- results: serverToolHost.toolNames.map(() => ({ kind: 'approve-once' })), +- pendingConfirmations: 0, +- }); +- }); - }); - - // ---- Plan mode ---------------------------------------------------------- @@ -12399,12 +16311,13 @@ index 3ba908cae5d..00000000000 - - test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { - const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - - mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); - -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - - // The plan summary and "View full plan" link are emitted as a @@ -12412,12 +16325,12 @@ index 3ba908cae5d..00000000000 - // client renders them inline above the question. - const deltaContent = signals.flatMap(s => { - if (s.kind !== 'action') { return []; } -- if (s.action.type === ActionType.SessionResponsePart) { -- const part = (s.action as SessionResponsePartAction).part; +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown ? [part.content] : []; - } -- if (s.action.type === ActionType.SessionDelta) { -- return [(s.action as SessionDeltaAction).content]; +- if (s.action.type === ActionType.ChatDelta) { +- return [(s.action as ChatDeltaAction).content]; - } - return []; - }).join(''); @@ -12425,8 +16338,8 @@ index 3ba908cae5d..00000000000 - 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.strictEqual(question?.kind, ChatInputQuestionKind.SingleSelect); +- if (question?.kind === ChatInputQuestionKind.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'); @@ -12434,55 +16347,62 @@ index 3ba908cae5d..00000000000 - } - - // Resolve the request so the deferred completes and the test can clean up. -- session.respondToUserInputRequest(request.id, SessionInputResponseKind.Decline); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); - await responsePromise; - }); - -- test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits and syncs mode=autopilot', async () => { +- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); +- // Picking "Implement with Autopilot" flips the AHP mode immediately. +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, +- ]); - }); - -- test('completing the input request with interactive resolves with approved + interactive (no autoApprove)', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- test('completing the input request with interactive resolves with approved + interactive (no autoApprove) and syncs mode=interactive', async () => { +- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, +- ]); - }); - - test('declining the input request resolves with approved=false', async () => { - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - -- session.respondToUserInputRequest(getInputRequest(signal).id, SessionInputResponseKind.Decline); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); - - assert.deepStrictEqual(await responsePromise, { approved: false }); - }); @@ -12491,15 +16411,15 @@ index 3ba908cae5d..00000000000 - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'exit_only' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'exit_only' }, - }, - }); - @@ -12510,16 +16430,16 @@ index 3ba908cae5d..00000000000 - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, +- state: ChatInputAnswerState.Submitted, - value: { -- kind: SessionInputAnswerValueKind.Selected, +- kind: ChatInputAnswerValueKind.Selected, - value: 'interactive', - freeformValues: ['Please use Python instead of Node.js'], - }, @@ -12537,7 +16457,7 @@ index 3ba908cae5d..00000000000 - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; @@ -12546,10 +16466,10 @@ index 3ba908cae5d..00000000000 - // 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, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); - @@ -12563,15 +16483,15 @@ index 3ba908cae5d..00000000000 - // the offered set. The client picked something invalid. With - // no usable selectedAction and no feedback, decline. - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, - }, - }); - @@ -12582,7 +16502,7 @@ index 3ba908cae5d..00000000000 - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; @@ -12591,10 +16511,10 @@ index 3ba908cae5d..00000000000 - // 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, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, -- value: { kind: SessionInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, - }, - }); - @@ -12609,16 +16529,16 @@ index 3ba908cae5d..00000000000 - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - -- session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { - [questionId]: { -- state: SessionInputAnswerState.Submitted, +- state: ChatInputAnswerState.Submitted, - value: { -- kind: SessionInputAnswerValueKind.Selected, +- kind: ChatInputAnswerValueKind.Selected, - value: 'interactive', - freeformValues: [' ', ''], - }, @@ -12648,16 +16568,16 @@ index 3ba908cae5d..00000000000 - ]); - }); - -- 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. +- test('session.mode_changed → autopilot maps directly to mode=autopilot', async () => { +- // The SDK and AHP share the same three-mode space; autopilot now +- // lives on the `mode` axis and the `autoApprove` axis is left +- // untouched. 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' } }, +- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, - ]); - }); - @@ -12669,37 +16589,23 @@ index 3ba908cae5d..00000000000 - assert.strictEqual(sessionConfigUpdates.length, 0); - }); - -- // ---- autopilot fast-path ------------------------------------------- +- // ---- no automatic plan → implementation handoff ------------------- - -- test('handleExitPlanModeRequest auto-accepts when autoApprove=autopilot (recommended action)', async () => { -- const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, +- test('handleExitPlanModeRequest always surfaces the plan-review UI, even in autopilot mode', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, - }); - -- const response = await runtime.handleExitPlanModeRequest(planRequestParams({ +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ - actions: ['autopilot', 'interactive', 'exit_only'], - recommendedAction: 'autopilot', - }), { sessionId: 'test-session-1' }); - -- assert.deepStrictEqual(response, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); -- // User-input request should NOT be surfaced to the client. -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionInputRequested)).length, 0); -- }); -- -- test('handleExitPlanModeRequest auto-accepts with priority order when no recommended action available', async () => { -- const { runtime } = 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 runtime.handleExitPlanModeRequest(planRequestParams({ -- actions: ['interactive', 'exit_only'], -- recommendedAction: 'autopilot_fleet', -- }), { sessionId: 'test-session-1' }); -- -- assert.deepStrictEqual(response, { approved: true, selectedAction: 'interactive' }); +- // There is no automatic handoff from plan into implementation: the +- // user must explicitly choose an action regardless of mode. +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); +- await responsePromise; - }); - - test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => { @@ -12710,18 +16616,64 @@ index 3ba908cae5d..00000000000 - const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); - - // The user-input request fires — the user must respond. -- const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); -- session.respondToUserInputRequest(getInputRequest(signal).id, SessionInputResponseKind.Decline); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); - await responsePromise; - }); - }); +- +- suite('MCP server inventory', () => { +- +- test('seeds inventory from rpc.mcp.list at subscription time', async () => { +- const { signals, waitForSignal } = await createAgentSession(disposables, { +- configureMockSession: m => { +- m.mcpListResult = { +- servers: [ +- { name: 'alpha', status: 'connected' }, +- { name: 'beta', status: 'pending' }, +- ], +- }; +- }, +- }); +- +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); +- // Give the seed's microtask chain time to apply both servers. +- await timeout(0); +- +- const updates = getActions(signals).filter(a => a.type === ActionType.SessionCustomizationUpdated); +- const names = updates.map(a => (a as { customization: { name: string } }).customization.name).sort(); +- assert.deepStrictEqual(names, ['alpha', 'beta']); +- }); +- +- test('logs a warning and continues when rpc.mcp.list rejects', async () => { +- const logService = new CapturingLogService(); +- const { mockSession, waitForSignal } = await createAgentSession(disposables, { +- logService, +- configureMockSession: m => { m.mcpListError = new Error('boom'); }, +- }); +- // Allow the rejected promise to surface. +- await timeout(0); +- await timeout(0); +- +- assert.ok( +- logService.warnings.some(w => w.message.includes('Failed to seed MCP server inventory')), +- `expected seed-failure warning, got: ${JSON.stringify(logService.warnings)}`, +- ); +- +- // Subsequent live events still flow through the normal pipeline. +- mockSession.fire('session.mcp_servers_loaded', { +- servers: [{ name: 'late', status: 'connected' }], +- } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); +- }); +- }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts deleted file mode 100644 -index 6c51573d1ff..00000000000 +index 21baa7f1..00000000 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ /dev/null -@@ -1,448 +0,0 @@ +@@ -1,511 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -12739,7 +16691,7 @@ index 6c51573d1ff..00000000000 -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; --import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; +-import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin, IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; -import { CustomizationType, McpServerStatus, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; - @@ -12949,6 +16901,69 @@ index 6c51573d1ff..00000000000 - prompt: 'Body only.', - }]); - }); +- +- test('trims whitespace from frontmatter name to match parsed agent name', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/padded.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString( +- `---\nname: " Inbox "\n---\nBody.` +- )); +- +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'padded' }]; +- const result = await toSdkCustomAgents(agents, fileService); +- +- assert.strictEqual(result[0].name, 'Inbox'); +- }); +- }); +- +- // ---- toSdkSessionCustomAgents --------------------------------------- +- +- suite('toSdkSessionCustomAgents', () => { +- +- test('includes agents from plugins without a file directory', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); +- +- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; +- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); +- +- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); +- }); +- +- test('excludes file-dir plugin agents when none is selected', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); +- +- const plugins: IPluginAgentsForSdk[] = [{ +- pluginDir: URI.file('/plugins/inbox'), +- agents: [{ uri: agentUri, name: 'Inbox' }], +- }]; +- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); +- +- assert.deepStrictEqual(result, []); +- }); +- +- test('forces the selected file-dir plugin agent into customAgents', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); +- +- const plugins: IPluginAgentsForSdk[] = [{ +- pluginDir: URI.file('/plugins/inbox'), +- agents: [{ uri: agentUri, name: 'Inbox' }], +- }]; +- const result = await toSdkSessionCustomAgents(plugins, 'Inbox', fileService); +- +- assert.deepStrictEqual(result, [{ name: 'Inbox', tools: null, prompt: 'Inbox agent' }]); +- }); +- +- test('does not duplicate the selected agent when already present', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); +- +- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; +- const result = await toSdkSessionCustomAgents(plugins, 'helper', fileService); +- +- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); +- }); - }); - - // ---- toSdkSkillDirectories ------------------------------------------ @@ -13172,10 +17187,10 @@ index 6c51573d1ff..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts deleted file mode 100644 -index 93f0cfbe1e4..00000000000 +index 5f6c455d..00000000 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ /dev/null -@@ -1,978 +0,0 @@ +@@ -1,944 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -13300,6 +17315,7 @@ index 93f0cfbe1e4..00000000000 - getRootValue: ((_schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getRootValue'], - updateRootConfig: () => { /* no-op */ }, - persistRootConfig: () => { /* no-op */ }, +- whenIdle: async () => { /* no-op */ }, - }; - return { - service, @@ -14114,52 +18130,17 @@ index 93f0cfbe1e4..00000000000 - assert.strictEqual(result.resultType, 'failure'); - assert.strictEqual(result.error, 'unsandboxed_disabled'); - assert.match(result.textResultForLlm ?? '', /allowUnsandboxedCommands/); -- assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested'); -- assert.strictEqual(terminalManager.sentTexts.length, 0, 'Disallowed command should not be sent to the terminal'); -- }); -- -- test('primary shell tool skips confirmation when autoApproveUnsandboxedCommands is enabled', async function () { -- const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); -- agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); -- agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AutoApproveUnsandboxedCommands, true); -- terminalManager.commandDetectionSupported = true; -- const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); -- const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; -- const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { -- confirmationRequests.push(request); -- return true; -- }); -- const bashTool = tools.find(tool => tool.name === 'bash'); -- assert.ok(bashTool); -- -- const invocation: ToolInvocation = { -- sessionId: 'session-1', -- toolCallId: 'tool-1', -- toolName: 'bash', -- arguments: { command: 'curl https://example.com' }, -- }; -- const resultPromise = bashTool.handler!({ command: 'curl https://example.com' }, invocation); -- await terminalManager.commandFinishedListenerRegistered.p; -- terminalManager.fireCommandFinished({ -- commandId: 'cmd-1', -- exitCode: 0, -- command: 'curl https://example.com', -- output: '', -- }); -- const result = await resultPromise as ToolResultObject; -- -- assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested when auto-approve is enabled'); -- assert.ok(terminalManager.sentTexts.length >= 1, 'Auto-approved command should be sent to the terminal unsandboxed'); -- assert.ok(terminalManager.sentTexts.every(entry => !entry.data.includes('sandbox-runtime')), 'Auto-approved command should run unsandboxed'); -- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested'); +- assert.strictEqual(terminalManager.sentTexts.length, 0, 'Disallowed command should not be sent to the terminal'); - }); +- -}); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts deleted file mode 100644 -index 7578ef4d294..00000000000 +index 70d60420..00000000 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ /dev/null -@@ -1,198 +0,0 @@ +@@ -1,295 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -14197,14 +18178,50 @@ index 7578ef4d294..00000000000 - assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck'), { command: 'rubber-duck', rest: '' }); - }); - +- test('matches lone /env', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/env'), { command: 'env', rest: '' }); +- }); +- +- test('matches lone /review', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/review'), { command: 'review', rest: '' }); +- }); +- +- test('matches lone /security-review', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review'), { command: 'security-review', rest: '' }); +- }); +- - test('captures trailing text after a space for /rubber-duck', () => { - assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck review my approach'), { command: 'rubber-duck', rest: 'review my approach' }); - }); - +- test('captures trailing text after a space for /env', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/env ignored input'), { command: 'env', rest: 'ignored input' }); +- }); +- +- test('captures trailing text after a space for /review', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/review focus on tests'), { command: 'review', rest: 'focus on tests' }); +- }); +- +- test('captures trailing text after a space for /security-review', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review focus on auth'), { command: 'security-review', rest: 'focus on auth' }); +- }); +- - test('rejects /rubber-duck-extra (no separator)', () => { - assert.strictEqual(parseLeadingSlashCommand('/rubber-duck-extra'), undefined); - }); - +- test('rejects /env-extra (no separator)', () => { +- assert.strictEqual(parseLeadingSlashCommand('/env-extra'), undefined); +- }); +- +- test('rejects /review-extra (no separator)', () => { +- assert.strictEqual(parseLeadingSlashCommand('/review-extra'), undefined); +- }); +- +- test('rejects /security-review-extra (no separator)', () => { +- assert.strictEqual(parseLeadingSlashCommand('/security-review-extra'), undefined); +- }); +- - test('rejects /rubber alone (incomplete command)', () => { - assert.strictEqual(parseLeadingSlashCommand('/rubber'), undefined); - }); @@ -14235,7 +18252,7 @@ index 7578ef4d294..00000000000 - }); - - suite('provideCompletionItems', () => { -- const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true }); +- const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); - const session = 'copilotcli:/abc'; - - async function run(text: string, offset = text.length) { @@ -14252,727 +18269,1254 @@ index 7578ef4d294..00000000000 - assert.deepStrictEqual(items, []); - }); - -- test('returns all items for lone "/"', async () => { -- const items = await run('/'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ']); +- test('returns all items for lone "/"', async () => { +- const items = await run('/'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- }); +- +- test('filters to /plan when "/p" typed', async () => { +- const items = await run('/p'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ']); +- }); +- +- test('filters to /compact when "/c" typed', async () => { +- const items = await run('/c'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/compact']); +- }); +- +- test('filters to /env when "/e" typed and runtime command exists', async () => { +- const items = await run('/e'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/env']); +- }); +- +- test('filters to /research and /rubber-duck when "/r" typed', async () => { +- const items = await run('/r'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/research ', '/rubber-duck ', '/review ']); +- }); +- +- test('filters to /security-review when "/s" typed', async () => { +- const items = await run('/s'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/security-review ']); +- }); +- +- test('returns nothing when /word does not match any command prefix', async () => { +- const items = await run('/zz'); +- assert.deepStrictEqual(items, []); +- }); +- +- test('returns nothing when input does not start with /', async () => { +- const items = await run('hello /pl', 9); +- assert.deepStrictEqual(items, []); +- }); +- +- test('returns nothing when cursor is past the leading word', async () => { +- // Cursor sits after the trailing space, no longer in the slash token. +- const items = await run('/plan ', 6); +- assert.deepStrictEqual(items, []); +- }); +- +- test('range covers only the leading slash word', async () => { +- const items = await run('/p extra text', 2); +- assert.strictEqual(items.length, 1); +- assert.strictEqual(items[0].rangeStart, 0); +- assert.strictEqual(items[0].rangeEnd, 2); +- }); +- +- test('attachment is Simple with command + description meta', async () => { +- const items = await run('/'); +- assert.deepStrictEqual(items.map(item => ({ type: item.attachment?.type, meta: item.attachment?._meta })), [ +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'plan', +- description: 'Create an implementation plan before coding', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'compact', +- description: 'Free up context by compacting the conversation history', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'research', +- description: 'Run deep research on a topic using search and web sources', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'rubber-duck', +- description: 'Get an independent critique of the current approach', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'env', +- description: 'Show loaded environment details', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'review', +- description: 'Run code review agent to analyze changes', +- }, +- }, +- { +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'security-review', +- description: 'Analyze staged and unstaged changes for security vulnerabilities', +- }, +- }, +- ]); +- }); +- +- test('omits /compact when session has no history', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, +- }, CancellationToken.None); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- }); +- +- test('omits /rubber-duck when not enabled', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => false, hasRuntimeSlashCommand: async () => true }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, +- }, CancellationToken.None); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/env', '/review ', '/security-review ']); +- }); +- +- test('omits /env when runtime command is unavailable', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command !== 'env' }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, +- }, CancellationToken.None); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/review ', '/security-review ']); +- }); +- +- test('keeps prompt-invoked commands when runtime commands are unavailable', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command === 'env' }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, +- }, CancellationToken.None); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- }); +- +- test('passes raw session id (no scheme/slash) to hasHistory', async () => { +- let seen: string | undefined; +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- hasHistory: (id: string) => { seen = id; return true; }, +- isRubberDuckEnabled: () => true, +- hasRuntimeSlashCommand: async () => true, +- }); +- await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/', offset: 1, +- }, CancellationToken.None); +- assert.strictEqual(seen, 'abc'); +- }); +- +- test('passes raw session id to runtime command availability', async () => { +- let seen: string | undefined; +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- hasHistory: () => true, +- isRubberDuckEnabled: () => true, +- hasRuntimeSlashCommand: async (id: string) => { seen = id; return true; }, +- }); +- await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/e', offset: 2, +- }, CancellationToken.None); +- assert.strictEqual(seen, 'abc'); +- }); +- }); +-}); +diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +index 4e64f874..f3065c70 100644 +--- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts ++++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +@@ -3,7 +3,13 @@ + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +-import type { Attachment, SessionEvent } from '@github/copilot-sdk'; ++// `Attachment`/`SessionEvent` originally came from '@github/copilot-sdk' ++// (removed in !!APP_NAME!!). This fixture only uses them as opaque types it ++// casts into, so alias them to `any` locally. ++/* eslint-disable @typescript-eslint/no-explicit-any */ ++type Attachment = any; ++type SessionEvent = any; ++/* eslint-enable @typescript-eslint/no-explicit-any */ + + // ============================================================================= + // Minimal session-event shapes for tests +diff --git a/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts b/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts +deleted file mode 100644 +index a9d124e4..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts ++++ /dev/null +@@ -1,77 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import assert from 'assert'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { isRestrictedTelemetryEnabled, parseCopilotTokenFields } from '../../node/copilot/copilotTokenFields.js'; +- +-suite('copilotTokenFields', () => { +- +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- suite('parseCopilotTokenFields', () => { +- test('returns empty map for undefined token', () => { +- assert.strictEqual(parseCopilotTokenFields(undefined).size, 0); +- }); +- +- test('returns empty map for empty token', () => { +- assert.strictEqual(parseCopilotTokenFields('').size, 0); +- }); +- +- test('parses fields from the leading colon-delimited segment', () => { +- const fields = parseCopilotTokenFields('tid=abc;exp=123;rt=1:HMACSIGNATURE'); +- assert.strictEqual(fields.get('tid'), 'abc'); +- assert.strictEqual(fields.get('exp'), '123'); +- assert.strictEqual(fields.get('rt'), '1'); +- }); +- +- test('parses fields when no colon separator is present', () => { +- const fields = parseCopilotTokenFields('tid=abc;rt=1'); +- assert.strictEqual(fields.get('tid'), 'abc'); +- assert.strictEqual(fields.get('rt'), '1'); +- }); +- +- test('skips segments without a value separator', () => { +- const fields = parseCopilotTokenFields('tid=abc;rt;exp=123:HMAC'); +- assert.strictEqual(fields.has('rt'), false); +- assert.strictEqual(fields.get('tid'), 'abc'); +- assert.strictEqual(fields.get('exp'), '123'); +- }); +- }); +- +- suite('isRestrictedTelemetryEnabled', () => { +- test('false for undefined token', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled(undefined), false); +- }); +- +- test('false for empty token', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled(''), false); +- }); +- +- test('false when rt field is missing', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123:HMAC'), false); +- }); +- +- test('false when rt=0', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=0;exp=123:HMAC'), false); +- }); +- +- test('true when rt=1 with other fields', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1;exp=123:HMAC'), true); +- }); +- +- test('true when rt=1 is the first field', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('rt=1;tid=abc:HMAC'), true); +- }); +- +- test('true when rt=1 is the last field', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123;rt=1:HMAC'), true); +- }); +- +- test('true when token has no colon-delimited signature segment', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1'), true); +- }); +- }); +-}); +diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +deleted file mode 100644 +index e81afe38..00000000 +--- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts ++++ /dev/null +@@ -1,242 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-import assert from 'assert'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; +-import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; +-import { buildSandboxConfigForSdk, type IAgentSandboxFileSystemSetting } from '../../node/copilot/sandboxConfigForSdk.js'; +- +-/** +- * Build the host-side `sandbox` root-config bag (the shape the workbench +- * forwarder dispatches in a `RootConfigChanged` action) for the given +- * `enabled` enum + optional per-OS filesystem rules and network host lists. +- * +- * Mirrors the per-OS dispatch in the Copilot extension's +- * `buildSandboxConfigForCLI` tests — the SDK helper consumes the same fields +- * but receives them via the host root bag instead of the per-OS keyed +- * object. +- */ +-function sandbox( +- platform: NodeJS.Platform, +- enabled: AgentSandboxEnabledValue | undefined, +- fs?: IAgentSandboxFileSystemSetting, +- hosts?: { allowedHosts?: readonly string[]; blockedHosts?: readonly string[] }, +-): ISandboxConfigValue | undefined { +- if (!enabled && !fs && !hosts) { +- return undefined; +- } +- const cfg: ISandboxConfigValue = {}; +- if (enabled !== undefined) { +- if (platform === 'win32') { +- cfg[AgentHostSandboxKey.WindowsEnabled] = enabled; +- } else { +- cfg[AgentHostSandboxKey.Enabled] = enabled; +- } +- } +- if (fs) { +- const fsKey = platform === 'win32' +- ? AgentHostSandboxKey.WindowsFileSystem +- : platform === 'darwin' +- ? AgentHostSandboxKey.MacFileSystem +- : AgentHostSandboxKey.LinuxFileSystem; +- cfg[fsKey] = fs as Record; +- } +- if (hosts?.allowedHosts?.length) { +- cfg[AgentHostSandboxKey.AllowedNetworkDomains] = [...hosts.allowedHosts]; +- } +- if (hosts?.blockedHosts?.length) { +- cfg[AgentHostSandboxKey.DeniedNetworkDomains] = [...hosts.blockedHosts]; +- } +- return cfg; +-} +- +-suite('buildSandboxConfigForSdk', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- suite('enablement', () => { +- test('returns undefined when no setting is set', () => { +- assert.strictEqual(buildSandboxConfigForSdk('darwin', undefined), undefined); +- assert.strictEqual(buildSandboxConfigForSdk('win32', undefined), undefined); +- }); +- +- test('returns undefined when the bag is empty', () => { +- assert.strictEqual(buildSandboxConfigForSdk('darwin', {}), undefined); +- assert.strictEqual(buildSandboxConfigForSdk('win32', {}), undefined); +- }); +- +- test('returns undefined for `off`', () => { +- assert.strictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.Off)), undefined); +- assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.Off)), undefined); +- }); +- +- test('enables sandbox for `on` on every platform', () => { +- for (const platform of ['darwin', 'linux', 'win32'] as const) { +- assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On)), { +- enabled: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, +- }); +- } +- }); +- +- test('enables sandbox and outbound network for `allowNetwork` on every platform', () => { +- for (const platform of ['darwin', 'linux', 'win32'] as const) { +- assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.AllowNetwork)), { +- enabled: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: true } }, +- }); +- } +- }); +- +- test('on Windows, WindowsEnabled overrides the cross-platform Enabled key', () => { +- // Cross-platform value says off, but the Windows override says on +- // → Windows session honors the override. +- const cfg = { +- [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.Off, +- [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg), { +- enabled: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, +- }); +- // Non-Windows ignores WindowsEnabled. +- assert.strictEqual(buildSandboxConfigForSdk('darwin', cfg), undefined); +- }); +- }); +- +- suite('filesystem policy', () => { +- test('selects the OS-specific slice from the per-OS filesystem keys', () => { +- const cfg: ISandboxConfigValue = { +- [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, +- [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, +- [AgentHostSandboxKey.LinuxFileSystem]: { allowWrite: ['/linux'] }, +- [AgentHostSandboxKey.MacFileSystem]: { allowWrite: ['/mac'] }, +- [AgentHostSandboxKey.WindowsFileSystem]: { allowWrite: ['C:\\win'] }, +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', cfg)?.userPolicy.filesystem, { readwritePaths: ['/linux'] }); +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', cfg)?.userPolicy.filesystem, { readwritePaths: ['/mac'] }); +- assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg)?.userPolicy.filesystem, { readwritePaths: ['C:\\win'] }); +- }); +- +- test('maps each setting to the corresponding SDK list', () => { +- const fs: IAgentSandboxFileSystemSetting = { +- allowWrite: ['/work'], +- allowRead: ['/read'], +- denyWrite: ['/readonly'], +- denyRead: ['/secret'], +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs)), { +- enabled: true, +- userPolicy: { +- filesystem: { +- readwritePaths: ['/work'], +- readonlyPaths: ['/readonly', '/read'], +- deniedPaths: ['/secret'], +- }, +- network: { allowOutbound: false }, +- }, +- }); +- }); +- +- test('omits filesystem lists that are empty', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, {})), { +- enabled: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, +- }); - }); - -- test('filters to /plan when "/p" typed', async () => { -- const items = await run('/p'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ']); +- test('denyRead wins over every other setting for the same path', () => { +- const fs: IAgentSandboxFileSystemSetting = { +- allowRead: ['/p'], +- allowWrite: ['/p'], +- denyWrite: ['/p'], +- denyRead: ['/p'], +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs))?.userPolicy.filesystem, { +- deniedPaths: ['/p'], +- }); - }); - -- test('filters to /compact when "/c" typed', async () => { -- const items = await run('/c'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/compact']); +- test('denyWrite wins over allowWrite / allowRead for the same path', () => { +- const fs: IAgentSandboxFileSystemSetting = { +- allowRead: ['/p'], +- allowWrite: ['/p'], +- denyWrite: ['/p'], +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs))?.userPolicy.filesystem, { +- readonlyPaths: ['/p'], +- }); - }); - -- test('filters to /research and /rubber-duck when "/r" typed', async () => { -- const items = await run('/r'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/research ', '/rubber-duck ']); +- test('allowWrite wins over allowRead for the same path', () => { +- const fs: IAgentSandboxFileSystemSetting = { +- allowRead: ['/p'], +- allowWrite: ['/p'], +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs))?.userPolicy.filesystem, { +- readwritePaths: ['/p'], +- }); - }); - -- test('returns nothing when /word does not match any command prefix', async () => { -- const items = await run('/zz'); -- assert.deepStrictEqual(items, []); +- test('keeps distinct paths in their own lists when settings overlap on some paths', () => { +- const fs: IAgentSandboxFileSystemSetting = { +- allowWrite: ['/work', '/shared'], +- denyWrite: ['/shared'], +- }; +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs))?.userPolicy.filesystem, { +- readwritePaths: ['/work'], +- readonlyPaths: ['/shared'], +- }); - }); +- }); - -- test('returns nothing when input does not start with /', async () => { -- const items = await run('hello /pl', 9); -- assert.deepStrictEqual(items, []); +- suite('network hosts', () => { +- test('forwards allowedHosts and opens outbound even when sandbox is `on`', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { +- allowOutbound: true, +- allowedHosts: ['github.com'], +- }); - }); - -- test('returns nothing when cursor is past the leading word', async () => { -- // Cursor sits after the trailing space, no longer in the slash token. -- const items = await run('/plan ', 6); -- assert.deepStrictEqual(items, []); +- test('ignores host lists when sandbox is `allowNetwork` (allow all)', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { +- allowOutbound: true, +- }); - }); - -- test('range covers only the leading slash word', async () => { -- const items = await run('/p extra text', 2); -- assert.strictEqual(items.length, 1); -- assert.strictEqual(items[0].rangeStart, 0); -- assert.strictEqual(items[0].rangeEnd, 2); +- test('forwards blockedHosts and opens outbound when only blockedHosts is set (deny-list-only allows non-denied domains)', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { blockedHosts: ['evil.example'] }))?.userPolicy.network, { +- allowOutbound: true, +- blockedHosts: ['evil.example'], +- }); - }); - -- test('attachment is Simple with command + description meta', async () => { -- const items = await run('/'); -- assert.deepStrictEqual(items.map(item => ({ type: item.attachment?.type, meta: item.attachment?._meta })), [ -- { -- type: MessageAttachmentKind.Simple, -- meta: { -- command: 'plan', -- description: 'Create an implementation plan before coding', -- }, -- }, -- { -- type: MessageAttachmentKind.Simple, -- meta: { -- command: 'compact', -- description: 'Free up context by compacting the conversation history', -- }, -- }, -- { -- type: MessageAttachmentKind.Simple, -- meta: { -- command: 'research', -- description: 'Run deep research on a topic using search and web sources', -- }, -- }, -- { -- type: MessageAttachmentKind.Simple, -- meta: { -- command: 'rubber-duck', -- description: 'Get an independent critique of the current approach', -- }, -- }, -- ]); +- test('forwards both allowedHosts and blockedHosts together when sandbox is `on`', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { +- allowOutbound: true, +- allowedHosts: ['a.example'], +- blockedHosts: ['b.example'], +- }); - }); - -- test('omits /compact when session has no history', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false, isRubberDuckEnabled: () => true }); -- const items = await gated.provideCompletionItems({ -- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, -- }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ', '/rubber-duck ']); +- test('ignores empty host lists', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: [], blockedHosts: [] }))?.userPolicy.network, { +- allowOutbound: false, +- }); - }); - -- test('omits /rubber-duck when not enabled', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => false }); -- const items = await gated.provideCompletionItems({ -- kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, -- }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ']); +- test('drops host lists and keeps outbound closed on darwin (Seatbelt has no per-host filter)', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy.network, { +- allowOutbound: false, +- }); - }); - -- test('passes raw session id (no scheme/slash) to hasHistory', async () => { -- let seen: string | undefined; -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { -- hasHistory: (id: string) => { seen = id; return true; }, -- isRubberDuckEnabled: () => true, +- test('darwin `allowNetwork` still opens outbound (host lists were already ignored)', () => { +- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { +- allowOutbound: true, - }); -- await gated.provideCompletionItems({ -- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/', offset: 1, -- }, CancellationToken.None); -- assert.strictEqual(seen, 'abc'); - }); - }); -}); -diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts deleted file mode 100644 -index d7a36fb8625..00000000000 ---- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +index 8697c546..00000000 +--- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ /dev/null -@@ -1,612 +0,0 @@ +@@ -1,728 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; +-import type { CopilotClient } from '@github/copilot-sdk'; +-import { DeferredPromise, raceTimeout, timeout } from '../../../../base/common/async.js'; +-import { VSBuffer } from '../../../../base/common/buffer.js'; +-import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +-import { DisposableStore } from '../../../../base/common/lifecycle.js'; +-import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +-import { FileService } from '../../../files/common/fileService.js'; +-import { IFileService } from '../../../files/common/files.js'; +-import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +-import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +-import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import { IAgentPluginManager } from '../../common/agentPluginManager.js'; +-import { DiscoveredType, SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; +-import { SessionPluginBundler } from '../../node/shared/sessionPluginBundler.js'; +-import { mapToParsedPlugin, toDiscoveredDirectoryCustomizations } from '../../node/copilot/copilotAgent.js'; - --suite('copilotToolDisplay — friendly tool names', () => { +-suite('SessionCustomizationDiscovery', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const disposables = new DisposableStore(); +- let fileService: FileService; +- let instantiationService: TestInstantiationService; +- let workspace: URI; +- let userHome: URI; +- let pluginBasePath: URI; - -- test('mirrors internal Copilot CLI friendly labels for representative tools', () => { -- const cases: Array<[toolName: string, displayName: string]> = [ -- ['bash', 'Run Shell Command'], -- ['powershell', 'Run Shell Command'], -- ['read_bash', 'Read Terminal'], -- ['read_powershell', 'Read Terminal'], -- ['write_bash', 'Write to Bash'], -- ['write_powershell', 'Write to PowerShell'], -- ['stop_bash', 'Stop Terminal Session'], -- ['stop_powershell', 'Stop Terminal Session'], -- ['bash_shutdown', 'Stop Terminal Session'], -- ['powershell_shutdown', 'Stop Terminal Session'], -- ['list_bash', 'List Shell Sessions'], -- ['list_powershell', 'List Shell Sessions'], -- ['view', 'Read'], -- ['edit', 'Edit File'], -- ['str_replace_editor', 'Edit File'], -- ['str_replace', 'Edit File'], -- ['insert', 'Edit File'], -- ['create', 'Create File'], -- ['grep', 'Search'], -- ['rg', 'Search'], -- ['glob', 'Search'], -- ['search_code_subagent', 'Search Code'], -- ['reply_to_comment', 'Reply to Comment'], -- ['code_review', 'Code Review'], -- ['think', 'Thinking'], -- ['report_intent', 'Report Intent'], -- ['report_progress', 'Progress update'], -- ['web_fetch', 'Fetch Web Content'], -- ['web_search', 'Web Search'], -- ['update_todo', 'Update Todo'], -- ['show_file', 'Show File'], -- ['fetch_copilot_cli_documentation', 'Fetch Documentation'], -- ['propose_work', 'Propose Work'], -- ['task_complete', 'Task Complete'], -- ['ask_user', 'Ask User'], -- ['skill', 'Invoke Skill'], -- ['task', 'Delegate Task'], -- ['list_agents', 'List Agents'], -- ['read_agent', 'Read Agent'], -- ['exit_plan_mode', 'Exit Plan Mode'], -- ['sql', 'Execute SQL'], -- ['lsp', 'Language Server'], -- ['create_pull_request', 'Create Pull Request'], -- ['gh-advisory-database', 'Check Dependencies'], -- ['store_memory', 'Store Memory'], -- ['apply_patch', 'Apply Patch'], -- ['write_agent', 'Write to Agent'], -- ['mcp_reload', 'Reload MCP Config'], -- ['mcp_validate', 'Validate MCP Config'], -- ['tool_search_tool_regex', 'Search Tools'], -- ['parallel_validation', 'Validate Changes'], -- ['codeql_checker', 'CodeQL Security Scan'], -- ]; +- setup(() => { +- fileService = disposables.add(new FileService(new NullLogService())); +- const memFs = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); - -- for (const [toolName, displayName] of cases) { -- assert.strictEqual(getToolDisplayName(toolName), displayName, toolName); -- } +- instantiationService = disposables.add(new TestInstantiationService()); +- instantiationService.stub(IFileService, fileService); +- instantiationService.stub(ILogService, new NullLogService()); +- +- workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); +- userHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); +- pluginBasePath = URI.from({ scheme: Schemas.inMemory, path: '/agentPlugins' }); +- instantiationService.stub(IAgentPluginManager, { basePath: pluginBasePath } as Partial); - }); - -- test('falls back to the raw tool name for unknown tools', () => { -- assert.strictEqual(getToolDisplayName('some_new_tool'), 'some_new_tool'); +- teardown(() => { +- disposables.clear(); - }); --}); +- ensureNoDisposablesAreLeakedInTestSuite(); - --suite('copilotToolDisplay — edit tool classification', () => { +- async function seed(path: string, content = ''): Promise { +- const uri = URI.from({ scheme: Schemas.inMemory, path }); +- await fileService.writeFile(uri, VSBuffer.fromString(content)); +- return uri; +- } - -- ensureNoDisposablesAreLeakedInTestSuite(); +- test('discovers supported agent instruction files in workspace roots', async () => { +- const wsCopilotInstructions = await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); +- const wsGeminiInstructions = await seed('/workspace/GEMINI.md', 'workspace gemini instructions'); - -- test('classifies direct file edit tools', () => { -- for (const toolName of ['edit', 'str_replace', 'insert', 'create', 'apply_patch', 'git_apply_patch']) { -- assert.strictEqual(isEditTool(toolName), true, toolName); -- } -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)) +- .flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))) +- .filter(entry => entry.type === DiscoveredType.AgentInstruction) +- .map(entry => entry.uri.toString()) +- .sort((a, b) => a.localeCompare(b)); - -- test('classifies str_replace_editor by command', () => { -- for (const command of ['edit', 'str_replace', 'insert', 'create']) { -- assert.strictEqual(isEditTool('str_replace_editor', command), true, command); -- } -- assert.strictEqual(isEditTool('str_replace_editor', 'view'), false); -- assert.strictEqual(isEditTool('str_replace_editor', 'unknown'), false); -- assert.strictEqual(isEditTool('str_replace_editor'), false); +- assert.deepStrictEqual(files, [ +- wsCopilotInstructions.toString(), +- wsGeminiInstructions.toString(), +- ].sort((a, b) => a.localeCompare(b))); - }); --}); - --suite('getPermissionDisplay — cd-prefix stripping', () => { +- test('groups discovered customizations by parent folder', async () => { +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const client = { +- rpc: { +- agents: { +- discover: async () => ({ +- agents: [ +- { id: 'one', name: 'One', description: '', path: '/workspace/.github/agents/one.agent.md', userInvocable: false }, +- { id: 'two', name: 'Two', description: '', path: '/workspace/.github/agents/two.agent.md', userInvocable: true }, +- { id: 'three', name: 'Three', description: '', path: '/workspace/.github/other/three.agent.md', userInvocable: false }, +- ], +- }), +- }, +- instructions: { discover: async () => ({ sources: [] }) }, +- skills: { discover: async () => ({ skills: [] }) }, +- }, +- } as unknown as CopilotClient; - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const customizations = await discovery.discover(client, CancellationToken.None); +- const agentDirectories = customizations.filter(customization => customization.contents === 'agent'); - -- const wd = URI.file('/repo/project'); +- const getPath = (uri: string) => URI.parse(uri).path; - -- test('strips redundant cd from shell permission request fullCommandText', () => { -- const request: ITypedPermissionRequest = { -- kind: 'shell', -- fullCommandText: 'cd /repo/project && npm test', -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, wd); -- assert.strictEqual(display.toolInput, 'npm test'); -- assert.strictEqual(display.permissionKind, 'shell'); +- assert.strictEqual(agentDirectories.length, 2); +- assert.deepStrictEqual(agentDirectories.map(customization => getPath(customization.uri)).sort(), [ +- '/workspace/.github/agents', +- '/workspace/.github/other', +- ]); +- const agentsInAgentsDir = agentDirectories.find(customization => getPath(customization.uri) === '/workspace/.github/agents'); +- assert.ok(agentsInAgentsDir); +- assert.deepStrictEqual(agentsInAgentsDir.children?.map(child => getPath(child.uri)).sort(), [ +- '/workspace/.github/agents/one.agent.md', +- '/workspace/.github/agents/two.agent.md', +- ]); - }); - -- test('leaves shell command alone when cd target differs from working directory', () => { -- const request: ITypedPermissionRequest = { -- kind: 'shell', -- fullCommandText: 'cd /tmp && ls', -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, wd); -- assert.strictEqual(display.toolInput, 'cd /tmp && ls'); +- test('returns directories sorted by type and URI', async () => { +- await seed('/workspace/.github/agents/aaa.agent.md', 'workspace agent a'); +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); +- await seed('/workspace/.github/skills/alpha/SKILL.md', 'workspace skill alpha'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'workspace skill'); +- await seed('/workspace/.github/instructions/alpha.instructions.md', 'workspace instruction alpha'); +- await seed('/workspace/.github/instructions/baz.instructions.md', 'workspace instruction'); +- await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); +- await seed('/home/.copilot/agents/abc.agent.md', 'user agent abc'); +- await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); +- await seed('/home/.agents/skills/aaa/SKILL.md', 'user skill aaa'); +- await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const directories = await discovery.scan(CancellationToken.None); +- const actual = directories.map(directory => `${directory.type}:${directory.uri.toString()}`); +- const expected = [...actual].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); +- +- assert.deepStrictEqual(actual, expected); +- for (const directory of directories) { +- const actualFiles = directory.files.map(file => file.uri.toString()); +- const expectedFiles = [...actualFiles].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); +- assert.deepStrictEqual(actualFiles, expectedFiles); +- } - }); - -- test('leaves shell command alone when no working directory provided', () => { -- const request: ITypedPermissionRequest = { -- kind: 'shell', -- fullCommandText: 'cd /repo/project && npm test', -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, undefined); -- assert.strictEqual(display.toolInput, 'cd /repo/project && npm test'); +- test('does not discover agent instruction files outside supported roots', async () => { +- await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); +- await seed('/workspace/docs/AGENTS.md', 'unsupported root'); +- await seed('/workspace/.claude/GEMINI.md', 'unsupported filename in .claude'); +- await seed('/home/copilot-instructions.md', 'unsupported home root'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)) +- .flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))) +- .filter(entry => entry.type === DiscoveredType.AgentInstruction) +- .map(entry => entry.uri.toString()) +- .sort((a, b) => a.localeCompare(b)); +- +- assert.deepStrictEqual(files, [ +- URI.from({ scheme: Schemas.inMemory, path: '/workspace/.github/copilot-instructions.md' }).toString(), +- ]); - }); - -- test('strips redundant cd from custom-tool shell permission request', () => { -- const request: ITypedPermissionRequest = { -- kind: 'custom-tool', -- toolName: 'bash', -- args: { command: 'cd /repo/project && echo hi' }, -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, wd); -- assert.strictEqual(display.toolInput, 'echo hi'); -- assert.strictEqual(display.permissionKind, 'shell'); -- }); -- -- test('does not affect non-shell custom-tool requests', () => { -- const request: ITypedPermissionRequest = { -- kind: 'custom-tool', -- toolName: 'some_other_tool', -- args: { command: 'cd /repo/project && echo hi' }, -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, wd); -- // Falls through to the generic branch — toolInput is the JSON-stringified args. -- assert.ok(display.toolInput?.includes('cd /repo/project'), `expected unrewritten args, got: ${display.toolInput}`); -- assert.strictEqual(display.permissionKind, 'custom-tool'); -- }); -- -- test('handles powershell custom-tool with semicolon separator', () => { -- const request: ITypedPermissionRequest = { -- kind: 'custom-tool', -- toolName: 'powershell', -- args: { command: 'cd /repo/project; dir' }, -- } as ITypedPermissionRequest; -- const display = getPermissionDisplay(request, wd); -- assert.strictEqual(display.toolInput, 'dir'); +- test('installs watchers for roots that contain discovered customizations', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'workspace skill'); +- await seed('/workspace/.github/instructions/rules.instructions.md', 'workspace instruction'); +- await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); +- await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction'); +- await seed('/home/.copilot/agents/user.agent.md', 'user agent'); +- await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill'); +- await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction'); +- await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); +- await seed('/home/.copilot/copilot-instructions.md', 'user copilot instructions'); +- +- const watchCalls: Array<{ resource: string; recursive: boolean }> = []; +- const originalWatch = fileService.watch.bind(fileService); +- disposables.add({ dispose: () => { fileService.watch = originalWatch as typeof fileService.watch; } }); +- fileService.watch = ((resource, options) => { +- watchCalls.push({ resource: resource.toString(), recursive: options?.recursive === true }); +- return originalWatch(resource, options); +- }) as typeof fileService.watch; +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- +- const watched = new Map(); +- for (const call of watchCalls) { +- const previous = watched.get(call.resource); +- watched.set(call.resource, previous === true || call.recursive); +- } +- assert.strictEqual(watched.get(workspace.toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.github').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.claude').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'agents').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'skills').toString()), true); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'instructions').toString()), true); +- assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); - }); --}); - --suite('view tool — view_range display', () => { +- test('refresh keeps existing watchers when discovered roots are unchanged', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const watchCalls: string[] = []; +- let watchDisposeCalls = 0; +- const originalWatch = fileService.watch.bind(fileService); +- disposables.add({ dispose: () => { fileService.watch = originalWatch as typeof fileService.watch; } }); +- fileService.watch = ((resource, options) => { +- watchCalls.push(resource.toString()); +- const disposable = originalWatch(resource, options); +- return { +- dispose: () => { +- watchDisposeCalls++; +- disposable.dispose(); +- } +- }; +- }) as typeof fileService.watch; - -- function invocation(parameters: Record | undefined): string { -- const result = getInvocationMessage('view', 'View File', parameters); -- return typeof result === 'string' ? result : result.markdown; -- } +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- const watchCallsAfterFirstScan = watchCalls.length; - -- function pastTense(parameters: Record | undefined): string { -- const result = getPastTenseMessage('view', 'View File', parameters, true); -- return typeof result === 'string' ? result : result.markdown; -- } +- await discovery.scan(CancellationToken.None); - -- test('renders path-only when view_range is absent', () => { -- assert.ok(invocation({ path: '/repo/file.ts' }).startsWith('Reading [')); -- assert.ok(pastTense({ path: '/repo/file.ts' }).startsWith('Read [')); +- assert.strictEqual(watchCalls.length, watchCallsAfterFirstScan, 'expected no new watch registrations for unchanged roots'); +- assert.strictEqual(watchDisposeCalls, 0, 'expected existing watchers to remain active for unchanged roots'); - }); - -- test('renders "lines X to Y" for a valid two-element range', () => { -- assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); -- assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); -- }); +- test('fires onDidChange when a new agent file is added under a non-recursively watched root', async () => { +- // Seed an existing agent so `.github/agents` is discovered and watched. +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- test('renders "line X" when start === end', () => { -- assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); -- assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); - -- test('renders "line X to the end" for the -1 EOF sentinel', () => { -- assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); -- assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); -- }); +- // Flush buffered file change events from the initial seed/scan so the +- // assertion below only observes the event triggered by the new file. +- await timeout(50); - -- test('falls back to path-only for invalid ranges', () => { -- // end < start (and not -1) -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: [20, 10] }).includes(',')); -- // negative start -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: [-5, 10] }).includes(',')); -- // non-integer -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: [1.5, 10] }).includes(',')); -- // wrong arity -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10] }).includes(',')); -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10, 20, 30] }).includes(',')); -- // non-array -- assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(',')); +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); +- +- await seed('/workspace/.github/agents/bar.agent.md', 'new workspace agent'); +- await raceTimeout(fired.p, 500); +- +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire for a new agent file inside the watched directory'); - }); --}); - --// ---- write_/read_ shell tool display --------------------------------------- --// --// Coverage for the secondary shell helpers (write_bash, read_bash, and their --// powershell siblings). These never appear in a permission dialog (they're --// registered with `skipPermission: true` — see copilotShellTools.ts), but they --// still flow through the tool-execution display pipeline. +- test('fires onDidChange when an existing agent file is modified under a non-recursively watched root', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - --suite('copilotToolDisplay — write_/read_ shell tools', () => { +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- await timeout(50); - -- ensureNoDisposablesAreLeakedInTestSuite(); +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); - -- suite('getToolKind', () => { +- // Overwrite the existing agent file to produce an UPDATED event. +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent (updated)'); +- await raceTimeout(fired.p, 500); - -- test('returns terminal for bash', () => { -- assert.strictEqual(getToolKind('bash'), 'terminal'); -- }); +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when an existing agent file is modified'); +- }); - -- test('returns terminal for powershell', () => { -- assert.strictEqual(getToolKind('powershell'), 'terminal'); -- }); +- test('fires onDidChange when an existing agent file is deleted under a non-recursively watched root', async () => { +- const agentUri = await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); +- // Seed a second agent so the parent directory still exists after the deletion. +- await seed('/workspace/.github/agents/bar.agent.md', 'workspace agent bar'); - -- test('returns undefined for write_bash (sending input to a running program, not launching a terminal)', () => { -- assert.strictEqual(getToolKind('write_bash'), undefined); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- await timeout(50); - -- test('returns undefined for write_powershell', () => { -- assert.strictEqual(getToolKind('write_powershell'), undefined); -- }); +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); - -- test('returns undefined for read_bash (reading output, not launching a terminal)', () => { -- assert.strictEqual(getToolKind('read_bash'), undefined); -- }); +- await fileService.del(agentUri); +- await raceTimeout(fired.p, 500); - -- test('returns undefined for read_powershell', () => { -- assert.strictEqual(getToolKind('read_powershell'), undefined); -- }); +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when an existing agent file is deleted'); +- }); - -- test('returns subagent for task', () => { -- assert.strictEqual(getToolKind('task'), 'subagent'); -- }); +- test('fires onDidChange when AGENTS.md in the workspace root is modified', async () => { +- // AGENTS.md lives directly under the workspace root, which is watched non-recursively. +- await seed('/workspace/AGENTS.md', 'agents instructions'); - -- test('returns undefined for view', () => { -- assert.strictEqual(getToolKind('view'), undefined); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- await timeout(50); - -- test('returns search for glob', () => { -- assert.strictEqual(getToolKind('glob'), 'search'); -- }); +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); +- +- await seed('/workspace/AGENTS.md', 'agents instructions (updated)'); +- await raceTimeout(fired.p, 500); +- +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when AGENTS.md at the workspace root is modified'); - }); - -- suite('getShellLanguage', () => { +- test('does not fire onDidChange for files outside any trigger URI', async () => { +- // Seed a customization so the workspace + `.github` dirs get watchers. +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- test('bash returns shellscript', () => { -- assert.strictEqual(getShellLanguage('bash'), 'shellscript'); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- await timeout(50); - -- test('powershell returns powershell', () => { -- assert.strictEqual(getShellLanguage('powershell'), 'powershell'); -- }); +- let changeCount = 0; +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- })); - -- test('write_bash returns shellscript', () => { -- assert.strictEqual(getShellLanguage('write_bash'), 'shellscript'); -- }); +- // None of these paths intersect any trigger URI: +- // - `.git/HEAD` : `.git` is unrelated (not `.github`) +- // - `.vscode/settings.json` : `.vscode` is unrelated +- // - `README.md` : at workspace root but not AGENTS.md/CLAUDE.md/GEMINI.md +- // - `src/index.ts` : unrelated top-level directory +- await seed('/workspace/.git/HEAD', 'ref: refs/heads/main'); +- await seed('/workspace/.vscode/settings.json', '{}'); +- await seed('/workspace/README.md', '# project'); +- await seed('/workspace/src/index.ts', 'export {};'); - -- test('write_powershell returns powershell', () => { -- assert.strictEqual(getShellLanguage('write_powershell'), 'powershell'); -- }); +- // Give the in-memory provider time to deliver any (stray) events. +- await timeout(100); - -- test('read_bash returns shellscript', () => { -- assert.strictEqual(getShellLanguage('read_bash'), 'shellscript'); -- }); +- assert.strictEqual(changeCount, 0, 'expected onDidChange not to fire for paths outside any trigger URI'); +- }); - -- test('read_powershell returns powershell', () => { -- assert.strictEqual(getShellLanguage('read_powershell'), 'powershell'); -- }); +- test('cancellation of one caller does not affect another concurrent caller', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const cancelSource = new CancellationTokenSource(); +- disposables.add(cancelSource); +- +- const cancelled = discovery.scan(cancelSource.token); +- const nonCancelled = discovery.scan(CancellationToken.None); +- cancelSource.cancel(); +- +- await assert.rejects(cancelled); +- const directories = await nonCancelled; +- assert.ok(directories.some(directory => directory.type === DiscoveredType.Agent)); - }); - -- suite('getInvocationMessage', () => { +- test('discovers agents, skills, instructions, and hooks across workspace and home roots', async () => { +- const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- const wsSkill = await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); +- const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); +- const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); +- const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); +- const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); +- // Noise that should not be picked up +- await seed('/workspace/.github/agents/not-an-agent.txt', 'ignored'); +- await seed('/workspace/.github/hooks/not-a-hook.md', 'ignored'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const directories = await discovery.scan(CancellationToken.None); +- const files = directories.flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); +- +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: userAgent, type: DiscoveredType.Agent }, +- { uri: userHook, type: DiscoveredType.Hook }, +- { uri: userSkill, type: DiscoveredType.Skill }, +- { uri: wsAgent, type: DiscoveredType.Agent }, +- { uri: wsHook, type: DiscoveredType.Hook }, +- { uri: wsInstr, type: DiscoveredType.Instruction }, +- { uri: wsSkill, type: DiscoveredType.Skill }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); +- assert.ok(directories.some(directory => directory.uri.toString() === URI.joinPath(workspace, '.github', 'agents').toString())); +- }); - -- function getText(msg: ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; -- } +- test('discovers nested .json hook files', async () => { +- const nestedWsHook = await seed('/workspace/.github/hooks/team/security/pre-tool.json', '{"PreToolUse": []}'); +- const nestedUserHook = await seed('/home/.copilot/hooks/domain/tools/post-tool.json', '{"PostToolUse": []}'); - -- test('write_bash with command includes the command text', () => { -- const msg = getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }); -- assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - -- test('write_bash without command returns a non-empty fallback message', () => { -- const msg = getInvocationMessage('write_bash', 'Write Shell Input', undefined); -- assert.ok(getText(msg).length > 0); -- assert.ok(!getText(msg).includes('undefined')); -- }); +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: nestedUserHook, type: DiscoveredType.Hook }, +- { uri: nestedWsHook, type: DiscoveredType.Hook }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); +- }); - -- test('write_powershell with command includes the command text', () => { -- const msg = getInvocationMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }); -- assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); -- }); +- test('discovers hook settings files from fixed workspace locations', async () => { +- const githubSettings = await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); +- const githubLocalSettings = await seed('/workspace/.github/copilot/settings.local.json', '{"hooks": {"PostToolUse": []}}'); +- const claudeSettings = await seed('/workspace/.claude/settings.json', '{"hooks": {"SessionStart": []}}'); +- const claudeLocalSettings = await seed('/workspace/.claude/settings.local.json', '{"hooks": {"SessionEnd": []}}'); +- await seed('/workspace/.github/copilot/settings.dev.json', '{"hooks": {"Ignored": []}}'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); +- +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: claudeLocalSettings, type: DiscoveredType.Hook }, +- { uri: claudeSettings, type: DiscoveredType.Hook }, +- { uri: githubLocalSettings, type: DiscoveredType.Hook }, +- { uri: githubSettings, type: DiscoveredType.Hook }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); +- }); - -- test('read_bash returns a non-empty message', () => { -- const msg = getInvocationMessage('read_bash', 'Read Shell Output', undefined); -- assert.strictEqual(getText(msg), 'Reading Terminal'); -- }); +- test('fires onDidChange when fixed hook settings file is modified', async () => { +- await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); - -- test('read_powershell returns a non-empty message', () => { -- const msg = getInvocationMessage('read_powershell', 'Read Shell Output', undefined); -- assert.strictEqual(getText(msg), 'Reading Terminal'); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- await discovery.scan(CancellationToken.None); +- await timeout(50); - -- test('write_bash message differs from bash message (distinct wording)', () => { -- const writeBashMsg = getText(getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hi' })); -- const bashMsg = getText(getInvocationMessage('bash', 'Bash', { command: 'echo hi' })); -- // Both include the command, but the surrounding text should differ -- assert.notStrictEqual(writeBashMsg, bashMsg); -- }); +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); +- +- await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": [{"command": "echo test"}]}}'); +- await raceTimeout(fired.p, 500); +- +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when fixed hook settings file is modified'); - }); - -- suite('getPastTenseMessage', () => { +- test('excludes exact-case README.md inside agent folders', async () => { +- const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- const wsPlainAgent = await seed('/workspace/.github/agents/plain.md', 'plain agent body'); +- const wsLowerReadmeAgent = await seed('/workspace/.github/agents/readme.md', 'docs lower'); +- await seed('/workspace/.github/agents/README.md', 'docs'); - -- function getText(msg: ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; -- } +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - -- test('write_bash with command includes the command text', () => { -- const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, true); -- assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); -- }); +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: wsAgent, type: DiscoveredType.Agent }, +- { uri: wsLowerReadmeAgent, type: DiscoveredType.Agent }, +- { uri: wsPlainAgent, type: DiscoveredType.Agent }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); +- }); - -- test('write_bash without command returns a non-empty fallback message', () => { -- const msg = getPastTenseMessage('write_bash', 'Write Shell Input', undefined, true); -- assert.ok(getText(msg).length > 0); -- }); +- test('includes non-README markdown files inside agent folders', async () => { +- const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- const wsLegacyMode = await seed('/workspace/.github/agents/legacy.chatmode.md', 'legacy mode body'); +- const wsPrompt = await seed('/workspace/.github/agents/bar.prompt.md', 'prompt body'); +- const wsInstruction = await seed('/workspace/.github/agents/baz.instructions.md', 'instruction body'); +- const wsCopilotInstructions = await seed('/workspace/.github/agents/copilot-instructions.md', 'copilot instructions body'); +- const wsSkill = await seed('/workspace/.github/agents/SKILL.md', 'skill body'); +- const wsSkillLowercase = await seed('/workspace/.github/agents/skill.md', 'skill body lowercase'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); +- +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: wsCopilotInstructions, type: DiscoveredType.Agent }, +- { uri: wsAgent, type: DiscoveredType.Agent }, +- { uri: wsInstruction, type: DiscoveredType.Agent }, +- { uri: wsLegacyMode, type: DiscoveredType.Agent }, +- { uri: wsPrompt, type: DiscoveredType.Agent }, +- { uri: wsSkill, type: DiscoveredType.Agent }, +- { uri: wsSkillLowercase, type: DiscoveredType.Agent }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); +- }); - -- test('write_powershell with command includes the command text', () => { -- const msg = getPastTenseMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }, true); -- assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); -- }); +- test('discovers nested .instructions.md files', async () => { +- const nestedWsInstr = await seed('/workspace/.github/instructions/team/security/policy.instructions.md', 'workspace nested instruction'); +- const nestedUserInstr = await seed('/home/.copilot/instructions/domain/tools/deep.instructions.md', 'user nested instruction'); - -- test('read_bash success returns a non-empty message', () => { -- const msg = getPastTenseMessage('read_bash', 'Read Shell Output', undefined, true); -- assert.strictEqual(getText(msg), 'Read Terminal'); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - -- test('write_bash failure returns a non-empty error message', () => { -- const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, false); -- assert.ok(getText(msg).length > 0); -- }); +- assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ +- { uri: nestedUserInstr, type: DiscoveredType.Instruction }, +- { uri: nestedWsInstr, type: DiscoveredType.Instruction }, +- ].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()))); - }); - -- suite('getToolInputString', () => { - -- test('write_bash extracts command field', () => { -- assert.strictEqual(getToolInputString('write_bash', { command: 'echo hello' }, undefined), 'echo hello'); -- }); - -- test('write_powershell extracts command field', () => { -- assert.strictEqual(getToolInputString('write_powershell', { command: 'Get-Date' }, undefined), 'Get-Date'); -- }); +- test('bundles nested .instructions.md files into rules', async () => { +- await seed('/workspace/.github/instructions/team/security/policy.instructions.md', 'workspace nested instruction'); +- await seed('/home/.copilot/instructions/domain/tools/deep.instructions.md', 'user nested instruction'); - -- test('write_bash falls back to rawArguments when no command field', () => { -- assert.strictEqual(getToolInputString('write_bash', {}, '{"command":"echo hello"}'), '{"command":"echo hello"}'); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- const result = await bundler.bundle(await discovery.scan(CancellationToken.None)); - -- test('write_bash returns undefined when both parameters and rawArguments are absent', () => { -- assert.strictEqual(getToolInputString('write_bash', undefined, undefined), undefined); -- }); +- assert.ok(result); - -- test('read_bash with no parameters returns undefined', () => { -- assert.strictEqual(getToolInputString('read_bash', undefined, undefined), undefined); -- }); +- const root = bundler.rootUri; +- const workspaceInstr = await fileService.readFile(URI.joinPath(root, 'rules', 'policy.instructions.md')); +- assert.strictEqual(workspaceInstr.value.toString(), 'workspace nested instruction'); +- +- const userInstr = await fileService.readFile(URI.joinPath(root, 'rules', 'deep.instructions.md')); +- assert.strictEqual(userInstr.value.toString(), 'user nested instruction'); - }); --}); - --suite('skill events', () => { +- test('returns undefined when no files were discovered', async () => { +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- const directories = await discovery.scan(CancellationToken.None); +- const result = await bundler.bundle(directories); +- assert.strictEqual(result, undefined); +- }); - -- ensureNoDisposablesAreLeakedInTestSuite(); +- test('maps discovered files to parsed plugin preserving source URIs', async () => { +- const agent = await seed('/workspace/.github/agents/foo.agent.md', '---\nname: Workspace Agent\ndescription: Agent description\n---\nbody'); +- const skill = await seed('/workspace/.github/skills/bar/SKILL.md', '---\nname: Workspace Skill\ndescription: Skill description\n---\nbody'); +- const instruction = await seed('/workspace/.github/instructions/baz.instructions.md', '---\nname: Workspace Rule\ndescription: Rule description\nglobs:\n - src/**\n---\nbody'); - -- test('hides the raw `skill` tool call and synthesizes a tool-start/complete pair from `skill.invoked`', () => { -- const withPath = synthesizeSkillToolCall( -- { name: 'plan', path: '/abs/repo/skills/plan/SKILL.md' }, -- 'evt-123', -- ); -- const noPath = synthesizeSkillToolCall( -- { name: 'plan' }, -- undefined, -- ); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); - -- assert.deepStrictEqual({ -- skillIsHidden: isHiddenTool('skill'), -- withPathToolCallId: withPath.toolCallId, -- withPathToolName: withPath.toolName, -- withPathDisplayName: withPath.displayName, -- withPathInvocation: withPath.invocationMessage, -- withPathPastTense: withPath.pastTenseMessage, -- noPathToolCallId: noPath.toolCallId, -- noPathInvocation: noPath.invocationMessage, -- noPathPastTense: noPath.pastTenseMessage, -- }, { -- skillIsHidden: true, -- withPathToolCallId: 'synth-skill-evt-123', -- withPathToolName: 'skill', -- withPathDisplayName: 'Read Skill', -- withPathInvocation: { markdown: 'Reading skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, -- withPathPastTense: { markdown: 'Read skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, -- noPathToolCallId: 'synth-skill-2108d652', -- noPathInvocation: 'Reading skill plan', -- noPathPastTense: 'Read skill plan', -- }); +- const plugin = mapToParsedPlugin(customizations); +- +- assert.ok(plugin); +- assert.strictEqual(plugin.agents.length, 1); +- assert.strictEqual(plugin.skills.length, 1); +- assert.strictEqual(plugin.instructions.length, 1); +- assert.deepStrictEqual( +- { +- agentUri: plugin.agents[0].uri.toString(), +- agentDescription: plugin.agents[0].description, +- skillUri: plugin.skills[0].uri.toString(), +- skillDescription: plugin.skills[0].description, +- ruleUri: plugin.instructions[0].uri.toString(), +- ruleDescription: plugin.instructions[0].description, +- }, +- { +- agentUri: agent.toString(), +- agentDescription: 'Agent description', +- skillUri: skill.toString(), +- skillDescription: 'Skill description', +- ruleUri: instruction.toString(), +- ruleDescription: 'Rule description', +- } +- ); - }); --}); - --suite('rg / grep search tool display', () => { +- test('does not include parsed agent-instruction rules in mapToParsedPlugin output', async () => { +- await seed('/workspace/.github/copilot-instructions.md', 'workspace instructions'); +- await seed('/workspace/.agents/skills/bar/SKILL.md', '---\nname: bar\ndescription: Skill description\n---\nbody'); - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); - -- function text(msg: ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; -- } +- const plugin = mapToParsedPlugin(customizations); - -- test('rg invocation/past tense use "Searching for {pattern}" wording', () => { -- const inv = text(getInvocationMessage('rg', 'Search', { pattern: 'foo' })); -- const past = text(getPastTenseMessage('rg', 'Search', { pattern: 'foo' }, true)); -- assert.deepStrictEqual({ inv, past }, { -- inv: 'Searching for `foo`', -- past: 'Searched for `foo`', -- }); +- assert.ok(plugin); +- assert.strictEqual(plugin.skills.length, 1); +- assert.strictEqual(plugin.instructions.length, 0); - }); - -- test('rg without a pattern falls back to a generic search message (not the raw tool name)', () => { -- const inv = text(getInvocationMessage('rg', 'Search', undefined)); -- assert.strictEqual(inv, 'Searching files'); -- }); +- test('returns undefined from mapToParsedPlugin when all customizations are agent-instruction files', async () => { +- // Only agent instruction files are discovered — these are excluded from the parsed plugin output. +- await seed('/workspace/.github/copilot-instructions.md', 'workspace instructions'); +- await seed('/home/.copilot/copilot-instructions.md', 'user instructions'); - -- test('grep keeps "Searching for {pattern}" wording', () => { -- const inv = text(getInvocationMessage('grep', 'Search', { pattern: 'bar' })); -- const past = text(getPastTenseMessage('grep', 'Search', { pattern: 'bar' }, true)); -- assert.deepStrictEqual({ inv, past }, { -- inv: 'Searching for `bar`', -- past: 'Searched for `bar`', -- }); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); +- +- const plugin = mapToParsedPlugin(customizations); - -- test('getToolInputString returns pattern for both grep and rg', () => { -- assert.strictEqual(getToolInputString('grep', { pattern: 'abc' }, undefined), 'abc'); -- assert.strictEqual(getToolInputString('rg', { pattern: 'abc' }, undefined), 'abc'); +- assert.strictEqual(plugin, undefined); - }); -}); - --suite('web_fetch tool display', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +-suite('SessionPluginBundler', () => { +- const disposables = new DisposableStore(); +- let fileService: FileService; +- let instantiationService: TestInstantiationService; +- let workspace: URI; +- let userHome: URI; +- let pluginBasePath: URI; - -- function text(msg: ReturnType | ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; -- } +- setup(() => { +- fileService = disposables.add(new FileService(new NullLogService())); +- const memFs = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); - -- test('uses the fetched URL for invocation and completion messages', () => { -- const parameters = { url: 'https://example.com/docs' }; -- assert.deepStrictEqual({ -- invocation: text(getInvocationMessage('web_fetch', 'Fetch Web Content', parameters)), -- pastTense: text(getPastTenseMessage('web_fetch', 'Fetch Web Content', parameters, true)), -- input: getToolInputString('web_fetch', parameters, undefined), -- }, { -- invocation: 'Fetching [https://example.com/docs](https://example.com/docs)', -- pastTense: 'Fetched [https://example.com/docs](https://example.com/docs)', -- input: 'https://example.com/docs', -- }); -- }); +- instantiationService = disposables.add(new TestInstantiationService()); +- instantiationService.stub(IFileService, fileService); +- instantiationService.stub(ILogService, new NullLogService()); - -- test('falls back to generic URL wording when the URL is absent', () => { -- assert.deepStrictEqual({ -- invocation: text(getInvocationMessage('web_fetch', 'Fetch Web Content', undefined)), -- pastTense: text(getPastTenseMessage('web_fetch', 'Fetch Web Content', undefined, true)), -- failure: text(getPastTenseMessage('web_fetch', 'Fetch Web Content', { url: 'https://example.com/docs' }, false)), -- }, { -- invocation: 'Fetching URL', -- pastTense: 'Fetched URL', -- failure: '"Fetch Web Content" failed', -- }); +- workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); +- userHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); +- pluginBasePath = URI.from({ scheme: Schemas.inMemory, path: '/agentPlugins' }); +- instantiationService.stub(IAgentPluginManager, { basePath: pluginBasePath } as Partial); - }); --}); -- --suite('sql tool display', () => { - +- teardown(() => { +- disposables.clear(); +- }); - ensureNoDisposablesAreLeakedInTestSuite(); - -- function text(msg: ReturnType | ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; +- async function seed(path: string, content = ''): Promise { +- const uri = URI.from({ scheme: Schemas.inMemory, path }); +- await fileService.writeFile(uri, VSBuffer.fromString(content)); +- return uri; - } - -- test('uses the SQL description for invocation and completion messages', () => { -- const parameters = { description: 'Insert agent host study todos', query: 'INSERT INTO todos (title) VALUES (\'Read terminal activation docs\')' }; -- assert.strictEqual(text(getInvocationMessage('sql', 'Execute SQL', parameters)), 'Insert agent host study todos'); -- assert.strictEqual(text(getPastTenseMessage('sql', 'Execute SQL', parameters, true)), 'Insert agent host study todos'); -- }); +- test('bundles discovered files into the synthetic plugin tree', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); +- await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); +- await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); - -- test('falls back to generic SQL wording when description is absent', () => { -- assert.strictEqual(text(getInvocationMessage('sql', 'Execute SQL', { query: 'SELECT 1' })), 'Executing SQL query'); -- assert.strictEqual(text(getPastTenseMessage('sql', 'Execute SQL', { query: 'SELECT 1' }, true)), 'Executed SQL query'); -- }); --}); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- const directories = await discovery.scan(CancellationToken.None); +- const result = await bundler.bundle(directories); - --suite('apply_patch tool display', () => { +- assert.ok(result); +- assert.strictEqual(result.ref.name, 'VS Code Synced Data'); +- assert.ok(result.ref.nonce); - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const root = bundler.rootUri; +- const manifest = await fileService.readFile(URI.joinPath(root, '.plugin', 'plugin.json')); +- assert.match(manifest.value.toString(), /"name": "VS Code Synced Data"/); - -- function text(msg: ReturnType): string { -- return typeof msg === 'string' ? msg : msg.markdown; -- } +- const agent = await fileService.readFile(URI.joinPath(root, 'agents', 'foo.agent.md')); +- assert.strictEqual(agent.value.toString(), 'agent body'); - -- const singleFilePatch = [ -- '*** Begin Patch', -- '*** Update File: /repo/src/foo.ts', -- '@@', -- '-old', -- '+new', -- '*** End Patch', -- ].join('\n'); +- const skill = await fileService.readFile(URI.joinPath(root, 'skills', 'bar', 'SKILL.md')); +- assert.strictEqual(skill.value.toString(), 'skill body'); - -- const multiFilePatch = [ -- '*** Begin Patch', -- '*** Update File: /repo/src/foo.ts', -- '@@', -- '-old', -- '+new', -- '*** Add File: /repo/src/bar.ts', -- '+hello', -- '*** Delete File: /repo/src/baz.ts', -- '*** End Patch', -- ].join('\n'); +- const instr = await fileService.readFile(URI.joinPath(root, 'rules', 'baz.instructions.md')); +- assert.strictEqual(instr.value.toString(), 'instr body'); - -- test('renders a clickable file link for a single-file patch', () => { -- const inv = text(getInvocationMessage('apply_patch', 'Patch', { input: singleFilePatch })); -- const past = text(getPastTenseMessage('apply_patch', 'Patch', { input: singleFilePatch }, true)); -- assert.deepStrictEqual({ inv, past }, { -- inv: 'Editing [foo.ts](file:///repo/src/foo.ts)', -- past: 'Edited [foo.ts](file:///repo/src/foo.ts)', -- }); +- const hook = await fileService.readFile(URI.joinPath(root, 'hooks', 'pre-tool.json')); +- assert.strictEqual(hook.value.toString(), '{"PreToolUse": []}'); - }); - -- test('lists every affected file for a multi-file patch', () => { -- const inv = text(getInvocationMessage('apply_patch', 'Patch', { input: multiFilePatch })); -- const past = text(getPastTenseMessage('apply_patch', 'Patch', { input: multiFilePatch }, true)); -- assert.deepStrictEqual({ inv, past }, { -- inv: 'Editing [foo.ts](file:///repo/src/foo.ts), [bar.ts](file:///repo/src/bar.ts), [baz.ts](file:///repo/src/baz.ts)', -- past: 'Edited [foo.ts](file:///repo/src/foo.ts), [bar.ts](file:///repo/src/bar.ts), [baz.ts](file:///repo/src/baz.ts)', -- }); -- }); - -- test('falls back to a generic message when the patch body is missing or unparseable', () => { -- assert.strictEqual(getInvocationMessage('apply_patch', 'Patch', undefined), 'Editing files'); -- assert.strictEqual(getInvocationMessage('apply_patch', 'Patch', { input: 'not a patch' }), 'Editing files'); -- assert.strictEqual(getPastTenseMessage('apply_patch', 'Patch', undefined, true), 'Edited files'); -- }); +- test('produces a stable nonce for identical content', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); - -- test('also accepts the patch text under the `patch` parameter (CLI shape)', () => { -- const inv = text(getInvocationMessage('apply_patch', 'Patch', { patch: singleFilePatch })); -- assert.strictEqual(inv, 'Editing [foo.ts](file:///repo/src/foo.ts)'); -- }); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- const first = await bundler.bundle(await discovery.scan(CancellationToken.None)); - -- test('git_apply_patch shares the same display path', () => { -- const inv = text(getInvocationMessage('git_apply_patch', 'Patch', { input: singleFilePatch })); -- const past = text(getPastTenseMessage('git_apply_patch', 'Patch', { input: singleFilePatch }, true)); -- assert.deepStrictEqual({ inv, past }, { -- inv: 'Editing [foo.ts](file:///repo/src/foo.ts)', -- past: 'Edited [foo.ts](file:///repo/src/foo.ts)', +- let writeCalls = 0; +- let deleteCalls = 0; +- const originalWriteFile = fileService.writeFile.bind(fileService); +- const originalDel = fileService.del.bind(fileService); +- disposables.add({ +- dispose: () => { +- fileService.writeFile = originalWriteFile as typeof fileService.writeFile; +- fileService.del = originalDel as typeof fileService.del; +- } +- }); +- fileService.writeFile = ((...args: Parameters) => { +- writeCalls++; +- return originalWriteFile(...args); +- }) as typeof fileService.writeFile; +- fileService.del = ((...args: Parameters) => { +- deleteCalls++; +- return originalDel(...args); +- }) as typeof fileService.del; +- +- const second = await bundler.bundle(await discovery.scan(CancellationToken.None)); +- assert.ok(first); +- assert.ok(second); +- assert.deepStrictEqual({ +- firstNonce: first.ref.nonce, +- secondNonce: second.ref.nonce, +- writeCalls, +- deleteCalls, +- }, { +- firstNonce: first.ref.nonce, +- secondNonce: first.ref.nonce, +- writeCalls: 0, +- deleteCalls: 0, - }); - }); - -- test('failure still routes through the generic failed message', () => { -- assert.strictEqual(getPastTenseMessage('apply_patch', 'Patch', { input: singleFilePatch }, false), '"Patch" failed'); -- }); +- test('returns undefined without rewriting when cancelled', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); - -- test('getEditFilePath returns the first affected file from a patch body', () => { -- assert.strictEqual(getEditFilePath({ input: singleFilePatch }), '/repo/src/foo.ts'); -- assert.strictEqual(getEditFilePath({ input: multiFilePatch }), '/repo/src/foo.ts'); -- assert.strictEqual(getEditFilePath({ patch: singleFilePatch }), '/repo/src/foo.ts'); -- assert.strictEqual(getEditFilePath(JSON.stringify({ input: singleFilePatch })), '/repo/src/foo.ts'); -- assert.strictEqual(getEditFilePath({ input: 'not a patch' }), undefined); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- +- let writeCalls = 0; +- let deleteCalls = 0; +- const originalWriteFile = fileService.writeFile.bind(fileService); +- const originalDel = fileService.del.bind(fileService); +- disposables.add({ +- dispose: () => { +- fileService.writeFile = originalWriteFile as typeof fileService.writeFile; +- fileService.del = originalDel as typeof fileService.del; +- } +- }); +- fileService.writeFile = ((...args: Parameters) => { +- writeCalls++; +- return originalWriteFile(...args); +- }) as typeof fileService.writeFile; +- fileService.del = ((...args: Parameters) => { +- deleteCalls++; +- return originalDel(...args); +- }) as typeof fileService.del; +- +- const result = await bundler.bundle(await discovery.scan(CancellationToken.None), CancellationToken.Cancelled); +- assert.deepStrictEqual({ result, writeCalls, deleteCalls }, { result: undefined, writeCalls: 0, deleteCalls: 0 }); - }); - -- test('getEditFilePaths returns every affected file from a patch body', () => { -- assert.deepStrictEqual(getEditFilePaths({ input: singleFilePatch }), ['/repo/src/foo.ts']); -- assert.deepStrictEqual(getEditFilePaths({ input: multiFilePatch }), ['/repo/src/foo.ts', '/repo/src/bar.ts', '/repo/src/baz.ts']); -- assert.deepStrictEqual(getEditFilePaths({ patch: multiFilePatch }), ['/repo/src/foo.ts', '/repo/src/bar.ts', '/repo/src/baz.ts']); -- assert.deepStrictEqual(getEditFilePaths(JSON.stringify({ input: multiFilePatch })), ['/repo/src/foo.ts', '/repo/src/bar.ts', '/repo/src/baz.ts']); -- assert.deepStrictEqual(getEditFilePaths({ path: '/repo/src/edit.ts' }), ['/repo/src/edit.ts']); -- assert.deepStrictEqual(getEditFilePaths({ input: 'not a patch' }), []); -- assert.deepStrictEqual(getEditFilePaths(undefined), []); -- // SDK custom-tool format: arguments arrive as a raw V4A patch string, -- // not as a JSON object — exercise the string fallback path. -- assert.deepStrictEqual(getEditFilePaths(multiFilePatch), ['/repo/src/foo.ts', '/repo/src/bar.ts', '/repo/src/baz.ts']); -- assert.deepStrictEqual(getEditFilePaths(singleFilePatch), ['/repo/src/foo.ts']); +- test('different working directories produce different bundle authorities', async () => { +- const otherWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/other-workspace' }); +- const a = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- const b = disposables.add(instantiationService.createInstance(SessionPluginBundler, otherWorkspace)); +- assert.notStrictEqual(a.rootUri.toString(), b.rootUri.toString()); - }); -}); diff --git a/upstream/stable.json b/upstream/stable.json index 184c1d4..fe07fae 100644 --- a/upstream/stable.json +++ b/upstream/stable.json @@ -1,4 +1,4 @@ { - "tag": "1.124.2", - "commit": "6928394f91b684055b873eecb8bc281365131f1c" + "tag": "1.126.0", + "commit": "7e7950df89d055b5a378379db9ee14290772148a" }