diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts index 251e615db203d..cd4dc34490e00 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts @@ -50,13 +50,20 @@ export class InstallPluginAction extends Action { } export class UninstallPluginAction extends Action { - constructor(plugin: IAgentPlugin & { remove(): void }) { - super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true, - () => { plugin.remove(); return Promise.resolve(); }); + constructor(private readonly plugin: IAgentPlugin & { remove(): Promise }) { + super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true); + } + + override async run(): Promise { + await this.runAndGetResult(); + } + + runAndGetResult(): Promise { + return this.plugin.remove(); } } -function isRemovableAgentPlugin(plugin: IAgentPlugin): plugin is IAgentPlugin & { remove(): void } { +function isRemovableAgentPlugin(plugin: IAgentPlugin): plugin is IAgentPlugin & { remove(): Promise } { return plugin.remove !== undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 4fb9dce2d36e3..c45b229b877a2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -314,7 +314,7 @@ registerAction2(class extends Action2 { type: 'question', }); if (result.confirmed) { - plugin.remove?.(); + await plugin.remove?.(); } } return; @@ -548,7 +548,7 @@ registerAction2(class extends Action2 { type: 'question', }); if (result.confirmed) { - plugin.remove?.(); + await plugin.remove?.(); } } }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index db35b380a4379..ac1ceb7a4db0e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -133,6 +133,16 @@ type CustomizationEditorSectionChangedClassification = { comment: 'Tracks section navigation within the Agent Customizations editor.'; }; +export function isCurrentPluginContributionNavigation( + requestGeneration: number, + currentGeneration: number, + requestedSection: AICustomizationManagementSection, + selectedSection: AICustomizationManagementSection | undefined, + isListView: boolean, +): boolean { + return requestGeneration === currentGeneration && requestedSection === selectedSection && isListView; +} + type CustomizationEditorItemSelectedEvent = { section: string; promptType: string; @@ -366,6 +376,7 @@ export class AICustomizationManagementEditor extends EditorPane { private readonly sections: ISectionItem[] = []; private readonly allSections: ISectionItem[] = []; private selectedSection: AICustomizationManagementSection | undefined; + private contentNavigationGeneration = 0; // Welcome page private welcomePage: AICustomizationWelcomePage | undefined; @@ -2016,6 +2027,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private updateContentVisibility(): void { + this.contentNavigationGeneration++; const isEditorMode = this.viewMode === 'editor'; const isMigrationMode = this.viewMode === 'migration'; const isMcpDetailMode = this.viewMode === 'mcpDetail'; @@ -3364,10 +3376,14 @@ export class AICustomizationManagementEditor extends EditorPane { this.sectionContextKey.set(section); this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER); this.updateContentVisibility(); + const navigationGeneration = this.contentNavigationGeneration; await this.listWidget.setSection(section); const modelSection = ITEMS_MODEL_SECTIONS.find(s => s === section); if (modelSection) { await this.itemsModel.whenSectionLoaded(modelSection); + if (!isCurrentPluginContributionNavigation(navigationGeneration, this.contentNavigationGeneration, section, this.selectedSection, this.viewMode === 'list')) { + return; + } const item = this.itemsModel.getItems(modelSection).get().find(item => isEqual(item.uri, uri)); if (item) { const source = item.source; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts index dd6a203da179b..ebfad9e982510 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts @@ -8,8 +8,9 @@ import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/butto import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { getErrorMessage, isCancellationError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; @@ -24,11 +25,11 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { defaultButtonStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { createPolicyBlockedEnableAction, createUninstallPluginAction, isPluginPolicyBlocked } from '../agentPluginActions.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { URI } from '../../../../../base/common/uri.js'; -import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, joinPath } from '../../../../../base/common/resources.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../platform/files/common/files.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -39,10 +40,10 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import type { IContextMenuProvider } from '../../../../../base/browser/contextmenu.js'; import { AnchorAlignment } from '../../../../../base/browser/ui/contextview/contextview.js'; import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; -import { getErrorMessage } from '../../../../../base/common/errors.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, waitForState } from '../../../../../base/common/observable.js'; const $ = DOM.$; +const INSTALL_REGISTRATION_TIMEOUT = 10_000; export interface IPluginReadme { readonly content: string; @@ -96,6 +97,27 @@ export async function loadPluginReadme( throw new Error(`Unsupported plugin README URI scheme: ${readmeUri.scheme}`); } +export async function waitForInstalledPlugin( + agentPluginService: Pick, + expectedUri: URI, + token: CancellationToken, +): Promise { + try { + const plugins = await waitForState( + agentPluginService.plugins, + plugins => plugins.some(plugin => isEqual(plugin.uri, expectedUri)), + undefined, + token, + ); + return plugins.find(plugin => isEqual(plugin.uri, expectedUri)); + } catch (error) { + if (isCancellationError(error)) { + return undefined; + } + throw error; + } +} + /** * Compact detail view for an agent plugin inside the AI Customizations management editor's * split-pane host. Renders identity, provenance, contribution summary, and description while @@ -131,6 +153,7 @@ export class EmbeddedAgentPluginDetail extends Disposable { private readonly copyStateReset = this._register(new MutableDisposable()); private readonly narrowLayoutUpdate = this._register(new MutableDisposable()); private readonly inputStateAutorun = this._register(new MutableDisposable()); + private readonly installWaitDisposables = this._register(new MutableDisposable()); private current: IAgentPluginItem | undefined; private narrowLayout = false; @@ -229,6 +252,7 @@ export class EmbeddedAgentPluginDetail extends Disposable { } setInput(item: IAgentPluginItem): void { + this.installWaitDisposables.clear(); this.current = item; this.renderItem(); if (item.kind === AgentPluginItemKind.Installed) { @@ -254,6 +278,7 @@ export class EmbeddedAgentPluginDetail extends Disposable { } clearInput(): void { + this.installWaitDisposables.clear(); this.current = undefined; this.inputStateAutorun.clear(); this.renderItem(); @@ -325,27 +350,44 @@ export class EmbeddedAgentPluginDetail extends Disposable { this.renderDisposables.add(installButton.onDidClick(async () => { installButton.label = localize('installing', "Installing..."); installButton.enabled = false; + const marketplacePlugin: IMarketplacePlugin = { + name: item.name, + description: item.description, + version: item.version ?? '', + source: item.source, + sourceDescriptor: item.sourceDescriptor, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + readmeUri: item.readmeUri, + }; try { - await this.pluginInstallService.installPlugin({ - name: item.name, - description: item.description, - version: item.version ?? '', - source: item.source, - sourceDescriptor: item.sourceDescriptor, - marketplace: item.marketplace, - marketplaceReference: item.marketplaceReference, - marketplaceType: item.marketplaceType, - readmeUri: item.readmeUri, - }); + await this.pluginInstallService.installPlugin(marketplacePlugin); + if (this._store.isDisposed || this.current !== item) { + return; + } + const waitDisposables = new DisposableStore(); + this.installWaitDisposables.value = waitDisposables; + const waitCts = new CancellationTokenSource(); + waitDisposables.add({ dispose: () => waitCts.dispose(true) }); + waitDisposables.add(disposableTimeout(() => waitCts.cancel(), INSTALL_REGISTRATION_TIMEOUT)); + const expectedUri = this.pluginInstallService.getPluginInstallUri(marketplacePlugin); + const plugin = await waitForInstalledPlugin(this.agentPluginService, expectedUri, waitCts.token); + if (this.installWaitDisposables.value === waitDisposables) { + this.installWaitDisposables.clear(); + } if (this._store.isDisposed || this.current !== item) { return; } - const installed = this.getInstalledPluginForMarketplaceItem(item); - if (installed) { + if (plugin) { installButton.label = localize('installed', "Installed"); - this.setInput(installed); + this.setInput(this.toInstalledPluginItem(plugin)); + } else { + installButton.label = localize('install', "Install"); + installButton.enabled = true; } } catch (error) { + this.installWaitDisposables.clear(); if (this._store.isDisposed || this.current !== item) { return; } @@ -375,9 +417,15 @@ export class EmbeddedAgentPluginDetail extends Disposable { uninstallButton.label = uninstallAction.label; uninstallButton.enabled = uninstallAction.enabled; this.renderDisposables.add(uninstallButton.onDidClick(async () => { - await uninstallAction.run(); - if (!this._store.isDisposed && this.current === item) { - this._onDidUninstall.fire(); + try { + const removed = await uninstallAction.runAndGetResult(); + if (removed && !this._store.isDisposed && this.current === item) { + this._onDidUninstall.fire(); + } + } catch (error) { + if (!this._store.isDisposed && this.current === item) { + this.notificationService.error(localize('pluginUninstallFailed', "Unable to uninstall plugin: {0}", getErrorMessage(error))); + } } })); } @@ -407,6 +455,10 @@ export class EmbeddedAgentPluginDetail extends Disposable { anchorAlignment: AnchorAlignment.RIGHT, }), }; + const alternateAction = this.renderDisposables.add(new Action('plugin.alternateScope', '', undefined, true, async () => { + const state = getPluginEnablementActionState(item.plugin.enablement.get()); + setEnablement(state.alternateState); + })); const splitButton = this.renderDisposables.add(new ButtonWithDropdown(this.titleActionsEl, { ...defaultButtonStyles, secondary: true, @@ -416,9 +468,8 @@ export class EmbeddedAgentPluginDetail extends Disposable { actions: { getActions: () => { const state = getPluginEnablementActionState(item.plugin.enablement.get()); - return [ - this.renderDisposables.add(new Action(`plugin.${state.isEnabled ? 'exclude' : 'include'}AlternateScope`, state.alternateLabel, undefined, true, async () => setEnablement(state.alternateState))) - ]; + alternateAction.label = state.alternateLabel; + return [alternateAction]; }, }, ariaLabel: '', @@ -434,22 +485,7 @@ export class EmbeddedAgentPluginDetail extends Disposable { this.renderDisposables.add(splitButton.onDidClick(() => setEnablement(getPluginEnablementActionState(item.plugin.enablement.get()).primaryState))); } - private getInstalledPluginForMarketplaceItem(item: Extract): IAgentPluginItem | undefined { - const expectedUri = this.pluginInstallService.getPluginInstallUri({ - name: item.name, - description: item.description, - version: item.version ?? '', - source: item.source, - sourceDescriptor: item.sourceDescriptor, - marketplace: item.marketplace, - marketplaceReference: item.marketplaceReference, - marketplaceType: item.marketplaceType, - readmeUri: item.readmeUri, - }); - const plugin = this.agentPluginService.plugins.get().find(plugin => plugin.uri.toString() === expectedUri.toString()); - if (!plugin) { - return undefined; - } + private toInstalledPluginItem(plugin: IAgentPlugin): IAgentPluginItem { return { kind: AgentPluginItemKind.Installed, name: plugin.label || basename(plugin.uri), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 9743a468d2c3c..2910ce60e8403 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -700,8 +700,8 @@ export function updateMcpCardRuntimePresentation( description.textContent = descriptionText; } -export function shouldLoadMcpGallerySnapshot(visible: boolean, query: string, itemCount: number, failed: boolean, loading: boolean): boolean { - return visible && !query.trim() && itemCount === 0 && !failed && !loading; +export function shouldLoadMcpGallerySnapshot(visible: boolean, query: string, itemCount: number, failed: boolean, loading: boolean, accessEnabled: boolean): boolean { + return accessEnabled && visible && !query.trim() && itemCount === 0 && !failed && !loading; } export function hasSameMcpMembership(previous: string, current: string): boolean { @@ -731,6 +731,10 @@ export function getActiveSessionServerLifecycleAction(server: AgentHostMcpServer type AgentHostMcpServerEnablementScope = 'global' | 'workspace' | 'session'; +function isHostOwnedPluginMcpServer(server: AgentHostMcpServer): boolean { + return server.isPluginProvided === true && !server.isClientBundled; +} + const agentHostMcpServerEnablementActionInfo = { global: { kind: CustomizationEnablementKind.Global, @@ -777,6 +781,62 @@ export function getAgentHostMcpServerEnablementActions(agentHostCustomizations: return actions; } +export function setPrimaryMcpServerEnablement( + mcpService: IMcpService, + agentHostCustomizations: IAgentHostCustomizationService, + sessionResource: URI, + localServerId: string | undefined, + activeSessionServer: AgentHostMcpServer | undefined, + enabled: boolean, +): void { + if (activeSessionServer && isHostOwnedPluginMcpServer(activeSessionServer)) { + agentHostCustomizations.setCustomizationEnablement( + sessionResource, + activeSessionServer.id, + activeSessionServer.enablement, + CustomizationEnablementKind.Global, + enabled, + ); + return; + } + if (localServerId) { + const current = mcpService.enablementModel.readEnabled(localServerId); + const next = getToggledMcpEnablementState(current); + if (isContributionEnabled(next) !== enabled) { + throw new Error(`Unexpected MCP enablement transition for ${localServerId}.`); + } + mcpService.enablementModel.setEnabled(localServerId, next); + return; + } + if (!activeSessionServer) { + throw new Error('Cannot update MCP enablement without a durable server target.'); + } + agentHostCustomizations.setCustomizationEnablement( + sessionResource, + activeSessionServer.id, + activeSessionServer.enablement, + CustomizationEnablementKind.Global, + enabled, + ); +} + +export function isPrimaryMcpServerEnabled( + mcpService: IMcpService, + localServerId: string | undefined, + activeSessionServer: AgentHostMcpServer | undefined, +): boolean { + if (activeSessionServer && isHostOwnedPluginMcpServer(activeSessionServer)) { + return getCustomizationScopeEnablement(activeSessionServer).global; + } + if (localServerId) { + return isContributionEnabled(mcpService.enablementModel.readEnabled(localServerId)); + } + if (activeSessionServer) { + return getCustomizationScopeEnablement(activeSessionServer).global; + } + return true; +} + function createAgentHostMcpServerEnablementAction(agentHostCustomizations: IAgentHostCustomizationService, sessionResource: URI, server: AgentHostMcpServer, enabled: boolean, scope: AgentHostMcpServerEnablementScope): IAction { const actionInfo = agentHostMcpServerEnablementActionInfo[scope]; return new Action( @@ -822,7 +882,7 @@ export function getBuiltinMcpServerEnablementActions(mcpService: IMcpService, se if (activeSessionServer === undefined) { return getLocalMcpServerEnablementActions(mcpService, serverId, isEmptyWorkbench); } - if (activeSessionServer.isPluginProvided && !activeSessionServer.isClientBundled) { + if (isHostOwnedPluginMcpServer(activeSessionServer)) { return getAgentHostMcpServerEnablementActions(agentHostCustomizations, agentPluginService, sessionResource, activeSessionServer); } return [ @@ -1020,6 +1080,7 @@ export class McpListWidget extends Disposable { private gallerySnapshotLoading = false; private gallerySearchLoading = false; private visible = false; + private mcpAccessEnabled = false; private firstCardFocusElement: HTMLElement | undefined; private cardScrollElement: HTMLElement | undefined; private availableSection: HTMLElement | undefined; @@ -1062,6 +1123,7 @@ export class McpListWidget extends Disposable { )); this._register(resizeObserver.observe(this.element)); this.updateAccessState(); + void this.refresh(); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpAccessConfig)) { this.updateAccessState(); @@ -1069,7 +1131,9 @@ export class McpListWidget extends Disposable { })); this._register({ dispose: () => { - this.galleryCts?.dispose(); + this.delayedFilter.cancel(); + this.delayedGallerySearch.cancel(); + this.galleryCts?.dispose(true); } }); } @@ -1131,12 +1195,18 @@ export class McpListWidget extends Disposable { this._register(this.searchInput.onDidChange(() => { this.searchQuery = this.searchInput.value; this.galleryCts?.dispose(true); + this.galleryCts = undefined; this.searchInput.hideMessage(); const query = this.searchQuery.toLowerCase().trim(); this.galleryServers = query ? this.gallerySnapshotServers.filter(server => this.matchesGalleryServerQuery(server, query)) : [...this.gallerySnapshotServers]; this.delayedFilter.trigger(() => this.filterServers()); + if (!this.mcpAccessEnabled) { + this.gallerySearchLoading = false; + this.delayedGallerySearch.cancel(); + return; + } if (query) { this.gallerySearchLoading = true; this.delayedGallerySearch.trigger(() => this.queryMcpSearch()); @@ -1190,13 +1260,11 @@ export class McpListWidget extends Disposable { } })); - // Initial refresh - void this.refresh(); } private async refresh(): Promise { this.filterServers(); - if (shouldLoadMcpGallerySnapshot(this.visible, this.searchQuery, this.gallerySnapshotServers.length, this.gallerySnapshotFailed, this.gallerySnapshotLoading)) { + if (shouldLoadMcpGallerySnapshot(this.visible, this.searchQuery, this.gallerySnapshotServers.length, this.gallerySnapshotFailed, this.gallerySnapshotLoading, this.mcpAccessEnabled)) { await this.queryGallerySnapshot(); } } @@ -1216,10 +1284,18 @@ export class McpListWidget extends Disposable { const value = inspect.value ?? inspect.defaultValue; const disabled = value === McpAccessValue.None; const policyLocked = inspect.policyValue === McpAccessValue.None; + const accessChanged = this.mcpAccessEnabled === disabled; + this.mcpAccessEnabled = !disabled; this.element.classList.toggle('access-disabled', disabled); if (disabled) { + this.delayedGallerySearch.cancel(); + this.galleryCts?.dispose(true); + this.galleryCts = undefined; + this.gallerySnapshotLoading = false; + this.gallerySearchLoading = false; + this.searchInput.hideMessage(); this.disabledIcon.className = 'empty-icon'; this.disabledIcon.classList.add(...ThemeIcon.asClassNameArray(policyLocked ? Codicon.shield : mcpServerIcon)); @@ -1238,23 +1314,35 @@ export class McpListWidget extends Disposable { this.commandService.executeCommand('workbench.action.openSettings', `@id:${mcpAccessConfig}`); }); } + } else if (accessChanged && this.visible) { + if (this.searchQuery.trim()) { + void this.queryMcpSearch(); + } else { + void this.refresh(); + } } } public showBrowseMarketplace(): void { + if (!this.mcpAccessEnabled) { + return; + } this.searchInput.value = ''; this.searchQuery = ''; void this.queryGallerySnapshot(true); } private async queryGallerySnapshot(revealMarketplace = false): Promise { + if (!this.mcpAccessEnabled) { + return; + } this.galleryCts?.dispose(true); const cts = this.galleryCts = new CancellationTokenSource(); this.gallerySnapshotLoading = true; try { const pager = await this.mcpWorkbenchService.queryGallery(undefined, cts.token); - if (cts.token.isCancellationRequested || this.searchQuery.trim()) { + if (this.galleryCts !== cts || cts.token.isCancellationRequested || !this.mcpAccessEnabled || this.searchQuery.trim()) { return; } @@ -1267,7 +1355,7 @@ export class McpListWidget extends Disposable { this.availableSection?.scrollIntoView({ block: 'start' }); } } catch { - if (!cts.token.isCancellationRequested) { + if (this.galleryCts === cts && !cts.token.isCancellationRequested && this.mcpAccessEnabled) { this.gallerySnapshotServers = []; this.galleryServers = []; this.gallerySnapshotFailed = true; @@ -1283,7 +1371,7 @@ export class McpListWidget extends Disposable { private async queryMcpSearch(): Promise { const query = this.searchQuery.trim(); - if (!query) { + if (!query || !this.mcpAccessEnabled) { return; } @@ -1292,13 +1380,13 @@ export class McpListWidget extends Disposable { this.gallerySearchLoading = true; try { const pager = await this.mcpWorkbenchService.queryGallery({ text: query }, cts.token); - if (cts.token.isCancellationRequested || this.searchQuery.trim() !== query) { + if (this.galleryCts !== cts || cts.token.isCancellationRequested || !this.mcpAccessEnabled || this.searchQuery.trim() !== query) { return; } this.galleryServers = pager.firstPage.items; this.searchInput.hideMessage(); } catch { - if (!cts.token.isCancellationRequested) { + if (this.galleryCts === cts && !cts.token.isCancellationRequested && this.mcpAccessEnabled && this.searchQuery.trim() === query) { this.galleryServers = this.gallerySnapshotServers.filter(server => this.matchesGalleryServerQuery(server, query.toLowerCase())); this.searchInput.showMessage({ content: localize('mcpSearchMarketplaceUnavailable', "Marketplace results are unavailable. Showing installed MCP servers only."), @@ -1306,7 +1394,7 @@ export class McpListWidget extends Disposable { }); } } finally { - if (this.galleryCts === cts) { + if (this.galleryCts === cts && this.mcpAccessEnabled && this.searchQuery.trim() === query) { this.gallerySearchLoading = false; this.filterServers(); } @@ -1696,36 +1784,23 @@ export class McpListWidget extends Disposable { private isInstalledEntryEnabled(entry: IMcpInstalledEntry): boolean { const activeSessionServer = getActiveSessionServer(entry); - if (activeSessionServer) { - return activeSessionServer.enabled; - } const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; - if (localServer) { - return isContributionEnabled(localServer.enablement.get()); - } - if (entry.type === 'server-item') { - return isContributionEnabled(this.mcpService.enablementModel.readEnabled(entry.server.id)); - } - return true; + const serverId = localServer?.definition.id ?? (entry.type === 'server-item' ? entry.server.id : undefined); + return isPrimaryMcpServerEnabled(this.mcpService, serverId, activeSessionServer); } private setInstalledEntryEnabled(entry: IMcpInstalledEntry, enabled: boolean): void { const activeSessionServer = getActiveSessionServer(entry); - if (activeSessionServer) { - activeSessionServer.setEnabled(enabled); - return; - } const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; const serverId = localServer?.definition.id ?? (entry.type === 'server-item' ? entry.server.id : undefined); - if (!serverId) { - return; - } - const current = this.mcpService.enablementModel.readEnabled(serverId); - const next = getToggledMcpEnablementState(current); - if (isContributionEnabled(next) !== enabled) { - throw new Error(`Unexpected MCP enablement transition for ${serverId}.`); - } - this.mcpService.enablementModel.setEnabled(serverId, next); + setPrimaryMcpServerEnablement( + this.mcpService, + this.agentHostCustomizationService, + this.customizationHarnessService.activeSessionResource.get(), + serverId, + activeSessionServer, + enabled, + ); } private updateSearchResults(): void { @@ -2043,7 +2118,7 @@ export class McpListWidget extends Disposable { type: 'question', }); if (result.confirmed) { - plugin.remove?.(); + await plugin.remove?.(); } } ))); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 0e52cb8258aff..69a83b62e2650 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -96,6 +96,20 @@ export function shouldLoadPluginMarketplaceSnapshot(visible: boolean, state: Plu return visible && state === 'uninitialized' && marketplaceAvailable; } +export function isCurrentPluginMarketplaceRequest( + requestQuery: string, + currentQuery: string, + requestBrowseMode: boolean, + currentBrowseMode: boolean, + isActiveRequest: boolean, + isCancellationRequested: boolean, +): boolean { + return isActiveRequest + && !isCancellationRequested + && requestQuery === currentQuery + && requestBrowseMode === currentBrowseMode; +} + //#region Entry types /** @@ -661,6 +675,7 @@ export class PluginListWidget extends Disposable { private marketplaceSnapshotCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); private readonly delayedMarketplaceSearch = new Delayer(400); + private filterGeneration = 0; constructor( private readonly marketplaceBrowsingAvailable = !isWeb, @@ -696,6 +711,8 @@ export class PluginListWidget extends Disposable { })); this._register({ dispose: () => { + this.delayedFilter.cancel(); + this.delayedMarketplaceSearch.cancel(); this.marketplaceCts?.dispose(true); this.marketplaceCts = undefined; this.marketplaceSnapshotCts?.dispose(true); @@ -779,11 +796,15 @@ export class PluginListWidget extends Disposable { this._register(this.searchInput.onDidChange(() => { this.searchQuery = this.searchInput.value; + this.marketplaceCts?.dispose(true); + this.marketplaceCts = undefined; if (this.browseMode) { this.delayedMarketplaceSearch.trigger(() => this.queryMarketplace()); } else if (this.searchQuery.trim()) { this.delayedMarketplaceSearch.trigger(() => this.queryPluginSearch()); } else { + this.delayedMarketplaceSearch.cancel(); + this.marketplaceItems = []; this.searchInput.hideMessage(); this.delayedFilter.trigger(() => this.filterPlugins()); } @@ -948,10 +969,7 @@ export class PluginListWidget extends Disposable { // Listen to plugin service changes this._register(autorun(reader => { - const plugins = this.agentPluginService.plugins.read(reader); - for (const plugin of plugins) { - plugin.enablement.read(reader); - } + this.agentPluginService.plugins.read(reader); void this.refresh(); })); this._register(this.pluginMarketplaceService.onDidChangeMarketplaces(() => { @@ -1336,8 +1354,6 @@ export class PluginListWidget extends Disposable { private appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-installed-item')); - const enabled = isContributionEnabled(item.plugin.enablement.get()); - row.classList.toggle('disabled', !enabled || item.plugin.policyBlocked?.get() === true); const primaryAction = this.addSurfaceActivation(row, localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin)), () => this._onDidSelectPlugin.fire(item)); const details = DOM.append(primaryAction, $('.plugin-list-item-details')); @@ -1358,7 +1374,7 @@ export class PluginListWidget extends Disposable { metadata.style.display = metadata.textContent ? '' : 'none'; const actions = DOM.append(row, $('.plugin-list-item-action')); - const toggle = this.appendInstalledPluginToggle(actions, item); + const toggle = this.appendInstalledPluginToggle(actions, row, primaryAction, item); const more = this.cardDisposables.add(new Button(actions, { ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), secondary: true, supportIcons: true, ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name) })); more.element.classList.add('plugin-card-icon-button'); more.label = `$(${Codicon.ellipsis.id})`; @@ -1372,25 +1388,36 @@ export class PluginListWidget extends Disposable { }); } - private appendInstalledPluginToggle(parent: HTMLElement, item: IInstalledPluginItem): HTMLButtonElement { - const current = item.plugin.enablement.get(); - const checked = isContributionEnabled(current); - const workspaceScope = current === ContributionEnablementState.EnabledWorkspace || current === ContributionEnablementState.DisabledWorkspace; - const blocked = isPluginPolicyBlocked(item.plugin); - const toggleLabel = checked - ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) - : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); + private appendInstalledPluginToggle(parent: HTMLElement, row: HTMLElement, primaryAction: HTMLElement, item: IInstalledPluginItem): HTMLButtonElement { + let renderedState = item.plugin.enablement.get(); const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; switchElement.type = 'button'; - switchElement.disabled = blocked; switchElement.setAttribute('role', 'switch'); - switchElement.setAttribute('aria-checked', String(checked)); - switchElement.setAttribute('aria-label', blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel); - switchElement.classList.toggle('checked', checked); - switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + const update = (state: ContributionEnablementState, blocked: boolean) => { + renderedState = state; + const checked = isContributionEnabled(state); + const workspaceScope = state === ContributionEnablementState.EnabledWorkspace || state === ContributionEnablementState.DisabledWorkspace; + const toggleLabel = checked + ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) + : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); + const accessibleLabel = blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel; + switchElement.disabled = blocked; + switchElement.setAttribute('aria-checked', String(checked)); + switchElement.setAttribute('aria-label', accessibleLabel); + switchElement.classList.toggle('checked', checked); + switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + row.classList.toggle('disabled', !checked || blocked); + primaryAction.setAttribute('aria-label', localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin))); + }; + this.cardDisposables.add(autorun(reader => { + const state = item.plugin.enablement.read(reader); + const blocked = item.plugin.policyBlocked?.read(reader) === true; + update(state, blocked); + })); this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { - const nextState = getToggledPluginEnablementState(current); + const nextState = getToggledPluginEnablementState(renderedState); + update(nextState, isPluginPolicyBlocked(item.plugin)); this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); status(localize('pluginInclusionChanged', "{0}. {1}.", item.name, getPluginInclusionLabel(item.plugin))); })); @@ -1678,6 +1705,9 @@ export class PluginListWidget extends Disposable { } private toggleBrowseMode(browse: boolean): void { + this.delayedMarketplaceSearch.cancel(); + this.marketplaceCts?.dispose(true); + this.marketplaceCts = undefined; this.browseMode = browse; this.element.classList.toggle('browse-mode', browse); this.searchInput.value = ''; @@ -1693,7 +1723,6 @@ export class PluginListWidget extends Disposable { if (browse) { void this.queryMarketplace(); } else { - this.marketplaceCts?.dispose(true); this.marketplaceItems = []; void this.filterPlugins(); } @@ -1707,6 +1736,8 @@ export class PluginListWidget extends Disposable { private async queryMarketplace(): Promise { this.marketplaceCts?.dispose(true); const cts = this.marketplaceCts = new CancellationTokenSource(); + const query = this.searchQuery.toLowerCase().trim(); + const browseMode = this.browseMode; // Show loading state this.showEmptySurface(); @@ -1716,18 +1747,22 @@ export class PluginListWidget extends Disposable { try { const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); - if (cts.token.isCancellationRequested) { + if (!this.isCurrentMarketplaceRequest(cts, query, browseMode)) { return; } - const query = this.searchQuery.toLowerCase().trim(); if (query) { const allPlugins = this.agentPluginService.plugins.get(); - this.installedItems = allPlugins + const installedItems = allPlugins .map(p => installedPluginToItem(p, this.labelService)) .filter(item => item.name.toLowerCase().includes(query) || item.description.toLowerCase().includes(query)) .sort(compareInstalledPluginItems); - this.remoteItems = [...await this.getRemotePluginItems(query)]; + const remoteItems = [...await this.getRemotePluginItems(query)]; + if (!this.isCurrentMarketplaceRequest(cts, query, browseMode)) { + return; + } + this.installedItems = installedItems; + this.remoteItems = remoteItems; } const filtered = query ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.marketplace.toLowerCase().includes(query)) @@ -1748,7 +1783,7 @@ export class PluginListWidget extends Disposable { this.updateMarketplaceList(); } } catch { - if (!cts.token.isCancellationRequested) { + if (this.isCurrentMarketplaceRequest(cts, query, browseMode)) { this.marketplaceItems = []; this.showEmptySurface(); this.emptyText.textContent = localize('marketplaceError', "Unable to load marketplace"); @@ -1764,33 +1799,69 @@ export class PluginListWidget extends Disposable { return; } + const query = this.searchQuery.toLowerCase().trim(); + if (!query || this.browseMode) { + return; + } this.marketplaceCts?.dispose(true); const cts = this.marketplaceCts = new CancellationTokenSource(); try { const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); - if (cts.token.isCancellationRequested || this.browseMode) { + if (!this.isCurrentMarketplaceRequest(cts, query, false)) { + return; + } + const installedItems = this.agentPluginService.plugins.get() + .map(p => installedPluginToItem(p, this.labelService)) + .filter(item => item.name.toLowerCase().includes(query) || item.description.toLowerCase().includes(query)) + .sort(compareInstalledPluginItems); + const remoteItems = [...await this.getRemotePluginItems(query)]; + if (!this.isCurrentMarketplaceRequest(cts, query, false)) { return; } - const query = this.searchQuery.toLowerCase().trim(); const filtered = query ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.marketplace.toLowerCase().includes(query)) : plugins; const installedUris = new Set(this.agentPluginService.plugins.get().map(p => p.uri.toString())); - this.marketplaceItems = filtered + const marketplaceItems = filtered .filter(p => { const expectedUri = this.pluginInstallService.getPluginInstallUri(p); return !installedUris.has(expectedUri.toString()); }) .map(marketplacePluginToItem); + if (!this.isCurrentMarketplaceRequest(cts, query, false)) { + return; + } + this.installedItems = installedItems; + this.remoteItems = remoteItems; + this.marketplaceItems = marketplaceItems; this.searchInput.hideMessage(); } catch { + if (!this.isCurrentMarketplaceRequest(cts, query, false)) { + return; + } this.marketplaceItems = []; this.searchInput.showMessage({ content: localize('pluginSearchMarketplaceUnavailable', "Marketplace results are unavailable. Showing installed plugins only."), type: MessageType.WARNING, }); + await this.filterPlugins(); + return; } - await this.filterPlugins(); + if (this.isCurrentMarketplaceRequest(cts, query, false)) { + this.updateSearchResultsList(); + this._onDidChangeItemCount.fire(this.itemCount); + } + } + + private isCurrentMarketplaceRequest(cts: CancellationTokenSource, query: string, browseMode: boolean): boolean { + return isCurrentPluginMarketplaceRequest( + query, + this.searchQuery.toLowerCase().trim(), + browseMode, + this.browseMode, + this.marketplaceCts === cts, + cts.token.isCancellationRequested, + ); } private updateMarketplaceList(): void { @@ -1872,9 +1943,15 @@ export class PluginListWidget extends Disposable { } private async filterPlugins(): Promise { + const generation = ++this.filterGeneration; const query = this.searchQuery.toLowerCase().trim(); + const browseMode = this.browseMode; const allPlugins = this.agentPluginService.plugins.get(); - this.remoteItems = [...await this.getRemotePluginItems(query)]; + const remoteItems = [...await this.getRemotePluginItems(query)]; + if (generation !== this.filterGeneration || this.searchQuery.toLowerCase().trim() !== query || this.browseMode !== browseMode) { + return; + } + this.remoteItems = remoteItems; this.installedItems = allPlugins .map(p => installedPluginToItem(p, this.labelService)) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 0928075ef46a8..42226bbc82d37 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -46,7 +46,7 @@ export interface IAgentPlugin { */ readonly policyBlocked?: IObservable; /** Removes this plugin from its discovery source (config or installed storage). Undefined for policy-managed plugins that cannot be removed by the user. */ - remove?(): void; + remove?(): Promise; readonly hooks: IObservable; readonly commands: IObservable; readonly skills: IObservable; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 726299ae6cfa7..3506842608a21 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -237,7 +237,7 @@ interface IPluginSource { /** Repository root that serves as the boundary for component path resolution. */ readonly repositoryUri?: URI; /** Called when remove is invoked on the plugin; absent for policy-managed plugins */ - remove?(): void; + remove?(): Promise; } /** @@ -331,7 +331,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => void) | undefined, version: number): Promise { + private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => Promise) | undefined, version: number): Promise { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -681,7 +681,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery return sources; } - private async _addPluginSource(sources: IPluginSource[], resource: URI, label: string, remove?: () => void): Promise { + private async _addPluginSource(sources: IPluginSource[], resource: URI, label: string, remove?: () => Promise): Promise { let stat; try { stat = await this._fileService.resolve(resource); @@ -744,7 +744,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery * Removes a plugin path from `chat.pluginLocations` in the most specific * config target where the key is defined. */ - private _removePluginPath(configKey: string): void { + private async _removePluginPath(configKey: string): Promise { const inspected = this._configurationService.inspect>(ChatConfiguration.PluginLocations); const targets = [ @@ -761,14 +761,15 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { const updated = { ...mapping }; delete updated[configKey]; - this._configurationService.updateValue( + await this._configurationService.updateValue( ChatConfiguration.PluginLocations, updated, target, ); - return; + return true; } } + return false; } } @@ -819,7 +820,7 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover uri: stat.resource, fromMarketplace: entry.plugin, repositoryUri, - remove: () => { + remove: async () => { this._enablementModel.remove(stat.resource.toString()); this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); @@ -832,6 +833,7 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover ).catch(error => { this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error); }); + return true; }, }); } @@ -995,21 +997,23 @@ export class CopilotCliAgentPluginDiscovery extends AbstractAgentPluginDiscovery return sources; } - private async _promptRemove(resource: URI): Promise { + private async _promptRemove(resource: URI): Promise { const { confirmed } = await this._dialogService.confirm({ message: localize('copilotCliPlugin.remove.confirm', "This plugin was installed by the Copilot CLI. Remove it from disk?"), detail: localize('copilotCliPlugin.remove.detail', "The plugin directory '{0}' will be moved to the trash. You can reinstall it later via the Copilot CLI.", resource.fsPath), primaryButton: localize('copilotCliPlugin.remove.primary', "Remove"), }); if (!confirmed) { - return; + return false; } try { await this._fileService.del(resource, { recursive: true, useTrash: true }); this._enablementModel.remove(resource.toString()); + return true; } catch (error) { this._logService.error('[CopilotCliAgentPluginDiscovery] Failed to remove plugin', error); + throw error; } } } @@ -1148,13 +1152,15 @@ export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery return sources; } - private async _promptUninstallExtension(extensionId: string): Promise { + private async _promptUninstallExtension(extensionId: string): Promise { const { confirmed } = await this._dialogService.confirm({ message: localize('uninstallExtensionForPlugin', "This plugin is provided by the extension '{0}'. Do you want to uninstall the extension?", extensionId), }); if (confirmed) { await this._commandService.executeCommand('workbench.extensions.uninstallExtension', extensionId); + return true; } + return false; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts index 95016351ddff8..093ccd511a33f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts @@ -17,7 +17,7 @@ import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPlu suite('AgentPluginActions', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createPlugin(remove?: () => void): IAgentPlugin { + function createPlugin(remove?: () => Promise): IAgentPlugin { return { uri: URI.file('/plugins/local-plugin'), format: PluginFormat.Copilot, @@ -43,7 +43,10 @@ suite('AgentPluginActions', () => { test('creates uninstall action for a removable local plugin', async () => { let removeCount = 0; - const action = createUninstallPluginAction(createPlugin(() => removeCount++)); + const action = createUninstallPluginAction(createPlugin(async () => { + removeCount++; + return true; + })); assert.ok(action); store.add(action); @@ -52,6 +55,14 @@ suite('AgentPluginActions', () => { assert.strictEqual(removeCount, 1); }); + test('returns the plugin removal result to direct callers', async () => { + const action = createUninstallPluginAction(createPlugin(async () => false)); + + assert.ok(action); + store.add(action); + assert.strictEqual(await action.runAndGetResult(), false); + }); + test('does not create uninstall action for a non-removable plugin', () => { assert.strictEqual(createUninstallPluginAction(createPlugin()), undefined); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 8bf4b61183dc7..9db7ad037680d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -160,7 +160,7 @@ suite('AICustomizationItemsModel', () => { format: PluginFormat.Copilot, label: name, enablement: observableValue('pluginEnablement', ContributionEnablementState.EnabledProfile), - remove: () => { }, + remove: async () => true, hooks: observableValue('pluginHooks', []), commands: observableValue('pluginCommands', []), skills: observableValue('pluginSkills', []), @@ -644,7 +644,7 @@ suite('AICustomizationItemsModel', () => { format: PluginFormat.Copilot, label: name, enablement: observableValue('pluginEnablement', ContributionEnablementState.EnabledProfile), - remove: () => { }, + remove: async () => true, hooks: observableValue('pluginHooks', []), commands: observableValue('pluginCommands', []), skills: observableValue('pluginSkills', []), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 784e0e3e834d6..e2cee3a93651e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -13,7 +13,7 @@ import type { IManagedHover } from '../../../../../../base/browser/ui/hover/hove import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { AICustomizationManagementEditor } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IPromptPath, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { IHeaderAttribute } from '../../../common/promptSyntax/promptFileParser.js'; @@ -41,6 +41,15 @@ suite('aiCustomizationManagementEditor', () => { ]); }); + test('rejects stale plugin contribution navigation', () => { + assert.deepStrictEqual([ + isCurrentPluginContributionNavigation(2, 2, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Skills, true), + isCurrentPluginContributionNavigation(1, 2, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Skills, true), + isCurrentPluginContributionNavigation(2, 2, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Agents, true), + isCurrentPluginContributionNavigation(2, 2, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Skills, false), + ], [true, false, false, false]); + }); + type TestableEditor = { currentEditingPromptType: PromptsType | undefined; currentEditingSource: string | undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts index 4a3a5d5572782..5d9a3073e4dd9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { VSBuffer, bufferToStream } from '../../../../../../base/common/buffer.js'; import { Event } from '../../../../../../base/common/event.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IRequestContext } from '../../../../../../base/parts/request/common/request.js'; @@ -13,7 +15,7 @@ import { FileOperationError, FileOperationResult, IFileService } from '../../../ import { IRequestService } from '../../../../../../platform/request/common/request.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { AgentPluginItemKind, IAgentPluginItem, IMarketplacePluginItem } from '../../../browser/agentPluginEditor/agentPluginItems.js'; -import { getPluginVersion, loadPluginReadme, PluginReadmeRenderGuard } from '../../../browser/aiCustomization/embeddedAgentPluginDetail.js'; +import { getPluginVersion, loadPluginReadme, PluginReadmeRenderGuard, waitForInstalledPlugin } from '../../../browser/aiCustomization/embeddedAgentPluginDetail.js'; import { MarketplaceType, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { parseMarketplaceReference } from '../../../common/plugins/marketplaceReference.js'; import { IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; @@ -54,7 +56,7 @@ function createMarketplaceItem(readmeUri: URI): IMarketplacePluginItem { } suite('embeddedAgentPluginDetail', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const fileService = new class extends mock() { }(); @@ -72,6 +74,29 @@ suite('embeddedAgentPluginDetail', () => { }); }); + test('waits for asynchronous installed plugin discovery', async () => { + const expectedUri = URI.file('/plugins/example'); + const plugins = observableValue('plugins', []); + const plugin = new class extends mock() { + override readonly uri = expectedUri; + }(); + const result = waitForInstalledPlugin({ plugins }, expectedUri, CancellationToken.None); + + plugins.set([plugin], undefined); + + assert.strictEqual(await result, plugin); + }); + + test('stops waiting for installed plugin discovery when cancelled', async () => { + const plugins = observableValue('plugins', []); + const cts = disposables.add(new CancellationTokenSource()); + const result = waitForInstalledPlugin({ plugins }, URI.file('/plugins/example'), cts.token); + + cts.cancel(); + + assert.strictEqual(await result, undefined); + }); + test('reads marketplace plugin versions', () => { assert.strictEqual( getPluginVersion(createMarketplaceItem(URI.parse('https://example.test/README.md'))), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 91a8362508b30..2697adc59ee27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -9,13 +9,15 @@ import { Button, unthemedButtonStyles } from '../../../../../../base/browser/ui/ import { URI } from '../../../../../../base/common/uri.js'; import { Action, IAction, Separator } from '../../../../../../base/common/actions.js'; import { Emitter } from '../../../../../../base/common/event.js'; -import { Disposable, DisposableStore, isDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, isDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { CustomizationEnablementKind, McpServerStatus, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; @@ -36,14 +38,17 @@ import { getMcpServerOutputHandler, getMcpStatusPresentation, isMcpServerCollectionVisible, + isPrimaryMcpServerEnabled, getMcpStatusRenderSignature, getServerItemContextMenuActions, getToggledMcpEnablementState, + McpListWidget, McpServerItemRenderer, registerMcpInlineButtonAction, type IMcpStatusRenderInput, updateMcpCardRuntimePresentation, hasSameMcpMembership, + setPrimaryMcpServerEnablement, shouldLoadMcpGallerySnapshot, } from '../../../browser/aiCustomization/mcpListWidget.js'; @@ -105,6 +110,65 @@ function trackActions(store: Pick, actions: readonly IAc return [...actions]; } +type McpAccessTestWidget = { + element: HTMLElement; + mcpAccessEnabled: boolean; + visible: boolean; + searchQuery: string; + access: McpAccessValue; + policyAccess: McpAccessValue | undefined; + configurationService: IConfigurationService; + delayedGallerySearch: { cancel(): void }; + delayedCancelCount: number; + galleryCts: { dispose(cancel?: boolean): void } | undefined; + requestCancelCount: number; + gallerySnapshotLoading: boolean; + gallerySearchLoading: boolean; + searchInput: { hideMessage(): void }; + disabledIcon: HTMLElement; + disabledMessage: HTMLElement; + disabledLinkListener: MutableDisposable<{ dispose(): void }>; + commandService: ICommandService; + queryCount: number; + refreshCount: number; + queryMcpSearch(): Promise; + refresh(): Promise; + updateAccessState(): void; +}; + +function createMcpAccessTestWidget(access: McpAccessValue, policyAccess: McpAccessValue | undefined, store: Pick): McpAccessTestWidget { + const widget = Object.create(McpListWidget.prototype) as McpAccessTestWidget; + widget.element = document.createElement('div'); + widget.mcpAccessEnabled = false; + widget.visible = false; + widget.searchQuery = ''; + widget.access = access; + widget.policyAccess = policyAccess; + widget.configurationService = { + inspect: (key: string) => key === mcpAccessConfig ? { + value: widget.access, + defaultValue: McpAccessValue.All, + policyValue: widget.policyAccess, + } : undefined, + } as unknown as IConfigurationService; + widget.delayedCancelCount = 0; + widget.delayedGallerySearch = { cancel: () => widget.delayedCancelCount++ }; + widget.galleryCts = undefined; + widget.requestCancelCount = 0; + widget.gallerySnapshotLoading = false; + widget.gallerySearchLoading = false; + widget.searchInput = { hideMessage() { } }; + widget.disabledIcon = document.createElement('div'); + widget.disabledMessage = document.createElement('div'); + widget.disabledLinkListener = store.add(new MutableDisposable()); + widget.commandService = { executeCommand: async () => undefined } as unknown as ICommandService; + widget.queryCount = 0; + widget.refreshCount = 0; + widget.queryMcpSearch = async () => { widget.queryCount++; }; + widget.refresh = async () => { widget.refreshCount++; }; + return widget; +} + suite('mcpListWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -171,11 +235,122 @@ suite('mcpListWidget', () => { test('loads gallery snapshots only for visible MCP sections', () => { assert.deepStrictEqual([ - shouldLoadMcpGallerySnapshot(false, '', 0, false, false), - shouldLoadMcpGallerySnapshot(true, '', 0, false, false), - shouldLoadMcpGallerySnapshot(true, 'search', 0, false, false), - shouldLoadMcpGallerySnapshot(true, '', 1, false, false), - ], [false, true, false, false]); + shouldLoadMcpGallerySnapshot(false, '', 0, false, false, true), + shouldLoadMcpGallerySnapshot(true, '', 0, false, false, true), + shouldLoadMcpGallerySnapshot(true, 'search', 0, false, false, true), + shouldLoadMcpGallerySnapshot(true, '', 1, false, false, true), + shouldLoadMcpGallerySnapshot(true, '', 0, false, false, false), + ], [false, true, false, false, false]); + }); + + test('shows access-disabled UI before gallery work starts', () => { + const widget = createMcpAccessTestWidget(McpAccessValue.None, McpAccessValue.None, disposables); + + widget.updateAccessState(); + + assert.deepStrictEqual({ + accessEnabled: widget.mcpAccessEnabled, + disabledClass: widget.element.classList.contains('access-disabled'), + message: widget.disabledMessage.textContent, + }, { + accessEnabled: false, + disabledClass: true, + message: 'Access to MCP servers is disabled by your organization. Contact your organization administrator for more information.', + }); + }); + + test('cancels delayed and in-flight gallery work when access is revoked', () => { + const widget = createMcpAccessTestWidget(McpAccessValue.All, undefined, disposables); + widget.updateAccessState(); + widget.galleryCts = { dispose: cancel => widget.requestCancelCount += cancel ? 1 : 0 }; + widget.gallerySnapshotLoading = true; + widget.gallerySearchLoading = true; + + widget.access = McpAccessValue.None; + widget.updateAccessState(); + + assert.deepStrictEqual({ + accessEnabled: widget.mcpAccessEnabled, + delayedCancelCount: widget.delayedCancelCount, + requestCancelCount: widget.requestCancelCount, + gallerySnapshotLoading: widget.gallerySnapshotLoading, + gallerySearchLoading: widget.gallerySearchLoading, + }, { + accessEnabled: false, + delayedCancelCount: 1, + requestCancelCount: 1, + gallerySnapshotLoading: false, + gallerySearchLoading: false, + }); + }); + + test('restarts a retained marketplace search when access is restored', () => { + const widget = createMcpAccessTestWidget(McpAccessValue.None, undefined, disposables); + widget.searchQuery = 'github'; + widget.visible = true; + widget.updateAccessState(); + + widget.access = McpAccessValue.All; + widget.updateAccessState(); + + assert.deepStrictEqual({ + queryCount: widget.queryCount, + refreshCount: widget.refreshCount, + }, { + queryCount: 1, + refreshCount: 0, + }); + }); + + test('uses durable enablement for the primary MCP switch', () => { + const sessionResource = URI.parse('vscode-agent-session:///session-1'); + const activeSessionServer = createAgentHostServer({ + enabled: false, + enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }], + }); + const { service: mcpService, calls: localCalls } = createMcpService(ContributionEnablementState.DisabledProfile); + const { service: agentHostService, calls: agentHostCalls } = createAgentHostCustomizations(); + + const localEnabled = isPrimaryMcpServerEnabled(mcpService, 'server-1', activeSessionServer); + const hostEnabled = isPrimaryMcpServerEnabled(mcpService, undefined, activeSessionServer); + setPrimaryMcpServerEnablement(mcpService, agentHostService, sessionResource, 'server-1', activeSessionServer, true); + setPrimaryMcpServerEnablement(mcpService, agentHostService, sessionResource, undefined, activeSessionServer, false); + + assert.deepStrictEqual({ + localEnabled, + hostEnabled, + localCalls, + agentHostCalls, + }, { + localEnabled: false, + hostEnabled: true, + localCalls: [['server-1', ContributionEnablementState.EnabledProfile]], + agentHostCalls: [[sessionResource, activeSessionServer.id, activeSessionServer.enablement, CustomizationEnablementKind.Global, false]], + }); + }); + + test('uses host enablement for host-owned plugin MCP rows with local counterparts', () => { + const sessionResource = URI.parse('vscode-agent-session:///session-1'); + const activeSessionServer = createAgentHostServer({ + isPluginProvided: true, + isClientBundled: false, + enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }], + }); + const { service: mcpService, calls: localCalls } = createMcpService(ContributionEnablementState.EnabledProfile); + const { service: agentHostService, calls: agentHostCalls } = createAgentHostCustomizations(); + + const enabled = isPrimaryMcpServerEnabled(mcpService, 'server-1', activeSessionServer); + setPrimaryMcpServerEnablement(mcpService, agentHostService, sessionResource, 'server-1', activeSessionServer, true); + + assert.deepStrictEqual({ + enabled, + localCalls, + agentHostCalls, + }, { + enabled: false, + localCalls: [], + agentHostCalls: [[sessionResource, activeSessionServer.id, activeSessionServer.enablement, CustomizationEnablementKind.Global, true]], + }); }); test('distinguishes membership changes from state-only changes', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts index 3d98acd004080..a3e531e564dbd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts @@ -10,7 +10,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; import { CustomizationEnablementKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { getInstalledPluginMetadata, getRemotePluginDisabledLabel, getToggledPluginEnablementState, PluginMarketplaceSnapshotModel, shouldLoadPluginMarketplaceSnapshot } from '../../../browser/aiCustomization/pluginListWidget.js'; +import { getInstalledPluginMetadata, getRemotePluginDisabledLabel, getToggledPluginEnablementState, isCurrentPluginMarketplaceRequest, PluginMarketplaceSnapshotModel, shouldLoadPluginMarketplaceSnapshot } from '../../../browser/aiCustomization/pluginListWidget.js'; import { AgentPluginItemKind, IInstalledPluginItem } from '../../../browser/agentPluginEditor/agentPluginItems.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; @@ -98,4 +98,14 @@ suite('pluginListWidget', () => { shouldLoadPluginMarketplaceSnapshot(true, 'uninitialized', false), ], [false, true, false, false]); }); + + test('accepts marketplace results only for the initiating search', () => { + assert.deepStrictEqual([ + isCurrentPluginMarketplaceRequest('agent', 'agent', false, false, true, false), + isCurrentPluginMarketplaceRequest('agent', '', false, false, true, false), + isCurrentPluginMarketplaceRequest('agent', 'agent', false, true, true, false), + isCurrentPluginMarketplaceRequest('agent', 'agent', false, false, false, false), + isCurrentPluginMarketplaceRequest('agent', 'agent', false, false, true, true), + ], [true, false, false, false, false]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts index 1101b8720dd05..9d2712199d4c9 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts @@ -33,7 +33,7 @@ import { PluginFormat } from '../../../../../../platform/agentPlugins/common/plu */ class TestPluginDiscovery extends AbstractAgentPluginDiscovery { private _sources: URI[] = []; - private _remove: (() => void) | undefined = () => { }; + private _remove: (() => Promise) | undefined = async () => true; private _nextDiscoveryBarrier: Promise | undefined; constructor( @@ -55,13 +55,13 @@ class TestPluginDiscovery extends AbstractAgentPluginDiscovery { await this._refreshPlugins(); } - async setRemoveAndRefresh(uri: URI, remove: (() => void) | undefined): Promise { + async setRemoveAndRefresh(uri: URI, remove: (() => Promise) | undefined): Promise { this._sources = [uri]; this._remove = remove; await this._refreshPlugins(); } - async setRemoveAndRefreshAfter(uri: URI, remove: (() => void) | undefined, barrier: Promise): Promise { + async setRemoveAndRefreshAfter(uri: URI, remove: (() => Promise) | undefined, barrier: Promise): Promise { this._sources = [uri]; this._remove = remove; this._nextDiscoveryBarrier = barrier; @@ -150,17 +150,23 @@ suite('AgentPlugin format detection', () => { const removeCounts = [0, 0]; const discovery = createDiscovery(); discovery.start(mockEnablementModel); - await discovery.setRemoveAndRefresh(uri, () => removeCounts[0]++); + await discovery.setRemoveAndRefresh(uri, async () => { + removeCounts[0]++; + return true; + }); const initialPlugin = getDiscoveredPlugins(discovery)[0]; - initialPlugin.remove?.(); + await initialPlugin.remove?.(); await discovery.setRemoveAndRefresh(uri, undefined); const managedPlugin = getDiscoveredPlugins(discovery)[0]; const managedRemove = managedPlugin.remove; - await discovery.setRemoveAndRefresh(uri, () => removeCounts[1]++); + await discovery.setRemoveAndRefresh(uri, async () => { + removeCounts[1]++; + return true; + }); const removablePlugin = getDiscoveredPlugins(discovery)[0]; - removablePlugin.remove?.(); + await removablePlugin.remove?.(); assert.deepStrictEqual({ reusedManagedPlugin: managedPlugin === initialPlugin, @@ -182,16 +188,19 @@ suite('AgentPlugin format detection', () => { let removeCount = 0; const discovery = createDiscovery(); discovery.start(mockEnablementModel); - await discovery.setRemoveAndRefresh(uri, () => { }); + await discovery.setRemoveAndRefresh(uri, async () => true); const staleDiscoveryBarrier = new DeferredPromise(); const staleRefresh = discovery.setRemoveAndRefreshAfter(uri, undefined, staleDiscoveryBarrier.p); - await discovery.setRemoveAndRefresh(uri, () => removeCount++); + await discovery.setRemoveAndRefresh(uri, async () => { + removeCount++; + return true; + }); staleDiscoveryBarrier.complete(); await staleRefresh; const plugin = getDiscoveredPlugins(discovery)[0]; - plugin.remove?.(); + await plugin.remove?.(); assert.deepStrictEqual({ hasRemove: plugin.remove !== undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 0ce66e2f53435..bf9c544c58861 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -4803,7 +4803,7 @@ suite('PromptsService', () => { format: PluginFormat.Copilot, label: 'my-plugin', enablement, - remove: () => { }, + remove: async () => true, hooks: observableValue('testPluginHooks', []), commands: observableValue('testPluginCommands', []), skills: observableValue('testPluginSkills', [{ uri: skillUri, name: 'deploy' }]), @@ -4849,7 +4849,7 @@ suite('PromptsService', () => { format: PluginFormat.Copilot, label: 'devtools', enablement, - remove: () => { }, + remove: async () => true, hooks: observableValue('testPluginHooks', []), commands: observableValue('testPluginCommands', []), skills: observableValue('testPluginSkills', [{ uri: skillUri, name: 'ci' }]), @@ -4902,7 +4902,7 @@ suite('PromptsService', () => { format: PluginFormat.Copilot, label: 'datadog', enablement, - remove: () => { }, + remove: async () => true, hooks: observableValue('testPluginHooks', []), commands: observableValue('testPluginCommands', []), skills: observableValue('testPluginSkills', [{ uri: skillUri, name: 'ddsetup' }]), @@ -5138,7 +5138,7 @@ suite('PromptsService', () => { format: PluginFormat.Copilot, label: basename(URI.file(path)), enablement, - remove: () => { }, + remove: async () => true, hooks, commands, skills, @@ -5411,7 +5411,7 @@ suite('PromptsService', () => { format: PluginFormat.Copilot, label: basename(URI.file(path)), enablement, - remove: () => { }, + remove: async () => true, hooks, commands, skills, diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 0b1238429908d..cf5c0186ed43f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1303,7 +1303,7 @@ function makeInstalledPlugin(name: string, uri: URI, enablement: boolean | Contr { uri: URI.joinPath(uri, 'instructions', `${contributionName}.instructions.md`), name: `${name} instructions`, description: `Context rules for ${name}.` }, ]); override readonly mcpServerDefinitions = constObservable([]); - override remove() { } + override async remove() { return true; } }(); }