diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 3d017432d98eeb..5ce4c142d7a92a 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -51,7 +51,9 @@ Core workbench registrations may expose Local, Copilot CLI, and Claude harnesses ### `ICustomizationMigrationService` -This shared workbench service computes customization migrations for an explicit chat session. File migrations include source URIs and migratable-configuration metadata for flows that need source type and storage; MCP migrations report known servers' binary harness compatibility together with discovery and policy-coverage state. The service also produces a localized, harness-specific hint summarizing available file migrations for UI consumers. +This shared workbench service computes customization migrations for an explicit chat session. File migrations include source URIs and migratable-configuration metadata for flows that need source type and storage. Its MCP migration domain owns source canonicalization, destination representability, candidate planning, pre-write revalidation, guarded execution, and typed failure reasons. MCP results also retain the full compatibility inventory, discovery state, and policy coverage. The service produces a localized, harness-specific hint summarizing available migrations for UI consumers. + +`CustomizationMigrationModel` owns the editor's reactive migration lifecycle: active-session and MCP refresh inputs, async sequencing, loading/error state, candidates, and destination folders. The management editor owns only selection, rendering, confirmation, and user notifications. ### `IHarnessDescriptor` diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 5d013ce2ff325c..81fda193ebfd68 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -484,14 +484,17 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat return undefined; } const sessionState = this._readSessionState(sessionResource); + const workingDirectories = sessionState === undefined + ? this._provisionalSessionService.getProvisionalWorkingDirectories(sessionResource)?.map(uri => uri.toString()) + : sessionState.workingDirectories; const rootState = target.connection.rootState.value; const channel = target.backendSession.toString(); return { customizations: sessionState?.customizations ?? [], resourceUris: target.connection.resourceUris, folderPickerDecision: readSessionFolderPickerDecision(sessionState?._meta), - workingDirectory: sessionState?.workingDirectories?.[0], - workingDirectories: sessionState?.workingDirectories, + workingDirectory: workingDirectories?.[0], + workingDirectories, rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, isBundledMcpServer: (pluginUri, serverName) => this._activeClientService.isBundledMcpServer(pluginUri, serverName), authenticate: request => target.connection.authenticate(request), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index 1bdf457ab68ef2..9c7716bf8828f7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -105,6 +105,9 @@ export interface IAgentHostUntitledProvisionalSessionService { */ get(sessionResource: URI): URI | undefined; + /** Working directories used to create the current provisional generation. */ + getProvisionalWorkingDirectories(sessionResource: URI): readonly URI[] | undefined; + /** * Initial config the editor window applies to every new Agent Host session. * Returns `undefined` in the Agents window, where the sessions provider owns @@ -373,6 +376,14 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return this._generationMatchingDesiredState(entry)?.backendSession; } + getProvisionalWorkingDirectories(sessionResource: URI): readonly URI[] | undefined { + const entry = this._entries.get(sessionResource); + if (!entry || entry.disposed) { + return undefined; + } + return this._generationMatchingDesiredState(entry)?.workingDirectories; + } + private _computeWorkingDirectories(primary: URI | undefined, provider: string): readonly URI[] | undefined { return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._agentHostService.rootState.value, provider); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 90e17c1db674bb..6f2ca2a5495128 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -10,12 +10,12 @@ import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { getErrorMessage, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Action } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; import { autorun } from '../../../../../base/common/observable.js'; import { dirname as dirnamePath } from '../../../../../base/common/path.js'; import { Orientation, Sizing, SplitView } from '../../../../../base/browser/ui/splitview/splitview.js'; @@ -64,7 +64,7 @@ import { import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; -import { CustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, getCustomizationMigrationTargetType, getMcpServerCustomizationMigrationCandidateKey, ICustomizationMigrationService, IMcpServerCustomizationMigrationCandidate, isMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { IHeaderAttribute, IValue, ParsedPromptFile } from '../../common/promptSyntax/promptFileParser.js'; import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; @@ -102,12 +102,12 @@ import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js' import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage, type ICustomizationMigrationCategorySummary } from './aiCustomizationWelcomePage.js'; -import { type CustomizationMigrationTargetFolders, type IMigratedCustomizationsResult, migrateCustomizations } from './customizationMigration.js'; +import { type CustomizationMigrationTargetFolders, migrateCustomizations } from './customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, type ICustomizationMigrationBanner, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; -import { isAgentHostTarget } from '../../common/chatSessionsService.js'; +import { CustomizationMigrationModel } from './customizationMigrationModel.js'; const $ = DOM.$; @@ -352,8 +352,10 @@ export class AICustomizationManagementEditor extends EditorPane { private migrationSelectedCountElement: HTMLElement | undefined; private migrationFirstFocusableElement: HTMLElement | undefined; private activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; - private selectedCustomizationMigrationItems = new ResourceMap>(); + private selectedCustomizationMigrationItems = new Set(); + private presentedCustomizationMigrationItems = new Set(); private readonly migrationPageDisposables = this._register(new DisposableStore()); + private readonly customizationMigrationModel: CustomizationMigrationModel; // Embedded MCP server detail view private mcpDetailContainer: HTMLElement | undefined; @@ -381,13 +383,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Welcome page private welcomePage: AICustomizationWelcomePage | undefined; - private customizationsByMigrationCategory = new Map(); - private customizationMigrationTargetFoldersByType = new Map(); - private customizationMigrationRefreshSequence = 0; - private customizationMigrationLoading = false; - private customizationMigrationLoadError: string | undefined; private customizationMigrationInProgress = false; - private customizationMigrationWritesInProgress = false; private readonly editorDisposables = this._register(new DisposableStore()); private _editorContentChanged = false; @@ -435,6 +431,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); + this.customizationMigrationModel = this._register(instantiationService.createInstance(CustomizationMigrationModel)); this.inEditorContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR.bindTo(contextKeyService); this.sectionContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.bindTo(contextKeyService); @@ -702,10 +699,6 @@ export class AICustomizationManagementEditor extends EditorPane { if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled)) { this.onStructuredPreviewSettingChanged(); } - // Candidates are only collected for enabled categories, so enabling one must re-scan. - if (CUSTOMIZATION_MIGRATION_CATEGORIES.some(category => e.affectsConfiguration(category.enablementSetting))) { - void this.refreshCustomizationMigrationInfo(); - } })); this.createSidebarMigrationShortcut(sidebarContent); @@ -931,19 +924,15 @@ export class AICustomizationManagementEditor extends EditorPane { // Welcome page (shown when no section is selected) this.createWelcomePage(contentInner); - this.editorDisposables.add(Event.any( - this.promptsService.onDidChangeSlashCommands, - this.promptsService.onDidChangeCustomAgents, - this.promptsService.onDidChangeInstructions, - this.promptsService.onDidChangeAgentInstructions, - )(() => this.refreshCustomizationMigrationInfoFromPromptChange())); - this.registerCustomizationMigrationSessionRefresh(); - // Container for prompts-based content (Agents, Skills, Instructions, Prompts) this.promptsContentContainer = DOM.append(contentInner, $('.prompts-content-container')); this.listWidget = this.editorDisposables.add(this.instantiationService.createInstance(AICustomizationListWidget)); this.promptsContentContainer.appendChild(this.listWidget.element); this.createCustomizationMigrationContent(contentInner); + this.editorDisposables.add(autorun(reader => { + const state = this.customizationMigrationModel.state.read(reader); + this.setCustomizationsToMigrate(state.candidatesByCategory); + })); // Handle item selection this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { @@ -1101,141 +1090,66 @@ export class AICustomizationManagementEditor extends EditorPane { void this.listWidget.setSection(this.selectedSection); } - void this.refreshCustomizationMigrationInfo(); - } - - private registerCustomizationMigrationSessionRefresh(): void { - this.editorDisposables.add(autorun(reader => { - this.harnessService.activeSessionResource.read(reader); - void this.refreshCustomizationMigrationInfo(); - })); - } - - private refreshCustomizationMigrationInfoFromPromptChange(): void { - if (!this.customizationMigrationWritesInProgress) { - void this.refreshCustomizationMigrationInfo(); - } } private async refreshCustomizationMigrationInfo(): Promise { - const activeHarnessId = this.harnessService.activeHarness.get(); - const activeSessionResource = this.harnessService.activeSessionResource.get(); - const refreshSequence = ++this.customizationMigrationRefreshSequence; - this.customizationMigrationLoading = true; - this.customizationMigrationLoadError = undefined; - this.renderCustomizationMigrationPage(); - - if (!isAgentHostTarget(activeHarnessId)) { - this.customizationMigrationLoading = false; - this.setCustomizationsToMigrate(new Map(), new Map()); - return; - } - - try { - const enabledCategories = this.getEnabledMigrationCategories(); - if (enabledCategories.length === 0) { - this.customizationMigrationLoading = false; - this.setCustomizationsToMigrate(new Map(), new Map()); - return; - } - - const migrationsByCategory = await Promise.all(enabledCategories.map(async category => { - const type = category.id === CustomizationMigrationCategoryId.PromptFiles - ? CustomizationMigrationType.PromptFiles - : CustomizationMigrationType.UserData; - const migration = await this.customizationMigrationService.computeMigration(activeSessionResource, type); - return [category.id, migration] as const; - })); - if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get() || !isEqual(activeSessionResource, this.harnessService.activeSessionResource.get())) { - return; - } - - const candidatesByCategory = new Map( - migrationsByCategory.map(([categoryId, migration]) => [categoryId, migration.candidates]), - ); - const provider = this.harnessService.findHarnessById(activeHarnessId)?.itemProvider; - const targetTypes = new Set([...candidatesByCategory.values()].flat().map(getCustomizationMigrationTargetType)); - const targetFolderEntries = await Promise.all([...targetTypes].map(async targetType => { - const folders = await provider?.provideSourceFolders?.(activeSessionResource, targetType, CancellationToken.None); - return [targetType, folders ?? []] as const; - })); - if (refreshSequence !== this.customizationMigrationRefreshSequence || activeHarnessId !== this.harnessService.activeHarness.get() || !isEqual(activeSessionResource, this.harnessService.activeSessionResource.get())) { - return; - } - const targetFoldersByType = new Map(targetFolderEntries); - this.customizationMigrationLoading = false; - this.setCustomizationsToMigrate(candidatesByCategory, targetFoldersByType); - } catch (error) { - if (refreshSequence === this.customizationMigrationRefreshSequence) { - this.customizationMigrationLoading = false; - this.customizationMigrationLoadError = getErrorMessage(error); - this.renderCustomizationMigrationPage(); - } - onUnexpectedError(error); - } + await this.customizationMigrationModel.refresh(); } private setCustomizationsToMigrate( - candidatesByCategory: Map, - targetFoldersByType: Map, + candidatesByCategory: ReadonlyMap, ): void { - const previousItems = this.createCustomizationMigrationItemMap(this.getAllMigrationCandidates()); - const selectedItems = new ResourceMap>(); + const selectedItems = new Set(); for (const customization of [...candidatesByCategory.values()].flat()) { - if (!this.hasCustomizationMigrationItem(previousItems, customization) || this.isCustomizationSelectedForMigration(customization)) { + if (!this.hasCustomizationMigrationItem(this.presentedCustomizationMigrationItems, customization) || this.isCustomizationSelectedForMigration(customization)) { this.addCustomizationMigrationItem(selectedItems, customization); } } + this.presentedCustomizationMigrationItems = this.createCustomizationMigrationItemMap([...candidatesByCategory.values()].flat()); this.selectedCustomizationMigrationItems = selectedItems; - this.customizationsByMigrationCategory = candidatesByCategory; - this.customizationMigrationTargetFoldersByType = targetFoldersByType; this.refreshCustomizationMigrationUi(); } - private createCustomizationMigrationItemMap(customizations: readonly MigratableConfiguration[]): ResourceMap> { - const result = new ResourceMap>(); + private createCustomizationMigrationItemMap(customizations: readonly CustomizationMigrationCandidate[]): Set { + const result = new Set(); for (const customization of customizations) { this.addCustomizationMigrationItem(result, customization); } return result; } - private hasCustomizationMigrationItem(items: ResourceMap>, customization: MigratableConfiguration): boolean { - return items.get(customization.uri)?.has(customization.storage) === true; + private hasCustomizationMigrationItem(items: ReadonlySet, customization: CustomizationMigrationCandidate): boolean { + return items.has(this.getCustomizationMigrationItemKey(customization)); } - private addCustomizationMigrationItem(items: ResourceMap>, customization: MigratableConfiguration): void { - const storages = items.get(customization.uri) ?? new Set(); - storages.add(customization.storage); - items.set(customization.uri, storages); + private addCustomizationMigrationItem(items: Set, customization: CustomizationMigrationCandidate): void { + items.add(this.getCustomizationMigrationItemKey(customization)); } - private isCustomizationSelectedForMigration(customization: MigratableConfiguration): boolean { + private isCustomizationSelectedForMigration(customization: CustomizationMigrationCandidate): boolean { return this.hasCustomizationMigrationItem(this.selectedCustomizationMigrationItems, customization); } - private setCustomizationSelectedForMigration(customization: MigratableConfiguration, selected: boolean): void { + private setCustomizationSelectedForMigration(customization: CustomizationMigrationCandidate, selected: boolean): void { if (selected) { this.addCustomizationMigrationItem(this.selectedCustomizationMigrationItems, customization); return; } - const storages = this.selectedCustomizationMigrationItems.get(customization.uri); - storages?.delete(customization.storage); - if (storages?.size === 0) { - this.selectedCustomizationMigrationItems.delete(customization.uri); - } + this.selectedCustomizationMigrationItems.delete(this.getCustomizationMigrationItemKey(customization)); } - private getMigrationCandidates(category: ICustomizationMigrationCategory): readonly MigratableConfiguration[] { - if (!this.isMigrationCategoryEnabled(category)) { - return []; - } - return this.customizationsByMigrationCategory.get(category.id) ?? []; + private getCustomizationMigrationItemKey(customization: CustomizationMigrationCandidate): string { + return isMcpServerCustomizationMigrationCandidate(customization) + ? `mcp:${getMcpServerCustomizationMigrationCandidateKey(customization)}` + : `file:${customization.uri.toString()}:${customization.storage}`; } - private getAllMigrationCandidates(): readonly MigratableConfiguration[] { - return [...this.customizationsByMigrationCategory.values()].flat(); + private getMigrationCandidates(category: ICustomizationMigrationCategory): readonly CustomizationMigrationCandidate[] { + if (!this.customizationMigrationModel.isCategoryEnabled(category.id)) { + return []; + } + return this.customizationMigrationModel.state.get().candidatesByCategory.get(category.id) ?? []; } private getActiveMigrationCategory(): ICustomizationMigrationCategory | undefined { @@ -1296,8 +1210,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.layoutSidebar(this.sidebarWidth, this.sidebarHeight); } - private async migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): Promise { - if (this.customizationMigrationInProgress || customizations.length === 0 || !this.isMigrationCategoryEnabled(category)) { + private async migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly CustomizationMigrationCandidate[]): Promise { + if (this.customizationMigrationInProgress || customizations.length === 0 || !this.customizationMigrationModel.isCategoryEnabled(category.id)) { return; } @@ -1305,13 +1219,27 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateCustomizationMigrationActionState(); try { const sessionResource = this.harnessService.activeSessionResource.get(); - const targetFolders = await this.resolveCustomizationMigrationTargetFolders(customizations, this.customizationMigrationTargetFoldersByType, sessionResource); + if (category.migrationType === CustomizationMigrationType.McpServers) { + await this.migrateSelectedMcpServers( + category, + customizations.filter(isMcpServerCustomizationMigrationCandidate), + sessionResource, + ); + return; + } + + const fileCustomizations = customizations.filter(customization => !isMcpServerCustomizationMigrationCandidate(customization)); + const targetFolders = await this.resolveCustomizationMigrationTargetFolders( + fileCustomizations, + this.customizationMigrationModel.state.get().targetFoldersByType, + sessionResource, + ); if (!targetFolders || !this.isCustomizationMigrationSessionActive(sessionResource)) { return; } const confirmation = category.getConfirmation( - customizations, + fileCustomizations, this.getActiveHarnessLabel(), this.getCustomizationMigrationDestinationLabel( [...targetFolders.values()].flatMap(foldersByStorage => [...foldersByStorage.values()]), @@ -1321,10 +1249,10 @@ export class AICustomizationManagementEditor extends EditorPane { type: 'question', message: confirmation.message, detail: confirmation.detail, - checkbox: { + checkbox: confirmation.deleteOriginalsLabel ? { label: confirmation.deleteOriginalsLabel, checked: true, - }, + } : undefined, primaryButton: confirmation.primaryButton, }); if (!confirmResult.confirmed || !this.isCustomizationMigrationSessionActive(sessionResource)) { @@ -1332,7 +1260,13 @@ export class AICustomizationManagementEditor extends EditorPane { } const deleteOriginalFiles = confirmResult.checkboxChecked !== false; - const migrationResult = await this.runCustomizationMigration(customizations, targetFolders, deleteOriginalFiles); + const migrationResult = await migrateCustomizations( + fileCustomizations, + targetFolders, + this.fileService, + onUnexpectedError, + { deleteOriginalFiles }, + ); const { migratedCount, failedCustomizationFileNames, unsupportedHeaderKeys, migratedCustomizations } = migrationResult; if (failedCustomizationFileNames.length > 0) { @@ -1343,7 +1277,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (migratedCount === 0) { if (failedCustomizationFileNames.length === 0) { - this.notificationService.warn(category.noFilesMigratedMessage); + this.notificationService.warn(category.nothingMigratedMessage); } return; } @@ -1366,20 +1300,49 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private async runCustomizationMigration(customizations: readonly MigratableConfiguration[], targetFolders: CustomizationMigrationTargetFolders, deleteOriginalFiles: boolean): Promise { - this.customizationMigrationWritesInProgress = true; - try { - return await migrateCustomizations( - customizations, - targetFolders, - this.fileService, - onUnexpectedError, - { deleteOriginalFiles }, - ); - } finally { - await timeout(0); - this.customizationMigrationWritesInProgress = false; + private async migrateSelectedMcpServers( + category: ICustomizationMigrationCategory, + servers: readonly IMcpServerCustomizationMigrationCandidate[], + sessionResource: URI, + ): Promise { + if (servers.length === 0) { + return; + } + + const confirmation = category.getConfirmation(servers, this.getActiveHarnessLabel()); + const confirmResult = await this.dialogService.confirm({ + type: 'question', + message: confirmation.message, + detail: confirmation.detail, + primaryButton: confirmation.primaryButton, + }); + if (!confirmResult.confirmed || !this.isCustomizationMigrationSessionActive(sessionResource)) { + return; + } + + const { migratedCount, failures } = await this.customizationMigrationService.migrateMcpServers(sessionResource, servers); + if (!this.isCustomizationMigrationSessionActive(sessionResource)) { + return; } + for (const failure of failures) { + if (failure.error) { + onUnexpectedError(failure.error); + } + } + if (failures.length > 0) { + this.notificationService.error(category.getMcpServerFailureMessage?.(failures) + ?? category.getFailedMessage(failures.slice(0, 3).map(failure => failure.name), Math.max(0, failures.length - 3))); + } + if (migratedCount === 0) { + if (failures.length === 0) { + this.notificationService.warn(category.nothingMigratedMessage); + } + await this.refreshCustomizationMigrationInfo(); + return; + } + + await this.refreshCustomizationMigrationInfo(); + this.notificationService.info(category.getMigratedMessage(migratedCount)); } private renderCustomizationMigrationPage(): void { @@ -1393,9 +1356,10 @@ export class AICustomizationManagementEditor extends EditorPane { const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const candidates = this.getMigrationCandidates(category); + const migrationState = this.customizationMigrationModel.state.get(); this.updateCustomizationMigrationPageHeader(category, candidates); - if (this.customizationMigrationLoading) { + if (migrationState.loading) { this.renderCustomizationMigrationState( localize('customizationMigrationLoading', "Loading customizations..."), localize('customizationMigrationLoadingDescription', "Checking the active harness and available destinations."), @@ -1404,7 +1368,7 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - if (this.customizationMigrationLoadError) { + if (migrationState.loadError) { this.renderCustomizationMigrationState( localize('customizationMigrationLoadError', "Customizations could not be loaded"), localize('customizationMigrationLoadErrorDescription', "Check the active agent connection, then try again."), @@ -1432,10 +1396,10 @@ export class AICustomizationManagementEditor extends EditorPane { isWorkspaceFile, ); }; - const renderSelectionCheckbox = (row: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { + const renderSelectionCheckbox = (row: HTMLElement, customization: CustomizationMigrationCandidate, onSelectionChange?: () => void): Checkbox => { const checkboxContainer = DOM.append(row, $('.item-sync-checkbox.prompt-migration-checkbox')); - const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", customization.name ?? basename(customization.uri)); - const checkbox = this.migrationPageDisposables.add(new Checkbox(checkboxTitle, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); + const presentation = category.getCandidatePresentation(customization, uri => this.labelService.getUriLabel(uri, { relative: true })); + const checkbox = this.migrationPageDisposables.add(new Checkbox(presentation.selectionAriaLabel, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); checkboxContainer.replaceChildren(checkbox.domNode); this.migrationFirstFocusableElement ??= checkbox.domNode; this.migrationPageDisposables.add(checkbox.onChange(() => { @@ -1446,55 +1410,62 @@ export class AICustomizationManagementEditor extends EditorPane { return checkbox; }; - const renderItem = (container: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { + const renderItem = (container: HTMLElement, customization: CustomizationMigrationCandidate, onSelectionChange?: () => void): Checkbox => { const row = DOM.append(container, $('div.ai-customization-list-item.prompt-migration-item')); const checkbox = renderSelectionCheckbox(row, customization, onSelectionChange); const itemLeft = DOM.append(row, $('span.item-left')); - const displayName = customization.name ?? basename(customization.uri); - const relativePath = this.labelService.getUriLabel(customization.uri, { relative: true }); - const openButton = this.migrationPageDisposables.add(new Button(itemLeft, { - ariaLabel: localize('openCustomizationFile', "Open {0}, {1}", displayName, relativePath), - })); - openButton.label = displayName; - DOM.clearNode(openButton.element); - openButton.element.classList.add('item-text', 'prompt-migration-open-button'); - this.migrationPageDisposables.add(openButton.onDidClick(() => openCustomizationInEmbeddedEditor(customization))); - const itemText = openButton.element; + const presentation = category.getCandidatePresentation(customization, uri => this.labelService.getUriLabel(uri, { relative: true })); + const file = presentation.file; + let itemText: HTMLElement; + if (!file) { + itemText = DOM.append(itemLeft, $('span.item-text')); + } else { + const openButton = this.migrationPageDisposables.add(new Button(itemLeft, { + ariaLabel: localize('openCustomizationFile', "Open {0}, {1}", presentation.name, presentation.pathLabel), + })); + openButton.label = presentation.name; + DOM.clearNode(openButton.element); + openButton.element.classList.add('item-text', 'prompt-migration-open-button'); + this.migrationPageDisposables.add(openButton.onDidClick(() => openCustomizationInEmbeddedEditor(file))); + itemText = openButton.element; + } const nameRow = DOM.append(itemText, $('span.item-name-row')); const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); - nameLabel.textContent = displayName; + nameLabel.textContent = presentation.name; const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); - pathLabel.textContent = relativePath; - - const itemRight = DOM.append(row, $('span.item-right')); - const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { - type: 'button', - 'aria-label': localize('customizationMigrationMoreActions', "More actions for {0}", customization.name ?? basename(customization.uri)), - })) as HTMLButtonElement; - moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); - this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), moreButton, localize('moreActions', "More Actions"))); - this.migrationPageDisposables.add(DOM.addDisposableListener(moreButton, 'click', event => { - event.stopPropagation(); - const actions = new DisposableStore(); - const deleteAction = actions.add(new Action( - 'customizationMigration.delete', - localize('delete', "Delete"), - ThemeIcon.asClassName(Codicon.trash), - true, - () => this.deleteCustomizationFile(customization), - )); - this.contextMenuService.showContextMenu({ - getAnchor: () => moreButton, - getActions: () => [deleteAction], - onHide: () => actions.dispose(), - }); - })); + pathLabel.textContent = presentation.pathLabel; + + if (file) { + const itemRight = DOM.append(row, $('span.item-right')); + const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { + type: 'button', + 'aria-label': localize('customizationMigrationMoreActions', "More actions for {0}", presentation.name), + })) as HTMLButtonElement; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), moreButton, localize('moreActions', "More Actions"))); + this.migrationPageDisposables.add(DOM.addDisposableListener(moreButton, 'click', event => { + event.stopPropagation(); + const actions = new DisposableStore(); + const deleteAction = actions.add(new Action( + 'customizationMigration.delete', + localize('delete', "Delete"), + ThemeIcon.asClassName(Codicon.trash), + true, + () => this.deleteCustomizationFile(file), + )); + this.contextMenuService.showContextMenu({ + getAnchor: () => moreButton, + getActions: () => [deleteAction], + onHide: () => actions.dispose(), + }); + })); + } return checkbox; }; - const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly MigratableConfiguration[]): void => { + const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly CustomizationMigrationCandidate[]): void => { const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); const groupHeader = DOM.append(group, $('.prompt-migration-group-header')); const groupHeading = DOM.append(groupHeader, $('.prompt-migration-group-heading')); @@ -1560,15 +1531,15 @@ export class AICustomizationManagementEditor extends EditorPane { }; const groups = category.group(candidates); - const groupedUris = new ResourceSet(); + const groupedCandidates = new Set(); for (const group of groups) { for (const customization of group.customizations) { - groupedUris.add(customization.uri); + groupedCandidates.add(this.getCustomizationMigrationItemKey(customization)); } renderGroup(group.key, group.label, group.customizations); } - for (const customization of candidates.filter(item => !groupedUris.has(item.uri))) { + for (const customization of candidates.filter(item => !groupedCandidates.has(this.getCustomizationMigrationItemKey(item)))) { renderItem(this.migrationListContainer, customization); } @@ -1589,7 +1560,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationListScrollable?.scanDomNode(); } - private updateCustomizationMigrationPageHeader(category: ICustomizationMigrationCategory, candidates: readonly MigratableConfiguration[]): void { + private updateCustomizationMigrationPageHeader(category: ICustomizationMigrationCategory, candidates: readonly CustomizationMigrationCandidate[]): void { if (this.migrationTitleElement) { this.migrationTitleElement.textContent = category.pageTitle; } @@ -1600,9 +1571,9 @@ export class AICustomizationManagementEditor extends EditorPane { candidates, this.getActiveHarnessLabel(), this.getCustomizationMigrationDestinationLabel( - candidates.flatMap(customization => { + candidates.filter(customization => !isMcpServerCustomizationMigrationCandidate(customization)).flatMap(customization => { const targetType = getCustomizationMigrationTargetType(customization); - return this.customizationMigrationTargetFoldersByType.get(targetType)?.filter(folder => folder.source === customization.storage) ?? []; + return this.customizationMigrationModel.state.get().targetFoldersByType.get(targetType)?.filter(folder => folder.source === customization.storage) ?? []; }), ), ) @@ -1695,19 +1666,7 @@ export class AICustomizationManagementEditor extends EditorPane { } } - const updatedCandidates = new Map(); - for (const [categoryId, candidates] of this.customizationsByMigrationCategory) { - updatedCandidates.set(categoryId, candidates.filter(item => !isEqual(item.uri, customization.uri))); - } - this.setCustomizationsToMigrate(updatedCandidates, this.customizationMigrationTargetFoldersByType); - } - - private isMigrationCategoryEnabled(category: ICustomizationMigrationCategory): boolean { - return this.configurationService.getValue(category.enablementSetting) === true; - } - - private getEnabledMigrationCategories(): readonly ICustomizationMigrationCategory[] { - return CUSTOMIZATION_MIGRATION_CATEGORIES.filter(category => this.isMigrationCategoryEnabled(category)); + await this.customizationMigrationModel.refresh(); } private async resolveCustomizationMigrationTargetFolders( @@ -2424,7 +2383,7 @@ export class AICustomizationManagementEditor extends EditorPane { } public showCustomizationMigrationPage(categoryId: CustomizationMigrationCategoryId): void { - if (!this.isMigrationCategoryEnabled(getCustomizationMigrationCategory(categoryId))) { + if (!this.customizationMigrationModel.isCategoryEnabled(categoryId)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts index 57bd11e2c7f234..3c6b2e52111810 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts @@ -4,17 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { splitLinesIncludeSeparators } from '../../../../../base/common/strings.js'; +import { Iterable } from '../../../../../base/common/iterator.js'; import { URI } from '../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { basename, dirname, getComparisonKey } from '../../../../../base/common/resources.js'; +import { basename, dirname, getComparisonKey, isEqual } from '../../../../../base/common/resources.js'; import { ResourceMap } from '../../../../../base/common/map.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; +import { FileOperationError, FileOperationResult, IFileService, IFileStatWithMetadata, toFileOperationResult } from '../../../../../platform/files/common/files.js'; import { getCleanPromptName, getPromptFileExtension, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IHeaderAttribute, ParsedPromptFile, PromptFileParser, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { getCustomizationMigrationTargetType, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationType, getCustomizationMigrationTargetType, IMcpServerCustomizationMigrationCandidate, IMcpServerMigrationFailure, IMcpServerMigrationResult, McpServerMigrationFailureReason, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; +import { parse, ParseError } from '../../../../../base/common/json.js'; +import { applyEdits, setProperty } from '../../../../../base/common/jsonEdit.js'; +import { FormattingOptions } from '../../../../../base/common/jsonFormatter.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { normalizeMcpServerConfiguration } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { ConfigurationResolverExpression } from '../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { AgentHostMcpServerApplicability, AgentHostMcpServerSourceKind, IAgentHostMcpServerSupportSnapshot } from '../agentSessions/agentHost/agentHostMcpServerSupport.js'; export interface IMigratedPromptFile { readonly skillName: string; @@ -275,3 +284,487 @@ async function getAvailableMigratedSkillName( reservedNames.add(candidate); return candidate; } + +export interface IMcpServerMigrationPlan { + readonly candidates: readonly IMcpServerCustomizationMigrationCandidate[]; + readonly exclusions: readonly IMcpServerMigrationFailure[]; +} + +interface IMcpServerMigrationGroup { + readonly sourceUri: URI; + readonly targetUri: URI; + readonly candidates: IMcpServerCustomizationMigrationCandidate[]; +} + +interface IJsonDocument { + readonly content: string; + readonly value: Record; + readonly exists: boolean; + readonly mtime?: number; + readonly etag?: string; +} + +/** + * Owns MCP migration eligibility and guarded source-to-target execution. + */ +export class McpServerMigration { + constructor(private readonly fileService: IFileService) { } + + async createPlan(snapshot: IAgentHostMcpServerSupportSnapshot): Promise { + const candidates: IMcpServerCustomizationMigrationCandidate[] = []; + const exclusions: IMcpServerMigrationFailure[] = []; + const sourceServers = new ResourceMap | undefined>>(); + + for (const server of snapshot.servers) { + const sourceUri = server.source.collectionUri; + if (server.source.kind !== AgentHostMcpServerSourceKind.VscodeWorkspaceFolder || !sourceUri) { + continue; + } + const targetUri = URI.joinPath(dirname(dirname(sourceUri)), '.mcp.json'); + const excluded = (reason: McpServerMigrationFailureReason, error?: Error): void => { + exclusions.push({ + id: server.id, + name: server.name, + sourceUri, + targetUri, + reason, + error, + }); + }; + if (!server.enablement.enabled + || server.applicability !== AgentHostMcpServerApplicability.Applicable + || server.compatibility.kind !== 'supported') { + excluded(McpServerMigrationFailureReason.NoLongerEligible); + continue; + } + + let sourceServersPromise = sourceServers.get(sourceUri); + if (!sourceServersPromise) { + sourceServersPromise = this.readMcpServers(sourceUri); + sourceServers.set(sourceUri, sourceServersPromise); + } + let rawConfiguration: unknown; + try { + rawConfiguration = (await sourceServersPromise)?.[server.name]; + } catch (error) { + excluded(McpServerMigrationFailureReason.SourceUnavailable, toError(error)); + continue; + } + const configuration = normalizeMcpServerConfiguration(rawConfiguration); + const sourceConfiguration = canonicalizeMcpServerMigrationSourceConfiguration(rawConfiguration); + if (!configuration || !sourceConfiguration) { + excluded(McpServerMigrationFailureReason.InvalidSource); + continue; + } + if (!isMcpServerMigrationConfigurationRepresentable(configuration) + || !Iterable.isEmpty(ConfigurationResolverExpression.parse(configuration).unresolved()) + || !equals(sourceConfiguration, canonicalizeMcpServerMigrationConfiguration(configuration))) { + excluded(McpServerMigrationFailureReason.UnrepresentableConfiguration); + continue; + } + candidates.push({ + type: CustomizationMigrationType.McpServers, + id: server.id, + name: server.name, + sourceUri, + targetUri, + configuration, + }); + } + + return { candidates, exclusions }; + } + + migrate(candidates: readonly IMcpServerCustomizationMigrationCandidate[]): Promise { + return executeMcpServerMigration(candidates, this.fileService); + } + + private async readMcpServers(resource: URI): Promise | undefined> { + let content: string; + try { + content = (await this.fileService.readFile(resource)).value.toString(); + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; + } + const errors: ParseError[] = []; + const value = parse(content, errors, { allowTrailingComma: true, allowEmptyContent: false }); + if (errors.length > 0 || !isJsonObject(value)) { + return undefined; + } + return getObjectProperty(value, 'servers'); + } +} + +async function executeMcpServerMigration( + candidates: readonly IMcpServerCustomizationMigrationCandidate[], + fileService: IFileService, +): Promise { + // Batch by source so multiple selected servers share one guarded source/target transaction. + const groups = new ResourceMap(); + const failures: IMcpServerMigrationFailure[] = []; + for (const candidate of candidates) { + const group = groups.get(candidate.sourceUri) ?? { + sourceUri: candidate.sourceUri, + targetUri: candidate.targetUri, + candidates: [], + }; + if (!isEqual(group.targetUri, candidate.targetUri)) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.InconsistentTarget)); + continue; + } + group.candidates.push(candidate); + groups.set(candidate.sourceUri, group); + } + + let migratedCount = 0; + for (const group of groups.values()) { + try { + const result = await migrateMcpServerGroup(group, fileService); + migratedCount += result.migratedCount; + failures.push(...result.failures); + } catch (error) { + const migrationError = toMcpServerMigrationError(error); + failures.push(...group.candidates.map(candidate => createMcpServerMigrationFailure(candidate, migrationError.reason, migrationError))); + } + } + + return { migratedCount, failures }; +} + +async function migrateMcpServerGroup( + group: IMcpServerMigrationGroup, + fileService: IFileService, +): Promise { + let source: IJsonDocument; + try { + source = await readSourceJsonDocument(group.sourceUri, fileService); + } catch (error) { + throw new McpServerMigrationError(McpServerMigrationFailureReason.SourceUnavailable, toError(error)); + } + const sourceServers = getObjectProperty(source.value, 'servers'); + if (!sourceServers) { + throw new McpServerMigrationError( + McpServerMigrationFailureReason.InvalidSource, + new Error(`MCP configuration ${group.sourceUri.toString()} does not contain a servers object.`), + ); + } + + let target: IJsonDocument; + try { + target = await readTargetJsonDocument(group.targetUri, fileService); + } catch (error) { + throw new McpServerMigrationError(McpServerMigrationFailureReason.InvalidTarget, toError(error)); + } + const targetServers = getObjectProperty(target.value, 'mcpServers')!; + const candidatesToMigrate: IMcpServerCustomizationMigrationCandidate[] = []; + const failures: IMcpServerMigrationFailure[] = []; + + for (const candidate of group.candidates) { + if (!isMcpServerMigrationConfigurationRepresentable(candidate.configuration)) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.UnrepresentableConfiguration)); + continue; + } + if (!Object.hasOwn(sourceServers, candidate.name)) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.NoLongerEligible)); + continue; + } + + const sourceConfiguration = canonicalizeMcpServerMigrationSourceConfiguration(sourceServers[candidate.name]); + if (!sourceConfiguration) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.InvalidSource)); + continue; + } + const migrationConfiguration = canonicalizeMcpServerMigrationConfiguration(candidate.configuration); + if (!equals(sourceConfiguration, migrationConfiguration)) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.SourceChanged)); + continue; + } + + const targetConfiguration = canonicalizeMcpServerMigrationSourceConfiguration(targetServers[candidate.name]); + if (Object.hasOwn(targetServers, candidate.name) && (!targetConfiguration || !equals(targetConfiguration, migrationConfiguration))) { + failures.push(createMcpServerMigrationFailure(candidate, McpServerMigrationFailureReason.TargetConflict)); + continue; + } + + candidatesToMigrate.push(candidate); + } + + if (candidatesToMigrate.length === 0) { + return { migratedCount: 0, failures }; + } + + let targetContent = target.content; + let targetChanged = false; + for (const candidate of candidatesToMigrate) { + if (Object.hasOwn(targetServers, candidate.name)) { + continue; + } + targetContent = setJsonValue(targetContent, ['mcpServers', candidate.name], canonicalizeMcpServerMigrationConfiguration(candidate.configuration)); + targetChanged = true; + } + + let sourceContent = source.content; + for (const candidate of candidatesToMigrate) { + sourceContent = setJsonValue(sourceContent, ['servers', candidate.name], undefined); + } + + // The destination must exist before source entries are removed, so a failed target write cannot lose a server. + let writtenTarget: IFileStatWithMetadata | undefined; + if (targetChanged) { + try { + writtenTarget = await writeJsonDocument(group.targetUri, targetContent, target, fileService); + } catch (error) { + throw new McpServerMigrationError(McpServerMigrationFailureReason.WriteFailed, toError(error)); + } + } + + let writtenSource: IFileStatWithMetadata; + try { + writtenSource = await writeJsonDocument(group.sourceUri, sourceContent, source, fileService); + } catch (error) { + if (writtenTarget) { + try { + if (target.exists) { + await ensureFileExists(group.targetUri, fileService); + await fileService.writeFile(group.targetUri, VSBuffer.fromString(target.content), { + etag: writtenTarget.etag, + mtime: writtenTarget.mtime, + }); + } else { + throw new Error(`Cannot safely remove newly created ${group.targetUri.toString()} after the source update failed.`); + } + } catch (rollbackError) { + throw new McpServerMigrationError( + McpServerMigrationFailureReason.RollbackFailed, + new AggregateError([toError(error), toError(rollbackError)], `Failed to migrate and roll back MCP servers from ${group.sourceUri.toString()}.`), + ); + } + } + throw new McpServerMigrationError(McpServerMigrationFailureReason.WriteFailed, toError(error)); + } + + try { + await verifyMigratedMcpServers(group.targetUri, candidatesToMigrate, fileService); + } catch (verificationError) { + // Two files cannot be updated atomically; restore the guarded source if another writer changed the target. + try { + await ensureFileExists(group.sourceUri, fileService); + await fileService.writeFile(group.sourceUri, VSBuffer.fromString(source.content), { + etag: writtenSource.etag, + mtime: writtenSource.mtime, + }); + } catch (sourceRollbackError) { + throw new McpServerMigrationError( + McpServerMigrationFailureReason.RollbackFailed, + new AggregateError([toError(verificationError), toError(sourceRollbackError)], `Failed to verify and restore MCP servers from ${group.sourceUri.toString()}.`), + ); + } + throw new McpServerMigrationError(McpServerMigrationFailureReason.TargetChanged, toError(verificationError)); + } + + return { migratedCount: candidatesToMigrate.length, failures }; +} + +async function verifyMigratedMcpServers( + targetUri: URI, + candidates: readonly IMcpServerCustomizationMigrationCandidate[], + fileService: IFileService, +): Promise { + const target = await readTargetJsonDocument(targetUri, fileService); + const targetServers = getObjectProperty(target.value, 'mcpServers')!; + for (const candidate of candidates) { + if (!equals( + canonicalizeMcpServerMigrationSourceConfiguration(targetServers[candidate.name]), + canonicalizeMcpServerMigrationConfiguration(candidate.configuration), + )) { + throw new Error(`MCP server '${candidate.name}' changed in ${targetUri.toString()} during migration.`); + } + } +} + +async function readSourceJsonDocument(resource: URI, fileService: IFileService): Promise { + const file = await fileService.readFile(resource); + const content = file.value.toString(); + const errors: ParseError[] = []; + const value = parse(content, errors, { allowTrailingComma: true, allowEmptyContent: false }); + if (errors.length > 0 || !isJsonObject(value)) { + throw new Error(`MCP configuration ${resource.toString()} contains invalid JSON.`); + } + return { content, value, exists: true, mtime: file.mtime, etag: file.etag }; +} + +async function readTargetJsonDocument(resource: URI, fileService: IFileService): Promise { + try { + const file = await fileService.readFile(resource); + const content = file.value.toString(); + let value: unknown; + try { + value = JSON.parse(content); + } catch { + throw new Error(`MCP configuration ${resource.toString()} must contain strict JSON.`); + } + if (!isJsonObject(value) || !getObjectProperty(value, 'mcpServers')) { + throw new Error(`MCP configuration ${resource.toString()} must contain an mcpServers object.`); + } + return { content, value, exists: true, mtime: file.mtime, etag: file.etag }; + } catch (error) { + if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + throw error; + } + const value = { mcpServers: {} }; + return { + content: `${JSON.stringify(value, null, '\t')}\n`, + value, + exists: false, + }; + } +} + +async function writeJsonDocument(resource: URI, content: string, document: IJsonDocument, fileService: IFileService): Promise { + if (document.exists) { + await ensureFileExists(resource, fileService); + return fileService.writeFile(resource, VSBuffer.fromString(content), { + etag: document.etag, + mtime: document.mtime, + }); + } + return fileService.createFile(resource, VSBuffer.fromString(content), { overwrite: false }); +} + +async function ensureFileExists(resource: URI, fileService: IFileService): Promise { + if (!await fileService.exists(resource)) { + throw new FileOperationError(`File was deleted during MCP migration: ${resource.toString()}`, FileOperationResult.FILE_NOT_FOUND); + } +} + +function getObjectProperty(value: Record, key: string): Record | undefined { + const property = value[key]; + return isJsonObject(property) ? property : undefined; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function canonicalizeMcpServerMigrationConfiguration(configuration: IMcpServerConfiguration): Record { + if (configuration.type === McpServerType.LOCAL) { + return { + type: configuration.type, + command: configuration.command, + ...(configuration.args?.length ? { args: [...configuration.args] } : {}), + ...(configuration.env && Object.keys(configuration.env).length > 0 ? { env: { ...configuration.env } } : {}), + ...(configuration.envFile !== undefined ? { envFile: configuration.envFile } : {}), + ...(configuration.cwd !== undefined ? { cwd: configuration.cwd } : {}), + ...(configuration.sandboxEnabled === true ? { sandboxEnabled: true } : {}), + ...(configuration.dev !== undefined ? { dev: configuration.dev } : {}), + }; + } + + return { + type: configuration.type, + ...(configuration.transport !== undefined ? { transport: configuration.transport } : {}), + url: configuration.url, + ...(configuration.headers && Object.keys(configuration.headers).length > 0 ? { headers: { ...configuration.headers } } : {}), + ...(configuration.oauth?.clientId !== undefined ? { oauth: { clientId: configuration.oauth.clientId } } : {}), + ...(configuration.dev !== undefined ? { dev: configuration.dev } : {}), + }; +} + +function isMcpServerMigrationConfigurationRepresentable(configuration: IMcpServerConfiguration): boolean { + if (configuration.version !== undefined || configuration.gallery !== undefined || configuration.dev !== undefined) { + return false; + } + if (configuration.type === McpServerType.LOCAL) { + return configuration.envFile === undefined + && configuration.cwd === undefined + && configuration.sandboxEnabled !== true; + } + return configuration.transport === undefined && configuration.oauth === undefined; +} + +/** + * Canonicalizes the original JSON while retaining fields that root `.mcp.json` discovery cannot preserve. + */ +export function canonicalizeMcpServerMigrationSourceConfiguration(rawConfiguration: unknown): Record | undefined { + const configuration = normalizeMcpServerConfiguration(rawConfiguration); + if (!configuration || !isJsonObject(rawConfiguration)) { + return undefined; + } + if (configuration.type === McpServerType.LOCAL) { + const sandboxEnabled = typeof rawConfiguration['sandboxEnabled'] === 'boolean' + ? rawConfiguration['sandboxEnabled'] + : undefined; + return withMcpSourceMetadata(canonicalizeMcpServerMigrationConfiguration({ + ...configuration, + ...(sandboxEnabled !== undefined ? { sandboxEnabled } : {}), + }), rawConfiguration); + } + const rawOAuth = rawConfiguration['oauth']; + return withMcpSourceMetadata({ + ...canonicalizeMcpServerMigrationConfiguration(configuration), + ...(isJsonObject(rawOAuth) ? { oauth: rawOAuth } : {}), + }, rawConfiguration); +} + +function withMcpSourceMetadata(configuration: Record, rawConfiguration: Record): Record { + const version = typeof rawConfiguration['version'] === 'string' ? rawConfiguration['version'] : undefined; + const gallery = typeof rawConfiguration['gallery'] === 'boolean' || typeof rawConfiguration['gallery'] === 'string' + ? rawConfiguration['gallery'] + : undefined; + return { + ...configuration, + ...(version !== undefined ? { version } : {}), + ...(gallery !== undefined ? { gallery } : {}), + }; +} + +function setJsonValue(content: string, path: readonly string[], value: unknown): string { + return applyEdits(content, setProperty(content, [...path], value, getFormattingOptions(content))); +} + +function getFormattingOptions(content: string): FormattingOptions { + const indentation = /^([ \t]+)"/m.exec(content)?.[1]; + const insertSpaces = indentation !== undefined && !indentation.includes('\t'); + return { + insertSpaces, + tabSize: insertSpaces ? indentation.length : 1, + eol: content.includes('\r\n') ? '\r\n' : '\n', + }; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +class McpServerMigrationError extends Error { + constructor( + readonly reason: McpServerMigrationFailureReason, + readonly underlyingError: Error, + ) { + super(underlyingError.message); + } +} + +function toMcpServerMigrationError(error: unknown): McpServerMigrationError { + return error instanceof McpServerMigrationError + ? error + : new McpServerMigrationError(McpServerMigrationFailureReason.WriteFailed, toError(error)); +} + +function createMcpServerMigrationFailure( + candidate: IMcpServerCustomizationMigrationCandidate, + reason: McpServerMigrationFailureReason, + error?: Error, +): IMcpServerMigrationFailure { + return { + id: candidate.id, + name: candidate.name, + sourceUri: candidate.sourceUri, + targetUri: candidate.targetUri, + reason, + error, + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts index b43e2e9fc22fe1..214947d9a26ee1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts @@ -4,27 +4,37 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../../nls.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ChatConfiguration } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { isPromptFileMigrationCandidate, isUserDataMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, IMcpServerMigrationFailure, isMcpServerCustomizationMigrationCandidate, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, McpServerMigrationFailureReason, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; export const enum CustomizationMigrationCategoryId { PromptFiles = 'promptFiles', UserData = 'userData', + McpServers = 'mcpServers', } export interface ICustomizationMigrationGroup { readonly key: string; readonly label: string; - readonly customizations: readonly MigratableConfiguration[]; + readonly customizations: readonly CustomizationMigrationCandidate[]; +} + +export interface ICustomizationMigrationCandidatePresentation { + readonly name: string; + readonly selectionAriaLabel: string; + readonly pathLabel: string; + readonly file?: MigratableConfiguration; } export interface ICustomizationMigrationConfirmation { readonly message: string; readonly detail: string; readonly primaryButton: string; - readonly deleteOriginalsLabel: string; + readonly deleteOriginalsLabel?: string; } /** @@ -37,14 +47,15 @@ export interface ICustomizationMigrationBanner { /** * A self-contained migration flow. Each category owns its candidates, grouping, - * and user-visible copy so the two migrations stay focused and independently readable. + * and user-visible copy so migrations stay focused and independently readable. */ export interface ICustomizationMigrationCategory { readonly id: CustomizationMigrationCategoryId; + readonly migrationType: CustomizationMigrationType; /** Prompt types scanned when collecting candidates for this category. */ - readonly sourceTypes: readonly PromptsType[]; - /** Experimental setting gating this migration. Each category is enabled independently. */ - readonly enablementSetting: ChatConfiguration; + readonly sourceTypes?: readonly PromptsType[]; + /** Optional experimental setting gating this migration. */ + readonly enablementSetting?: ChatConfiguration; readonly shortcutLabel: string; readonly shortcutTooltip: string; readonly cardLabel: string; @@ -56,22 +67,25 @@ export interface ICustomizationMigrationCategory { readonly pageEmptyMessage: string; readonly migrateButtonTooltip: string; readonly backLabel: string; - readonly noFilesMigratedMessage: string; - isCandidate(customization: MigratableConfiguration): boolean; - group(customizations: readonly MigratableConfiguration[]): readonly ICustomizationMigrationGroup[]; + readonly nothingMigratedMessage: string; + isCandidate?(customization: MigratableConfiguration): boolean; + group(customizations: readonly CustomizationMigrationCandidate[]): readonly ICustomizationMigrationGroup[]; + getCandidatePresentation(customization: CustomizationMigrationCandidate, getUriLabel: (uri: URI) => string): ICustomizationMigrationCandidatePresentation; getShortcutAriaLabel(count: number): string; - getCardDescription(customizations: readonly MigratableConfiguration[], harnessLabel: string): string; - getPageDescription(customizations: readonly MigratableConfiguration[], harnessLabel: string): string; + getCardDescription(customizations: readonly CustomizationMigrationCandidate[], harnessLabel: string): string; + getPageDescription(customizations: readonly CustomizationMigrationCandidate[], harnessLabel: string): string; /** When present, replaces the page description with a prominent banner. */ - getBanner?(customizations: readonly MigratableConfiguration[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationBanner; - getConfirmation(customizations: readonly MigratableConfiguration[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationConfirmation; + getBanner?(customizations: readonly CustomizationMigrationCandidate[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationBanner; + getConfirmation(customizations: readonly CustomizationMigrationCandidate[], harnessLabel: string, destinationLabel?: string): ICustomizationMigrationConfirmation; getMigratedMessage(migratedCount: number): string; getMigratedWithReviewMessage?(migratedCount: number, unsupportedHeaderKeys: string): string; getFailedMessage(failedFileNames: readonly string[], hiddenFileCount: number): string; + getMcpServerFailureMessage?(failures: readonly IMcpServerMigrationFailure[]): string; } const SKILLS_DOCUMENTATION_URL = 'https://code.visualstudio.com/docs/agent-customization/agent-skills?referrer=in-product'; const CUSTOMIZATION_DOCUMENTATION_URL = 'https://code.visualstudio.com/docs/agent-customization/overview?referrer=in-product'; +const MCP_DOCUMENTATION_URL = 'https://code.visualstudio.com/docs/agent-customization/mcp-servers?referrer=in-product'; /** * Converts `*.prompt.md` files into skills. Agent-host harnesses ignore prompt @@ -79,6 +93,7 @@ const CUSTOMIZATION_DOCUMENTATION_URL = 'https://code.visualstudio.com/docs/agen */ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { id: CustomizationMigrationCategoryId.PromptFiles, + migrationType: CustomizationMigrationType.PromptFiles, sourceTypes: [PromptsType.prompt], enablementSetting: ChatConfiguration.ChatCustomizationsPromptMigrationEnabled, shortcutLabel: localize('promptMigrationShortcutLabel', "Migrate Prompts"), @@ -92,21 +107,22 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { pageEmptyMessage: localize('promptMigrationPageEmpty', "No prompt files are available to migrate."), migrateButtonTooltip: localize('promptMigrationPageButtonTooltip', "Convert selected prompt files to skills"), backLabel: localize('backToPromptMigration', "Back to Migrate Prompt Files"), - noFilesMigratedMessage: localize('promptMigrationNoFilesConverted', "No prompt files were converted."), + nothingMigratedMessage: localize('promptMigrationNoFilesConverted', "No prompt files were converted."), isCandidate: isPromptFileMigrationCandidate, + getCandidatePresentation: getFileCandidatePresentation, group(customizations) { return [ { key: PromptsStorage.local, label: localize('promptMigrationWorkspaceGroup', "Workspace"), - customizations: customizations.filter(customization => customization.storage === PromptsStorage.local), + customizations: customizations.filter(customization => !isMcpServerCustomizationMigrationCandidate(customization) && customization.storage === PromptsStorage.local), }, { key: PromptsStorage.user, label: localize('promptMigrationUserGroup', "User"), - customizations: customizations.filter(customization => customization.storage === PromptsStorage.user), + customizations: customizations.filter(customization => !isMcpServerCustomizationMigrationCandidate(customization) && customization.storage === PromptsStorage.user), }, ]; }, @@ -216,6 +232,7 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { */ const userDataMigrationCategory: ICustomizationMigrationCategory = { id: CustomizationMigrationCategoryId.UserData, + migrationType: CustomizationMigrationType.UserData, sourceTypes: [PromptsType.agent, PromptsType.instructions], enablementSetting: ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled, shortcutLabel: localize('userDataMigrationShortcutLabel', "Migrate User Data"), @@ -229,9 +246,10 @@ const userDataMigrationCategory: ICustomizationMigrationCategory = { pageEmptyMessage: localize('userDataMigrationPageEmpty', "No user data customizations are available to migrate."), migrateButtonTooltip: localize('userDataMigrationPageButtonTooltip', "Move the selected user data customizations to the active harness"), backLabel: localize('backToUserDataMigration', "Back to Migrate User Data Customizations"), - noFilesMigratedMessage: localize('userDataMigrationNoFilesMigrated', "No user data customizations were migrated."), + nothingMigratedMessage: localize('userDataMigrationNoFilesMigrated', "No user data customizations were migrated."), isCandidate: isUserDataMigrationCandidate, + getCandidatePresentation: getFileCandidatePresentation, group(customizations) { return [ @@ -388,9 +406,117 @@ const userDataMigrationCategory: ICustomizationMigrationCategory = { }, }; +const mcpServersMigrationCategory: ICustomizationMigrationCategory = { + id: CustomizationMigrationCategoryId.McpServers, + migrationType: CustomizationMigrationType.McpServers, + shortcutLabel: localize('mcpMigrationShortcutLabel', "Migrate MCP Servers"), + shortcutTooltip: localize('mcpMigrationShortcutTooltip', "Move supported workspace MCP servers to root .mcp.json files"), + cardLabel: localize('mcpMigrationCardLabel', "Migrate MCP Servers"), + cardActionLabel: localize('mcpMigrationCardAction', "Migrate..."), + cardActionAriaLabel: localize('mcpMigrationCardActionAriaLabel', "Migrate supported workspace MCP servers"), + pageTitle: localize('mcpMigrationPageTitle', "Migrate MCP Servers"), + pageLinkLabel: localize('mcpMigrationLearnMore', "Learn more about MCP servers"), + pageLinkUrl: MCP_DOCUMENTATION_URL, + pageEmptyMessage: localize('mcpMigrationPageEmpty', "No supported workspace MCP servers are available to migrate."), + migrateButtonTooltip: localize('mcpMigrationPageButtonTooltip', "Move selected MCP servers to root .mcp.json files"), + backLabel: localize('backToMcpMigration', "Back to Migrate MCP Servers"), + nothingMigratedMessage: localize('mcpMigrationNoneMigrated', "No MCP servers were migrated."), + + getCandidatePresentation(customization, getUriLabel) { + if (!isMcpServerCustomizationMigrationCandidate(customization)) { + throw new Error('Expected an MCP server migration candidate.'); + } + const sourceLabel = getUriLabel(customization.sourceUri); + return { + name: customization.name, + selectionAriaLabel: localize('mcpMigrationSelectAriaLabel', "Select {0} from {1}", customization.name, sourceLabel), + pathLabel: localize('mcpMigrationItemPath', "{0} to {1}", sourceLabel, getUriLabel(customization.targetUri)), + }; + }, + + group(customizations) { + return [{ + key: 'workspace', + label: localize('mcpMigrationWorkspaceGroup', "Workspace"), + customizations, + }]; + }, + + getShortcutAriaLabel(count) { + return count === 1 + ? localize('mcpMigrationShortcutAriaLabelSingle', "MCP servers, 1 server can be migrated") + : localize('mcpMigrationShortcutAriaLabelWithCount', "MCP servers, {0} servers can be migrated", count); + }, + + getCardDescription(customizations, harnessLabel) { + return customizations.length === 1 + ? localize('mcpMigrationCardDescriptionSingle', "Found 1 supported server in .vscode/mcp.json that can move to the workspace root so {0} can discover it directly.", harnessLabel) + : localize('mcpMigrationCardDescriptionMultiple', "Found {0} supported servers in .vscode/mcp.json that can move to workspace root files so {1} can discover them directly.", customizations.length, harnessLabel); + }, + + getPageDescription(customizations, harnessLabel) { + return customizations.length === 1 + ? localize('mcpMigrationPageDescriptionSingle', "Select the supported MCP server to move so {0} can discover it directly.", harnessLabel) + : localize('mcpMigrationPageDescriptionMultiple', "Select supported MCP servers to move so {0} can discover them directly.", harnessLabel); + }, + + getBanner(_customizations, harnessLabel) { + return { + message: localize('mcpMigrationBannerMessage', "Move supported servers from .vscode/mcp.json to .mcp.json at each workspace root so {0} can discover them directly. Unsupported servers stay in their current files.", harnessLabel), + consequence: localize('mcpMigrationBannerConsequence', "Migrated entries are removed from .vscode/mcp.json. Existing servers with the same name in .mcp.json are not overwritten."), + }; + }, + + getConfirmation(customizations) { + return { + message: customizations.length === 1 + ? localize('mcpMigrationConfirmMessageSingle', "Migrate 1 MCP server to .mcp.json?") + : localize('mcpMigrationConfirmMessageMultiple', "Migrate {0} MCP servers to .mcp.json?", customizations.length), + detail: localize('mcpMigrationConfirmDetail', "The selected entries will be removed from .vscode/mcp.json after they are written successfully."), + primaryButton: localize('mcpMigrationConfirmButton', "Migrate"), + }; + }, + + getMigratedMessage(migratedCount) { + return migratedCount === 1 + ? localize('mcpMigrationCompletedSingle', "Migrated 1 MCP server.") + : localize('mcpMigrationCompletedMultiple', "Migrated {0} MCP servers.", migratedCount); + }, + + getFailedMessage(failedServerNames, hiddenServerCount) { + const failedCount = failedServerNames.length + hiddenServerCount; + if (failedCount === 1) { + return localize('mcpMigrationFailedSingle', "Failed to migrate MCP server: {0}.", failedServerNames[0]); + } + return hiddenServerCount > 0 + ? localize('mcpMigrationFailedWithRemainder', "Failed to migrate {0} MCP servers: {1}, and {2} more.", failedCount, failedServerNames.join(', '), hiddenServerCount) + : localize('mcpMigrationFailedMultiple', "Failed to migrate {0} MCP servers: {1}.", failedCount, failedServerNames.join(', ')); + }, + + getMcpServerFailureMessage(failures) { + if (failures.length !== 1) { + return this.getFailedMessage(failures.slice(0, 3).map(failure => failure.name), Math.max(0, failures.length - 3)); + } + const [failure] = failures; + switch (failure.reason) { + case McpServerMigrationFailureReason.NoLongerEligible: + return localize('mcpMigrationNoLongerEligible', "Could not migrate '{0}' because it is no longer eligible.", failure.name); + case McpServerMigrationFailureReason.SourceChanged: + return localize('mcpMigrationSourceChanged', "Could not migrate '{0}' because its source configuration changed.", failure.name); + case McpServerMigrationFailureReason.TargetConflict: + return localize('mcpMigrationTargetConflict', "Could not migrate '{0}' because .mcp.json already contains a different server with that name.", failure.name); + case McpServerMigrationFailureReason.InvalidTarget: + return localize('mcpMigrationInvalidTarget', "Could not migrate '{0}' because the destination .mcp.json is invalid.", failure.name); + default: + return this.getFailedMessage([failure.name], 0); + } + }, +}; + export const CUSTOMIZATION_MIGRATION_CATEGORIES: readonly ICustomizationMigrationCategory[] = [ promptFilesMigrationCategory, userDataMigrationCategory, + mcpServersMigrationCategory, ]; export function getCustomizationMigrationCategory(id: CustomizationMigrationCategoryId): ICustomizationMigrationCategory { @@ -405,16 +531,34 @@ export function getCustomizationMigrationCategory(id: CustomizationMigrationCate * All prompt types the given categories can discover, so candidates can be collected with one pass per type. */ export function getCustomizationMigrationSourceTypes(categories: readonly ICustomizationMigrationCategory[]): readonly PromptsType[] { - return Array.from(new Set(categories.flatMap(category => category.sourceTypes))); + return Array.from(new Set(categories.flatMap(category => category.sourceTypes ?? []))); +} + +function getFileCandidatePresentation( + customization: CustomizationMigrationCandidate, + getUriLabel: (uri: URI) => string, +): ICustomizationMigrationCandidatePresentation { + if (isMcpServerCustomizationMigrationCandidate(customization)) { + throw new Error('Expected a file migration candidate.'); + } + const name = customization.name ?? basename(customization.uri); + const pathLabel = getUriLabel(customization.uri); + return { + name, + selectionAriaLabel: localize('customizationMigrationSelectAriaLabel', "Select {0}", name), + pathLabel, + file: customization, + }; } -function countPromptStorages(customizations: readonly MigratableConfiguration[]): { workspaceCount: number; userCount: number; totalCount: number } { - const workspaceCount = customizations.filter(customization => customization.storage === PromptsStorage.local).length; - const userCount = customizations.filter(customization => customization.storage === PromptsStorage.user).length; +function countPromptStorages(customizations: readonly CustomizationMigrationCandidate[]): { workspaceCount: number; userCount: number; totalCount: number } { + const fileCustomizations = customizations.filter(customization => !isMcpServerCustomizationMigrationCandidate(customization)); + const workspaceCount = fileCustomizations.filter(customization => customization.storage === PromptsStorage.local).length; + const userCount = fileCustomizations.filter(customization => customization.storage === PromptsStorage.user).length; return { workspaceCount, userCount, totalCount: workspaceCount + userCount }; } -function countUserDataTypes(customizations: readonly MigratableConfiguration[]): { agentCount: number; instructionsCount: number; totalCount: number } { +function countUserDataTypes(customizations: readonly CustomizationMigrationCandidate[]): { agentCount: number; instructionsCount: number; totalCount: number } { const agentCount = customizations.filter(customization => customization.type === PromptsType.agent).length; const instructionsCount = customizations.filter(customization => customization.type === PromptsType.instructions).length; return { agentCount, instructionsCount, totalCount: agentCount + instructionsCount }; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationModel.ts new file mode 100644 index 00000000000000..ec1493b0019042 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationModel.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { RunOnceScheduler, Throttler } from '../../../../../base/common/async.js'; +import { Event } from '../../../../../base/common/event.js'; +import { getErrorMessage, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { getComparisonKey, isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { isAgentHostTarget } from '../../common/chatSessionsService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, isMcpServerCustomizationMigrationCandidate } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IMcpService } from '../../../mcp/common/mcpTypes.js'; +import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId } from './customizationMigrationCategories.js'; + +export interface ICustomizationMigrationModelState { + readonly loading: boolean; + readonly loadError?: string; + readonly candidatesByCategory: ReadonlyMap; + readonly targetFoldersByType: ReadonlyMap; +} + +const emptyMigrationState: ICustomizationMigrationModelState = { + loading: false, + candidatesByCategory: new Map(), + targetFoldersByType: new Map(), +}; + +const allMigrationCategoryIds = CUSTOMIZATION_MIGRATION_CATEGORIES.map(category => category.id); + +interface ICustomizationMigrationRefreshContext { + readonly generation: number; + readonly harnessId: string; + readonly sessionResource: URI; +} + +type CustomizationMigrationCategoryCandidates = readonly [ + CustomizationMigrationCategoryId, + readonly CustomizationMigrationCandidate[], +]; + +/** + * Owns migration discovery and refresh lifecycle independently from the management editor's DOM. + */ +export class CustomizationMigrationModel extends Disposable { + private readonly _state = observableValue(this, emptyMigrationState); + readonly state: IObservable = this._state; + + // Prevents an in-flight refresh from publishing after the session or working-directory context changes away and back. + private contextGeneration = 0; + private workingDirectoriesSignature = ''; + private contextKey = ''; + private readonly pendingCategories = new Set(); + private readonly refreshThrottler = this._register(new Throttler()); + private readonly refreshScheduler = this._register(new RunOnceScheduler(() => { + void this.runPendingRefresh(); + }, 0)); + + constructor( + @ICustomizationMigrationService private readonly migrationService: ICustomizationMigrationService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IPromptsService promptsService: IPromptsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IMcpService mcpService: IMcpService, + @IAgentHostCustomizationService agentHostCustomizationService: IAgentHostCustomizationService, + ) { + super(); + + // Prompt and user-data changes. + this._register(promptsService.onDidChangeSlashCommands(() => this.scheduleRefresh([CustomizationMigrationCategoryId.PromptFiles]))); + this._register(Event.any( + promptsService.onDidChangeCustomAgents, + promptsService.onDidChangeInstructions, + promptsService.onDidChangeAgentInstructions, + )(() => this.scheduleRefresh([CustomizationMigrationCategoryId.UserData]))); + + // Migration enablement changes. + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (CUSTOMIZATION_MIGRATION_CATEGORIES.some(category => category.enablementSetting && event.affectsConfiguration(category.enablementSetting))) { + this.scheduleRefresh(); + } + })); + + // Active session and working-directory changes. + this._register(autorun(reader => { + const sessionResource = this.harnessService.activeSessionResource.read(reader); + const harnessId = this.harnessService.activeHarness.read(reader); + const nextContextKey = `${harnessId}\n${getComparisonKey(sessionResource)}`; + if (nextContextKey !== this.contextKey) { + this.contextKey = nextContextKey; + this.contextGeneration++; + this._state.set(emptyMigrationState, undefined); + } + this.workingDirectoriesSignature = agentHostCustomizationService.getWorkingDirectories(sessionResource).join('\n'); + this.scheduleRefresh(); + })); + this._register(agentHostCustomizationService.onDidChangeCustomizations(() => { + const sessionResource = this.harnessService.activeSessionResource.get(); + const nextSignature = agentHostCustomizationService.getWorkingDirectories(sessionResource).join('\n'); + if (nextSignature !== this.workingDirectoriesSignature) { + this.workingDirectoriesSignature = nextSignature; + this.contextGeneration++; + this.scheduleRefresh(); + } + })); + + // MCP inventory changes. + this._register(autorun(reader => { + for (const server of mcpService.servers.read(reader)) { + server.enablement.read(reader); + server.readDefinitions().read(reader); + } + this.scheduleRefresh([CustomizationMigrationCategoryId.McpServers]); + })); + } + + async refresh(): Promise { + this.refreshScheduler.cancel(); + this.addPendingCategories(allMigrationCategoryIds); + await this.runPendingRefresh(); + } + + private scheduleRefresh(categories = allMigrationCategoryIds): void { + this.addPendingCategories(categories); + this.refreshScheduler.schedule(); + } + + private addPendingCategories(categories: readonly CustomizationMigrationCategoryId[]): void { + for (const category of categories) { + this.pendingCategories.add(category); + } + } + + private runPendingRefresh(): Promise { + if (this.pendingCategories.size === 0) { + return Promise.resolve(); + } + + return this.refreshThrottler.queue(async () => { + if (this.pendingCategories.size === 0) { + return; + } + const categoryIds = new Set(this.pendingCategories); + this.pendingCategories.clear(); + await this.refreshCategories(categoryIds); + }); + } + + private async refreshCategories(categoryIds: ReadonlySet): Promise { + // Capture one context snapshot so asynchronous work can reject results invalidated while it was running. + const context: ICustomizationMigrationRefreshContext = { + generation: this.contextGeneration, + harnessId: this.harnessService.activeHarness.get(), + sessionResource: this.harnessService.activeSessionResource.get(), + }; + this._state.set({ + ...this._state.get(), + loading: categoryIds.size === allMigrationCategoryIds.length, + loadError: undefined, + }, undefined); + + if (!isAgentHostTarget(context.harnessId)) { + this.setStateIfCurrent(context, emptyMigrationState); + return; + } + + try { + const state = await this.computeRefreshState(context, categoryIds); + if (state) { + this.setStateIfCurrent(context, state); + } + } catch (error) { + if (this.isCurrent(context)) { + this._state.set({ + ...this._state.get(), + loading: false, + loadError: getErrorMessage(error), + }, undefined); + } + onUnexpectedError(error); + } + } + + private async computeRefreshState( + context: ICustomizationMigrationRefreshContext, + categoryIds: ReadonlySet, + ): Promise { + const enabledCategories = CUSTOMIZATION_MIGRATION_CATEGORIES.filter(category => this.isCategoryEnabled(category.id)); + if (enabledCategories.length === 0) { + return emptyMigrationState; + } + + const categoriesToRefresh = enabledCategories.filter(category => categoryIds.has(category.id)); + // Compute enabled categories together so the refresh publishes one consistent candidate snapshot. + const migrationsByCategory: CustomizationMigrationCategoryCandidates[] = await Promise.all(categoriesToRefresh.map(async category => { + const migration = category.migrationType === CustomizationMigrationType.McpServers + ? await this.migrationService.computeMigration(context.sessionResource, CustomizationMigrationType.McpServers) + : await this.migrationService.computeMigration(context.sessionResource, category.migrationType); + return [category.id, migration.candidates] as const; + })); + if (!this.isCurrent(context)) { + return undefined; + } + + // Preserve untouched enabled categories while replacing only those included in this partial refresh. + const enabledCategoryIds = new Set(enabledCategories.map(category => category.id)); + const candidatesByCategory = new Map( + [...this._state.get().candidatesByCategory].filter(([categoryId]) => enabledCategoryIds.has(categoryId)) + ); + for (const [categoryId, candidates] of migrationsByCategory) { + candidatesByCategory.set(categoryId, candidates); + } + const refreshesFileCategories = categoriesToRefresh.some(category => category.migrationType !== CustomizationMigrationType.McpServers); + const targetFoldersByType = refreshesFileCategories + ? await this.computeTargetFolders(context, candidatesByCategory) + : this._state.get().targetFoldersByType; + return { + loading: false, + candidatesByCategory, + targetFoldersByType, + }; + } + + private async computeTargetFolders( + context: ICustomizationMigrationRefreshContext, + candidatesByCategory: ReadonlyMap, + ): Promise> { + const provider = this.harnessService.findHarnessById(context.harnessId)?.itemProvider; + const targetTypes = new Set([...candidatesByCategory.values()].flat() + .filter(candidate => !isMcpServerCustomizationMigrationCandidate(candidate)) + .map(getCustomizationMigrationTargetType)); + const targetFolderEntries = await Promise.all([...targetTypes].map(async targetType => { + const folders = await provider?.provideSourceFolders?.(context.sessionResource, targetType, CancellationToken.None); + return [targetType, folders ?? []] as const; + })); + return new Map(targetFolderEntries); + } + + isCategoryEnabled(categoryId: CustomizationMigrationCategoryId): boolean { + const category = CUSTOMIZATION_MIGRATION_CATEGORIES.find(candidate => candidate.id === categoryId); + return !!category && (!category.enablementSetting || this.configurationService.getValue(category.enablementSetting) === true); + } + + private setStateIfCurrent( + context: ICustomizationMigrationRefreshContext, + state: ICustomizationMigrationModelState, + ): void { + if (this.isCurrent(context)) { + this._state.set(state, undefined); + } + } + + private isCurrent(context: ICustomizationMigrationRefreshContext): boolean { + return context.generation === this.contextGeneration + && context.harnessId === this.harnessService.activeHarness.get() + && isEqual(context.sessionResource, this.harnessService.activeSessionResource.get()); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts index b37d51b5b4aae3..a9fab1c75ce2a6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.ts @@ -6,25 +6,33 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { CustomizationMigration, CustomizationMigrationType, FileCustomizationMigration, FileCustomizationMigrationType, getCustomizationMigrationTargetType, ICustomizationMigrationService, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, McpServerCustomizationMigration, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigration, CustomizationMigrationType, FileCustomizationMigration, FileCustomizationMigrationType, getCustomizationMigrationTargetType, getMcpServerCustomizationMigrationCandidateKey, ICustomizationMigrationService, IMcpServerCustomizationMigrationCandidate, IMcpServerMigrationFailure, IMcpServerMigrationResult, isPromptFileMigrationCandidate, isUserDataMigrationCandidate, McpServerCustomizationMigration, McpServerMigrationFailureReason, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { IAgentHostActiveClientService } from '../agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { AgentHostMcpServerApplicability } from '../agentSessions/agentHost/agentHostMcpServerSupport.js'; +import { McpServerMigration } from './customizationMigration.js'; export class CustomizationMigrationService implements ICustomizationMigrationService { declare readonly _serviceBrand: undefined; + private readonly mcpServerMigration: McpServerMigration; constructor( @IPromptsService private readonly promptsService: IPromptsService, @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @IAgentHostActiveClientService private readonly activeClientService: IAgentHostActiveClientService, @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, - ) { } + @IFileService fileService: IFileService, + @ILogService private readonly logService: ILogService, + ) { + this.mcpServerMigration = new McpServerMigration(fileService); + } computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; @@ -60,6 +68,34 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer ]); } + async migrateMcpServers(sessionResource: URI, requestedCandidates: readonly IMcpServerCustomizationMigrationCandidate[]): Promise { + const currentMigration = await this.computeMigration(sessionResource, CustomizationMigrationType.McpServers); + const currentCandidateKeys = new Set(currentMigration.candidates.map(getMcpServerCustomizationMigrationCandidateKey)); + const eligibleCandidates = requestedCandidates.filter(candidate => currentCandidateKeys.has(getMcpServerCustomizationMigrationCandidateKey(candidate))); + const failures: IMcpServerMigrationFailure[] = requestedCandidates + .filter(candidate => !currentCandidateKeys.has(getMcpServerCustomizationMigrationCandidateKey(candidate))) + .map(candidate => ({ + id: candidate.id, + name: candidate.name, + sourceUri: candidate.sourceUri, + targetUri: candidate.targetUri, + reason: McpServerMigrationFailureReason.NoLongerEligible, + })); + + this.logService.info(`[MCP Migration] Starting migration: selected=${requestedCandidates.length}, eligible=${eligibleCandidates.length}, noLongerEligible=${failures.length}`); + const result = await this.mcpServerMigration.migrate(eligibleCandidates); + const combined = { migratedCount: result.migratedCount, failures: [...failures, ...result.failures] }; + for (const failure of combined.failures) { + if (failure.error) { + this.logService.error(`[MCP Migration] Failed server: reason=${failure.reason}, name=${failure.name}`, failure.error); + } else { + this.logService.warn(`[MCP Migration] Failed server: reason=${failure.reason}, name=${failure.name}`); + } + } + this.logService.info(`[MCP Migration] Finished migration: migrated=${combined.migratedCount}, failed=${combined.failures.length}`); + return combined; + } + async computeMigrationHint(sessionResource: URI): Promise { const harness = this.customizationHarnessService.findHarnessById(getChatSessionType(sessionResource)); if (!harness) { @@ -72,21 +108,38 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer this.computeMigration(sessionResource, CustomizationMigrationType.McpServers), ]); const fileCount = userDataMigration.files.length + promptFilesMigration.files.length; + const migratableMcpServerCount = mcpServerMigration.candidates.length; const unsupportedMcpServerCount = mcpServerMigration.servers.filter(server => !server.supported).length; const fileHint = fileCount === 0 ? undefined : fileCount === 1 ? localize('customizationMigrationHintSingle', "Found 1 customization file that is present but not used by {0} and could be migrated.", harness.label) : localize('customizationMigrationHintMultiple', "Found {0} customization files that are present but not used by {1} and could be migrated.", fileCount, harness.label); - const mcpHint = unsupportedMcpServerCount === 0 + const mcpMigrationHint = migratableMcpServerCount === 0 + ? undefined + : migratableMcpServerCount === 1 + ? localize('customizationMigrationHintMigratableMcpSingle', "Found 1 workspace MCP server that can be migrated for {0}.", harness.label) + : localize('customizationMigrationHintMigratableMcpMultiple', "Found {0} workspace MCP servers that can be migrated for {1}.", migratableMcpServerCount, harness.label); + const unsupportedMcpHint = unsupportedMcpServerCount === 0 ? undefined : unsupportedMcpServerCount === 1 - ? localize('customizationMigrationHintMcpSingle', "Found 1 MCP server that is not fully supported by {0}.", harness.label) - : localize('customizationMigrationHintMcpMultiple', "Found {0} MCP servers that are not fully supported by {1}.", unsupportedMcpServerCount, harness.label); - if (fileHint && mcpHint) { - return localize('customizationMigrationHintCombined', "{0} {1}", fileHint, mcpHint); + ? localize('customizationMigrationHintUnsupportedMcpSingle', "Found 1 MCP server that is not fully supported by {0}.", harness.label) + : localize('customizationMigrationHintUnsupportedMcpMultiple', "Found {0} MCP servers that are not fully supported by {1}.", unsupportedMcpServerCount, harness.label); + const hints: string[] = []; + if (fileHint) { + hints.push(fileHint); + } + if (mcpMigrationHint) { + hints.push(mcpMigrationHint); + } + if (unsupportedMcpHint) { + hints.push(unsupportedMcpHint); } - return fileHint ?? mcpHint; + let hint = hints.shift(); + for (const nextHint of hints) { + hint = localize('customizationMigrationHintCombined', "{0} {1}", hint, nextHint); + } + return hint; } private async createFileMigration(sessionResource: URI, type: FileCustomizationMigrationType, candidates: readonly MigratableConfiguration[]): Promise { @@ -109,7 +162,7 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer } private async computeMcpServerMigration(sessionResource: URI): Promise { - const roots = this.agentHostCustomizationService.getWorkingDirectories(sessionResource).map(path => URI.file(path)); + const roots = this.agentHostCustomizationService.getWorkingDirectories(sessionResource).map(path => URI.parse(path)); const scope = this.activeClientService.acquireMcpServerSupportScope(getChatSessionType(sessionResource), roots); if (!scope) { return this.emptyMcpServerMigration(); @@ -118,6 +171,11 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer try { await scope.whenResolved(); const snapshot = scope.support.get(); + const plan = await this.mcpServerMigration.createPlan(snapshot); + if (plan.exclusions.length > 0) { + const exclusionsByReason = Object.groupBy(plan.exclusions, exclusion => exclusion.reason); + this.logService.debug(`[MCP Migration] Excluded candidates: ${Object.entries(exclusionsByReason).map(([reason, exclusions]) => `${reason}=${exclusions?.length ?? 0}`).join(', ')}`); + } return { type: CustomizationMigrationType.McpServers, servers: snapshot.servers @@ -127,6 +185,7 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer name: server.name, supported: server.compatibility.kind === 'supported', })), + candidates: plan.candidates, discoveryComplete: snapshot.discoveryComplete, coverage: snapshot.coverage, }; @@ -139,6 +198,7 @@ export class CustomizationMigrationService implements ICustomizationMigrationSer return { type: CustomizationMigrationType.McpServers, servers: [], + candidates: [], discoveryComplete: true, coverage: { restrictedByMcpAccess: false, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts index c772bce4565a70..cd97abffadad1e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { getComparisonKey } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { PromptFileSource, PromptsType } from '../promptTypes.js'; import { PromptsStorage } from './promptsService.js'; @@ -54,6 +56,24 @@ export interface IMcpServerCustomizationMigrationItem { readonly supported: boolean; } +export interface IMcpServerCustomizationMigrationCandidate { + readonly type: CustomizationMigrationType.McpServers; + readonly id: string; + readonly name: string; + readonly sourceUri: URI; + readonly targetUri: URI; + readonly configuration: IMcpServerConfiguration; +} + +export function getMcpServerCustomizationMigrationCandidateKey(candidate: IMcpServerCustomizationMigrationCandidate): string { + return JSON.stringify([ + candidate.id, + candidate.name, + getComparisonKey(candidate.sourceUri), + getComparisonKey(candidate.targetUri), + ]); +} + export interface IAgentHostMcpServerSupportCoverage { /** Some installed servers may be absent or disabled because MCP access is restricted. */ readonly restrictedByMcpAccess: boolean; @@ -64,12 +84,47 @@ export interface IAgentHostMcpServerSupportCoverage { export interface McpServerCustomizationMigration { readonly type: CustomizationMigrationType.McpServers; readonly servers: readonly IMcpServerCustomizationMigrationItem[]; + readonly candidates: readonly IMcpServerCustomizationMigrationCandidate[]; /** Whether all lazy MCP collections known to the client have loaded; when false, servers may be missing. */ readonly discoveryComplete: boolean; /** Snapshot-wide restrictions that may limit inventory or delivery, independent of per-server support. */ readonly coverage: IAgentHostMcpServerSupportCoverage; } +export const enum McpServerMigrationFailureReason { + NoLongerEligible = 'noLongerEligible', + SourceUnavailable = 'sourceUnavailable', + InvalidSource = 'invalidSource', + UnrepresentableConfiguration = 'unrepresentableConfiguration', + SourceChanged = 'sourceChanged', + InvalidTarget = 'invalidTarget', + TargetConflict = 'targetConflict', + TargetChanged = 'targetChanged', + WriteFailed = 'writeFailed', + RollbackFailed = 'rollbackFailed', + InconsistentTarget = 'inconsistentTarget', +} + +export interface IMcpServerMigrationFailure { + readonly id: string; + readonly name: string; + readonly sourceUri: URI; + readonly targetUri: URI; + readonly reason: McpServerMigrationFailureReason; + readonly error?: Error; +} + +export interface IMcpServerMigrationResult { + readonly migratedCount: number; + readonly failures: readonly IMcpServerMigrationFailure[]; +} + +export type CustomizationMigrationCandidate = MigratableConfiguration | IMcpServerCustomizationMigrationCandidate; + +export function isMcpServerCustomizationMigrationCandidate(candidate: CustomizationMigrationCandidate): candidate is IMcpServerCustomizationMigrationCandidate { + return candidate.type === CustomizationMigrationType.McpServers; +} + export type CustomizationMigration = FileCustomizationMigration | McpServerCustomizationMigration; export interface ICustomizationMigrationService { @@ -77,6 +132,7 @@ export interface ICustomizationMigrationService { computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; + migrateMcpServers(sessionResource: URI, candidates: readonly IMcpServerCustomizationMigrationCandidate[]): Promise; computeMigrations(sessionResource: URI): Promise; computeMigrationHint(sessionResource: URI): Promise; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 9e57d45a24ae4a..67b0e79b5dadf5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -1494,6 +1494,26 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + test('retains working directories after rebinding a provisional session', async () => { + const folderA = URI.file('/repoA'); + const folderB = URI.file('/repoB'); + workspaceFolders = [folderA, folderB]; + agentHost.rootStateAgents = [agentInfo('copilot', true)]; + const untitled = untitledChatUri('rebind-roots'); + const real = URI.from({ scheme: 'agent-host-copilot', path: '/real-rebind-roots' }); + + await provisional.getOrCreate(untitled, 'copilot', folderA); + await provisional.tryRebind(untitled, real, 'copilot'); + + assert.deepStrictEqual({ + untitled: provisional.getProvisionalWorkingDirectories(untitled), + real: provisional.getProvisionalWorkingDirectories(real)?.map(directory => directory.toString()), + }, { + untitled: undefined, + real: [folderA.toString(), folderB.toString()], + }); + }); + test('sends only the primary when the provider does not advertise multiple working directories', async () => { const folderA = URI.file('/repoA'); const folderB = URI.file('/repoB'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index f87a74a02303f7..8ad09df557e6df 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -539,7 +539,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.strictEqual(bundler.received.length, 1); assert.deepStrictEqual(bundler.receivedMcp[0], [ - { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(true) }, + { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, enablement: globalEnablement(true) }, ]); assert.strictEqual(refs.length, 1); assert.strictEqual(refs[0].name, 'Open Plugin'); @@ -572,8 +572,8 @@ suite('resolveCustomizationRefs - built-in skills', () => { ); assert.deepStrictEqual(bundler.receivedMcp, [[ - { name: 'GitHub', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(true) }, - { name: 'extension-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(true) }, + { name: 'GitHub', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, enablement: globalEnablement(true) }, + { name: 'extension-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, enablement: globalEnablement(true) }, ]]); }); @@ -593,7 +593,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { ); assert.deepStrictEqual(bundler.receivedMcp, [[ - { name: 'GitHub', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(true) }, + { name: 'GitHub', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, enablement: globalEnablement(true) }, ]]); }); @@ -664,7 +664,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { ); assert.deepStrictEqual(bundler.receivedMcp[0], [ - { name: 'off', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(false) }, + { name: 'off', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, enablement: globalEnablement(false) }, ]); }); @@ -754,7 +754,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.strictEqual(bundler.received.length, 1); assert.deepStrictEqual(bundler.receivedMcp[0], [ - { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, defaultCwd, enablement: globalEnablement(true) }, + { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, defaultCwd, enablement: globalEnablement(true) }, ]); assert.strictEqual(refs.length, 1); assert.strictEqual(refs[0].name, 'Open Plugin'); @@ -845,7 +845,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.strictEqual(bundler.received.length, 1); assert.deepStrictEqual(bundler.receivedMcp[0], [ - { name: 'folder-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--root', '/ws'], env: undefined, envFile: undefined, cwd: undefined }, defaultCwd, enablement: globalEnablement(true) }, + { name: 'folder-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--root', '/ws'], env: undefined }, defaultCwd, enablement: globalEnablement(true) }, ]); assert.strictEqual(refs.length, 1); }); @@ -899,7 +899,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { assert.strictEqual(bundler.received.length, 1); assert.deepStrictEqual(bundler.receivedMcp[0], [{ name: 'srv', - configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, + configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined }, defaultCwd: extensionDefaultCwd, enablement: globalEnablement(true), }]); 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 22599bf3cde971..1274b2f2d4c614 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 @@ -5,25 +5,26 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../../../base/common/map.js'; import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import type { IManagedHover } from '../../../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { MigratableConfiguration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, IMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { IHeaderAttribute } from '../../../common/promptSyntax/promptFileParser.js'; import { PromptFileSource, PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; -import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; +import { CustomizationMigrationCategoryId, getCustomizationMigrationCategory } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { ICustomizationMigrationModelState } from '../../../browser/aiCustomization/customizationMigrationModel.js'; suite('aiCustomizationManagementEditor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -55,10 +56,12 @@ suite('aiCustomizationManagementEditor', () => { currentEditingPromptType: PromptsType | undefined; currentEditingSource: string | undefined; currentEditingReadOnly: boolean; - customizationsByMigrationCategory: Map; - customizationMigrationTargetFoldersByType: Map; + customizationMigrationModel: { + state: ISettableObservable; + refresh(): Promise; + isCategoryEnabled(categoryId: CustomizationMigrationCategoryId): boolean; + }; customizationMigrationInProgress: boolean; - customizationMigrationWritesInProgress: boolean; activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; editorDisplayMode: 'preview' | 'raw'; editorPreviewFrontMatterContainer: HTMLElement | undefined; @@ -76,7 +79,8 @@ suite('aiCustomizationManagementEditor', () => { migrationDescriptionElement: HTMLElement | undefined; migrationBannerContainer: HTMLElement | undefined; migrationLinkElement: HTMLAnchorElement | undefined; - selectedCustomizationMigrationItems: ResourceMap>; + selectedCustomizationMigrationItems: Set; + presentedCustomizationMigrationItems: Set; migrationPageDisposables: DisposableStore; labelService: { getUriLabel(uri: URI, options?: { relative?: boolean }): string }; quickInputService: { @@ -93,14 +97,12 @@ suite('aiCustomizationManagementEditor', () => { renderPreviewAttribute(attribute: IHeaderAttribute, promptType: PromptsType, target: Target): void; onStructuredPreviewSettingChanged(): void; refreshCustomizationMigrationUi(): void; - refreshCustomizationMigrationInfoFromPromptChange(): void; refreshCustomizationMigrationInfo(): Promise; - registerCustomizationMigrationSessionRefresh(): void; renderCustomizationMigrationPage(): void; updateCustomizationMigrationActionState(): void; - setCustomizationsToMigrate(candidates: Map, targetFoldersByType: Map): void; - isCustomizationSelectedForMigration(customization: MigratableConfiguration): boolean; - setCustomizationSelectedForMigration(customization: MigratableConfiguration, selected: boolean): void; + setCustomizationsToMigrate(candidates: ReadonlyMap): void; + isCustomizationSelectedForMigration(customization: CustomizationMigrationCandidate): boolean; + setCustomizationSelectedForMigration(customization: CustomizationMigrationCandidate, selected: boolean): void; resolveCustomizationMigrationTargetFolders( customizations: readonly MigratableConfiguration[], availableSourceFolders: ReadonlyMap, @@ -127,10 +129,20 @@ suite('aiCustomizationManagementEditor', () => { editor.currentEditingPromptType = undefined; editor.currentEditingSource = undefined; editor.currentEditingReadOnly = false; - editor.customizationsByMigrationCategory = new Map(); - editor.customizationMigrationTargetFoldersByType = new Map(); + const migrationState = observableValue('migrationState', { + loading: false, + candidatesByCategory: new Map(), + targetFoldersByType: new Map(), + }); + editor.customizationMigrationModel = { + state: migrationState, + refresh: async () => { }, + isCategoryEnabled: categoryId => { + const category = getCustomizationMigrationCategory(categoryId); + return !category.enablementSetting || editor.configurationService.getValue(category.enablementSetting) === true; + }, + }; editor.customizationMigrationInProgress = false; - editor.customizationMigrationWritesInProgress = false; editor.activeMigrationCategoryId = undefined; editor.editorDisplayMode = 'preview'; editor.editorPreviewFrontMatterContainer = document.createElement('div'); @@ -154,7 +166,8 @@ suite('aiCustomizationManagementEditor', () => { editor.migrationDescriptionElement = undefined; editor.migrationBannerContainer = undefined; editor.migrationLinkElement = undefined; - editor.selectedCustomizationMigrationItems = new ResourceMap(); + editor.selectedCustomizationMigrationItems = new Set(); + editor.presentedCustomizationMigrationItems = new Set(); editor.migrationPageDisposables = editor.editorPreviewDisposables.add(new DisposableStore()); editor.labelService = { getUriLabel: uri => uri.path, @@ -180,6 +193,18 @@ suite('aiCustomizationManagementEditor', () => { return editor; } + function setMigrationState( + editor: TestableEditor, + candidatesByCategory: ReadonlyMap, + targetFoldersByType: ReadonlyMap = new Map(), + ): void { + editor.customizationMigrationModel.state.set({ + loading: false, + candidatesByCategory, + targetFoldersByType, + }, undefined); + } + function createScalarAttribute(key: string, value: string): IHeaderAttribute { return { key, @@ -285,14 +310,14 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); - test('gates each migration category on its own experimental setting', () => { + test('gates optional migration categories on their experimental settings', () => { const welcomePageCalls: ICustomizationMigrationCategorySummary[][] = []; const configurationService = createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: false, [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: false, }) as IConfigurationService & { setValue(key: string, value: unknown): void }; const editor = createTestEditor(undefined, configurationService); - editor.customizationsByMigrationCategory = new Map([ + setMigrationState(editor, new Map([ [CustomizationMigrationCategoryId.PromptFiles, [{ uri: URI.file('/workspace/.github/prompts/prompt.prompt.md'), storage: PromptsStorage.local, @@ -305,7 +330,15 @@ suite('aiCustomizationManagementEditor', () => { type: PromptsType.agent, source: PromptFileSource.UserData, } as MigratableConfiguration]], - ]); + [CustomizationMigrationCategoryId.McpServers, [{ + type: CustomizationMigrationType.McpServers, + id: 'mcp.config.ws0.server', + name: 'server', + sourceUri: URI.file('/workspace/.vscode/mcp.json'), + targetUri: URI.file('/workspace/.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: 'server' }, + } as IMcpServerCustomizationMigrationCandidate]], + ])); editor.welcomePage = { setMigrationCategories: categories => welcomePageCalls.push([...categories as readonly ICustomizationMigrationCategorySummary[]]), }; @@ -317,9 +350,9 @@ suite('aiCustomizationManagementEditor', () => { editor.refreshCustomizationMigrationUi(); assert.deepStrictEqual(welcomePageCalls.map(categories => categories.map(category => category.id)), [ - [], - [CustomizationMigrationCategoryId.UserData], - [CustomizationMigrationCategoryId.PromptFiles, CustomizationMigrationCategoryId.UserData], + [CustomizationMigrationCategoryId.McpServers], + [CustomizationMigrationCategoryId.UserData, CustomizationMigrationCategoryId.McpServers], + [CustomizationMigrationCategoryId.PromptFiles, CustomizationMigrationCategoryId.UserData, CustomizationMigrationCategoryId.McpServers], ]); editor.editorPreviewDisposables.dispose(); }); @@ -345,80 +378,93 @@ suite('aiCustomizationManagementEditor', () => { [CustomizationMigrationCategoryId.PromptFiles, [workspacePrompt, userPrompt]], ]); - editor.setCustomizationsToMigrate(candidates, new Map()); + setMigrationState(editor, candidates); + editor.setCustomizationsToMigrate(candidates); editor.setCustomizationSelectedForMigration(workspacePrompt, false); - editor.setCustomizationsToMigrate(candidates, new Map()); + editor.setCustomizationsToMigrate(candidates); assert.deepStrictEqual({ workspaceSelected: editor.isCustomizationSelectedForMigration(workspacePrompt), userSelected: editor.isCustomizationSelectedForMigration(userPrompt), - selectedStorages: [...(editor.selectedCustomizationMigrationItems.get(sharedUri) ?? [])], + selectedItems: [...editor.selectedCustomizationMigrationItems], }, { workspaceSelected: false, userSelected: true, - selectedStorages: [PromptsStorage.user], + selectedItems: [`file:${sharedUri.toString()}:${PromptsStorage.user}`], }); editor.editorPreviewDisposables.dispose(); }); - test('refreshes migration state when the active session changes within one harness', () => { + test('renders individually selectable MCP server migration candidates', () => { const editor = createTestEditor(); - const sessionA = URI.parse('agent-host-test:/session-a'); - const sessionB = URI.parse('agent-host-test:/session-b'); - const refreshedSessions: string[] = []; - editor.harnessService.activeSessionResource.set(sessionA, undefined); - editor.refreshCustomizationMigrationInfo = async () => { - const sessionResource = editor.harnessService.activeSessionResource.get(); - refreshedSessions.push(sessionResource.path); - editor.customizationsByMigrationCategory = new Map([[ - CustomizationMigrationCategoryId.UserData, - [{ - uri: URI.file(`/user-data${sessionResource.path}.instructions.md`), - storage: PromptsStorage.user, - type: PromptsType.instructions, - source: PromptFileSource.UserData, - } as MigratableConfiguration], - ]]); - editor.customizationMigrationTargetFoldersByType = new Map([[ - PromptsType.instructions, - [{ - uri: URI.file('/home/test/.test-harness' + sessionResource.path + '/instructions'), - label: sessionResource.path, - source: AICustomizationSources.user, - }], - ]]); + const serverA: IMcpServerCustomizationMigrationCandidate = { + type: CustomizationMigrationType.McpServers, + id: 'mcp.config.ws0.server-a', + name: 'server', + sourceUri: URI.file('/workspace-a/.vscode/mcp.json'), + targetUri: URI.file('/workspace-a/.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: 'server' }, }; + const serverB: IMcpServerCustomizationMigrationCandidate = { + ...serverA, + id: 'mcp.config.ws0.server-b', + sourceUri: URI.file('/workspace-b/.vscode/mcp.json'), + targetUri: URI.file('/workspace-b/.mcp.json'), + }; + setMigrationState(editor, new Map([[CustomizationMigrationCategoryId.McpServers, [serverA, serverB]]])); + editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.McpServers; + editor.selectedCustomizationMigrationItems = new Set(); + editor.setCustomizationSelectedForMigration(serverA, true); + editor.migrationListContainer = document.createElement('div'); + editor.migrationMigrateButton = { enabled: false, label: '' }; - editor.registerCustomizationMigrationSessionRefresh(); - editor.harnessService.activeSessionResource.set(sessionB, undefined); + editor.renderCustomizationMigrationPage(); assert.deepStrictEqual({ - refreshedSessions, - candidatePaths: [...editor.customizationsByMigrationCategory.values()].flat().map(candidate => candidate.uri.path), - destinationPaths: [...editor.customizationMigrationTargetFoldersByType.values()].flat().map(folder => folder.uri.path), + rowText: [...editor.migrationListContainer.querySelectorAll('.prompt-migration-item')].map(row => row.textContent), + selectAriaLabel: [...editor.migrationListContainer.querySelectorAll('.prompt-migration-item [aria-label]')].map(element => element.getAttribute('aria-label')), + button: editor.migrationMigrateButton, }, { - refreshedSessions: ['/session-a', '/session-b'], - candidatePaths: ['/user-data/session-b.instructions.md'], - destinationPaths: ['/home/test/.test-harness/session-b/instructions'], + rowText: [ + 'server/workspace-a/.vscode/mcp.json to /workspace-a/.mcp.json', + 'server/workspace-b/.vscode/mcp.json to /workspace-b/.mcp.json', + ], + selectAriaLabel: [ + 'Select server from /workspace-a/.vscode/mcp.json', + 'Select server from /workspace-b/.vscode/mcp.json', + ], + button: { enabled: true, label: 'Migrate 1' }, }); editor.editorPreviewDisposables.dispose(); }); - test('suppresses prompt change refreshes only while migration writes are in progress', () => { + test('does not preserve MCP migration selection when a positional ID moves to another source', () => { const editor = createTestEditor(); - let refreshCount = 0; - editor.refreshCustomizationMigrationInfo = async () => { - refreshCount++; + const serverA: IMcpServerCustomizationMigrationCandidate = { + type: CustomizationMigrationType.McpServers, + id: 'mcp.config.ws0.server', + name: 'server', + sourceUri: URI.file('/workspace-a/.vscode/mcp.json'), + targetUri: URI.file('/workspace-a/.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: 'server' }, + }; + const serverB: IMcpServerCustomizationMigrationCandidate = { + ...serverA, + sourceUri: URI.file('/workspace-b/.vscode/mcp.json'), + targetUri: URI.file('/workspace-b/.mcp.json'), }; - editor.customizationMigrationInProgress = true; - editor.refreshCustomizationMigrationInfoFromPromptChange(); - editor.customizationMigrationWritesInProgress = true; - editor.refreshCustomizationMigrationInfoFromPromptChange(); - editor.customizationMigrationWritesInProgress = false; - editor.refreshCustomizationMigrationInfoFromPromptChange(); + editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.McpServers, [serverA]]])); + editor.setCustomizationSelectedForMigration(serverA, false); + editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.McpServers, [serverB]]])); - assert.strictEqual(refreshCount, 2); + assert.deepStrictEqual({ + oldSourceSelected: editor.isCustomizationSelectedForMigration(serverA), + newSourceSelected: editor.isCustomizationSelectedForMigration(serverB), + }, { + oldSourceSelected: false, + newSourceSelected: true, + }); editor.editorPreviewDisposables.dispose(); }); @@ -431,7 +477,7 @@ suite('aiCustomizationManagementEditor', () => { source: PromptFileSource.UserData, }; editor.migrationMigrateButton = { enabled: true, label: '' }; - editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.UserData, [customization]]]), new Map()); + editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.UserData, [customization]]])); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.UserData; editor.customizationMigrationInProgress = true; @@ -471,15 +517,14 @@ suite('aiCustomizationManagementEditor', () => { source: PromptFileSource.GitHubWorkspace, } as MigratableConfiguration, ]; - editor.customizationsByMigrationCategory = new Map([ + setMigrationState(editor, new Map([ [CustomizationMigrationCategoryId.UserData, userDataCustomizations], [CustomizationMigrationCategoryId.PromptFiles, promptFiles], - ]); - editor.customizationMigrationTargetFoldersByType = new Map([ + ]), new Map([ [PromptsType.agent, [{ uri: URI.file('/home/test/.copilot/agents'), label: '~/.copilot', source: AICustomizationSources.user }]], [PromptsType.instructions, [{ uri: URI.file('/home/test/.copilot/instructions'), label: '~/.copilot', source: AICustomizationSources.user }]], - ]); - editor.selectedCustomizationMigrationItems = new ResourceMap(); + ])); + editor.selectedCustomizationMigrationItems = new Set(); editor.migrationListContainer = document.createElement('div'); editor.migrationTitleElement = document.createElement('h2'); editor.migrationDescriptionElement = document.createElement('p'); @@ -541,7 +586,7 @@ suite('aiCustomizationManagementEditor', () => { }; const openedItems: unknown[][] = []; editor.showEmbeddedEditor = async (...args: unknown[]) => { openedItems.push(args); }; - editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, [promptFile]]]); + setMigrationState(editor, new Map([[CustomizationMigrationCategoryId.PromptFiles, [promptFile]]])); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; editor.migrationListContainer = document.createElement('div'); editor.migrationMigrateButton = { enabled: false, label: '' }; @@ -599,7 +644,7 @@ suite('aiCustomizationManagementEditor', () => { source: PromptFileSource.GitHubWorkspace, } as MigratableConfiguration, ]; - editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); + setMigrationState(editor, new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]])); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; for (const promptFile of promptFiles) { editor.setCustomizationSelectedForMigration(promptFile, true); @@ -698,7 +743,7 @@ suite('aiCustomizationManagementEditor', () => { source: PromptFileSource.UserData, } as MigratableConfiguration, ]; - editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); + setMigrationState(editor, new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]])); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; for (const promptFile of promptFiles) { editor.setCustomizationSelectedForMigration(promptFile, true); @@ -750,7 +795,7 @@ suite('aiCustomizationManagementEditor', () => { source: PromptFileSource.GitHubWorkspace, } as MigratableConfiguration, ]; - editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); + setMigrationState(editor, new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]])); editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; for (const promptFile of promptFiles) { editor.setCustomizationSelectedForMigration(promptFile, true); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts index 1db67a703e43b1..f3fa2a4cc79c80 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts @@ -13,7 +13,9 @@ import { FileService } from '../../../../../../platform/files/common/fileService import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { FileType, IFileDeleteOptions, IFileWriteOptions, createFileSystemProviderError, FileSystemProviderErrorCode } from '../../../../../../platform/files/common/files.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { CustomizationMigrationType, IMcpServerCustomizationMigrationCandidate, McpServerMigrationFailureReason } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage, type IPromptPath } from '../../../common/promptSyntax/service/promptsService.js'; import { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import { createSkillFileUri, migrateCustomizations, migratePromptFileToSkill, type CustomizationMigrationTargetFolders } from '../../../browser/aiCustomization/customizationMigration.js'; @@ -61,13 +63,13 @@ suite('customizationMigration', () => { { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, source: PromptFileSource.GitHubWorkspace }, ]; const candidatesFor = (id: CustomizationMigrationCategoryId) => customizations - .filter(customization => getCustomizationMigrationCategory(id).isCandidate(customization)) + .filter(customization => getCustomizationMigrationCategory(id).isCandidate?.(customization) === true) .map(customization => customization.uri.path); assert.deepStrictEqual({ promptFiles: candidatesFor(CustomizationMigrationCategoryId.PromptFiles), userData: candidatesFor(CustomizationMigrationCategoryId.UserData), - sourceTypes: CUSTOMIZATION_MIGRATION_CATEGORIES.map(category => [category.id, [...category.sourceTypes]]), + sourceTypes: CUSTOMIZATION_MIGRATION_CATEGORIES.map(category => [category.id, [...(category.sourceTypes ?? [])]]), }, { promptFiles: [ '/workspace/.github/prompts/review.prompt.md', @@ -80,10 +82,54 @@ suite('customizationMigration', () => { sourceTypes: [ [CustomizationMigrationCategoryId.PromptFiles, [PromptsType.prompt]], [CustomizationMigrationCategoryId.UserData, [PromptsType.agent, PromptsType.instructions]], + [CustomizationMigrationCategoryId.McpServers, []], ], }); }); + test('uses MCP migration copy for supported workspace servers', () => { + const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.McpServers); + const server: IMcpServerCustomizationMigrationCandidate = { + type: CustomizationMigrationType.McpServers, + id: 'mcp.config.ws0.server', + name: 'server', + sourceUri: URI.file('/workspace/.vscode/mcp.json'), + targetUri: URI.file('/workspace/.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: 'server' }, + }; + + assert.deepStrictEqual({ + card: category.getCardDescription([server], 'Copilot'), + page: category.getPageDescription([server], 'Copilot'), + banner: category.getBanner?.([server], 'Copilot'), + confirmation: category.getConfirmation([server], 'Copilot'), + migrated: category.getMigratedMessage(1), + failed: category.getFailedMessage(['server'], 0), + conflict: category.getMcpServerFailureMessage?.([{ + id: server.id, + name: server.name, + sourceUri: server.sourceUri, + targetUri: server.targetUri, + reason: McpServerMigrationFailureReason.TargetConflict, + }]), + }, { + card: 'Found 1 supported server in .vscode/mcp.json that can move to the workspace root so Copilot can discover it directly.', + page: 'Select the supported MCP server to move so Copilot can discover it directly.', + banner: { + message: 'Move supported servers from .vscode/mcp.json to .mcp.json at each workspace root so Copilot can discover them directly. Unsupported servers stay in their current files.', + consequence: 'Migrated entries are removed from .vscode/mcp.json. Existing servers with the same name in .mcp.json are not overwritten.', + }, + confirmation: { + message: 'Migrate 1 MCP server to .mcp.json?', + detail: 'The selected entries will be removed from .vscode/mcp.json after they are written successfully.', + primaryButton: 'Migrate', + }, + migrated: 'Migrated 1 MCP server.', + failed: 'Failed to migrate MCP server: server.', + conflict: 'Could not migrate \'server\' because .mcp.json already contains a different server with that name.', + }); + }); + test('uses singular copy for one User Data customization', () => { const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.UserData); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationModel.test.ts new file mode 100644 index 00000000000000..92396175fd7574 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationModel.test.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { errorHandler, setUnexpectedErrorHandler } from '../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue, waitForState } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; +import { CustomizationMigrationModel } from '../../../browser/aiCustomization/customizationMigrationModel.js'; +import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; +import { CustomizationMigration, CustomizationMigrationType, FileCustomizationMigration, FileCustomizationMigrationType, ICustomizationMigrationService, IMcpServerMigrationResult, isMcpServerCustomizationMigrationCandidate, McpServerCustomizationMigration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; +import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; + +class TestMigrationService implements ICustomizationMigrationService { + declare readonly _serviceBrand: undefined; + readonly requestedSessions: URI[] = []; + beforeCompute?: (sessionResource: URI, type: CustomizationMigrationType) => Promise; + + computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; + computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; + async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { + this.requestedSessions.push(sessionResource); + await this.beforeCompute?.(sessionResource, type); + if (type === CustomizationMigrationType.McpServers) { + return { + type, + servers: [], + candidates: [], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + } + const candidates = type === CustomizationMigrationType.UserData + ? [{ + uri: URI.file(`/user-data${sessionResource.path}.instructions.md`), + storage: PromptsStorage.user, + type: PromptsType.instructions, + }] + : []; + return { type, files: candidates.map(candidate => candidate.uri), candidates }; + } + + async migrateMcpServers(): Promise { + return { migratedCount: 0, failures: [] }; + } + + async computeMigrations(sessionResource: URI): Promise { + return [ + await this.computeMigration(sessionResource, CustomizationMigrationType.UserData), + await this.computeMigration(sessionResource, CustomizationMigrationType.PromptFiles), + await this.computeMigration(sessionResource, CustomizationMigrationType.McpServers), + ]; + } + + async computeMigrationHint(): Promise { + return undefined; + } +} + +suite('CustomizationMigrationModel', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('refreshes from session, MCP inventory, and working-directory changes', async () => { + const sessionA = URI.parse('agent-host-test:/session-a'); + const sessionB = URI.parse('agent-host-test:/session-b'); + const activeSessionResource = observableValue('activeSessionResource', sessionA); + const activeHarness = observableValue('activeHarness', sessionA.scheme); + const migrationService = new TestMigrationService(); + const promptsService = store.add(new MockPromptsService()); + const mcpServers = observableValue('mcpServers', []); + const onDidChangeCustomizations = store.add(new Emitter()); + let workingDirectories: readonly string[] = []; + const harnessService = new class extends mock() { + override readonly activeSessionResource = activeSessionResource; + override readonly activeHarness = activeHarness; + override findHarnessById() { + return { + id: sessionA.scheme, + label: 'Test', + icon: Codicon.beaker, + itemProvider: { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + provideSourceFolders: async (_resource: URI, type: PromptsType) => type === PromptsType.instructions + ? [{ uri: URI.file('/instructions'), label: 'Instructions', source: PromptsStorage.user }] + : [], + }, + }; + } + }(); + const configurationService = { + onDidChangeConfiguration: Event.None, + getValue: () => true, + } as Partial as IConfigurationService; + const mcpService = { + servers: mcpServers, + } as Partial as IMcpService; + const agentHostCustomizationService = { + onDidChangeCustomizations: onDidChangeCustomizations.event, + getWorkingDirectories: () => workingDirectories, + } as Partial as IAgentHostCustomizationService; + const model = store.add(new CustomizationMigrationModel( + migrationService, + harnessService, + promptsService, + configurationService, + mcpService, + agentHostCustomizationService, + )); + await waitForState(model.state, state => !state.loading && state.candidatesByCategory.size > 0); + const requestsAfterInitialRefresh = migrationService.requestedSessions.length; + + const sessionBRefreshStarted = new DeferredPromise(); + const releaseSessionBRefresh = new DeferredPromise(); + migrationService.beforeCompute = async (sessionResource, type) => { + if (sessionResource.path === sessionB.path && type === CustomizationMigrationType.UserData) { + sessionBRefreshStarted.complete(); + await releaseSessionBRefresh.p; + } + }; + activeSessionResource.set(sessionB, undefined); + await sessionBRefreshStarted.p; + mcpServers.set([], undefined); + await timeout(10); + const candidateDuringSessionSwitch = model.state.get().candidatesByCategory.get(CustomizationMigrationCategoryId.UserData); + releaseSessionBRefresh.complete(); + const getUserDataCandidatePath = () => { + const candidate = model.state.get().candidatesByCategory.get(CustomizationMigrationCategoryId.UserData)?.[0]; + return candidate && !isMcpServerCustomizationMigrationCandidate(candidate) ? candidate.uri.path : undefined; + }; + await waitForState(model.state, state => + !state.loading + && getUserDataCandidatePath() === '/user-data/session-b.instructions.md' + && migrationService.requestedSessions.length >= requestsAfterInitialRefresh + 4 + ); + const requestsAfterSessionChange = requestsAfterInitialRefresh + 3; + const requestsAfterMcpChange = migrationService.requestedSessions.length; + workingDirectories = ['file:///workspace']; + onDidChangeCustomizations.fire(); + await waitForState(model.state, state => !state.loading && migrationService.requestedSessions.length > requestsAfterMcpChange); + + assert.deepStrictEqual({ + lastCandidate: getUserDataCandidatePath(), + candidateDuringSessionSwitch, + lastTarget: model.state.get().targetFoldersByType.get(PromptsType.instructions)?.[0].uri.path, + requestsAfterInitialRefresh, + requestsAddedBySessionChange: requestsAfterSessionChange - requestsAfterInitialRefresh, + requestsAddedByMcpChange: requestsAfterMcpChange - requestsAfterSessionChange, + requestsAddedByRootChange: migrationService.requestedSessions.length - requestsAfterMcpChange, + requestedSessions: [...new Set(migrationService.requestedSessions.map(resource => resource.path))], + }, { + lastCandidate: '/user-data/session-b.instructions.md', + candidateDuringSessionSwitch: undefined, + lastTarget: '/instructions', + requestsAfterInitialRefresh: 3, + requestsAddedBySessionChange: 3, + requestsAddedByMcpChange: 1, + requestsAddedByRootChange: 3, + requestedSessions: ['/session-a', '/session-b'], + }); + }); + + test('serializes overlapping partial refreshes without discarding either category', async () => { + const session = URI.parse('agent-host-test:/session'); + const promptRefreshStarted = new DeferredPromise(); + const releasePromptRefresh = new DeferredPromise(); + let blockPromptRefresh = false; + let promptVersion = 0; + let mcpVersion = 0; + const migrationService = new class extends TestMigrationService { + override async computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; + override async computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; + override async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { + this.requestedSessions.push(sessionResource); + if (blockPromptRefresh && type === CustomizationMigrationType.PromptFiles) { + promptRefreshStarted.complete(); + await releasePromptRefresh.p; + } + if (type === CustomizationMigrationType.McpServers) { + return { + type, + servers: [], + candidates: [{ + type, + id: `mcp-${mcpVersion}`, + name: 'server', + sourceUri: URI.file('/workspace/.vscode/mcp.json'), + targetUri: URI.file('/workspace/.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: `server-${mcpVersion}` }, + }], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + } + const candidate = { + uri: URI.file(`/prompt-${promptVersion}.prompt.md`), + storage: PromptsStorage.local, + type: PromptsType.prompt, + }; + return type === CustomizationMigrationType.PromptFiles + ? { type, files: [candidate.uri], candidates: [candidate] } + : { type, files: [], candidates: [] }; + } + }(); + const onDidChangeSlashCommands = store.add(new Emitter()); + const promptsService = store.add(new MockPromptsService()); + promptsService.onDidChangeSlashCommands = onDidChangeSlashCommands.event; + const mcpServers = observableValue('mcpServers', []); + const harnessService = new class extends mock() { + override readonly activeSessionResource = observableValue('activeSessionResource', session); + override readonly activeHarness = observableValue('activeHarness', session.scheme); + override findHarnessById() { + return { id: session.scheme, label: 'Test', icon: Codicon.beaker }; + } + }(); + const model = store.add(new CustomizationMigrationModel( + migrationService, + harnessService, + promptsService, + { onDidChangeConfiguration: Event.None, getValue: () => true } as Partial as IConfigurationService, + { servers: mcpServers } as Partial as IMcpService, + { onDidChangeCustomizations: Event.None, getWorkingDirectories: () => [] } as Partial as IAgentHostCustomizationService, + )); + const getPromptVersion = () => { + const candidate = model.state.get().candidatesByCategory.get(CustomizationMigrationCategoryId.PromptFiles)?.[0]; + return candidate && !isMcpServerCustomizationMigrationCandidate(candidate) ? candidate.uri.path : undefined; + }; + const getMcpVersion = () => { + const candidate = model.state.get().candidatesByCategory.get(CustomizationMigrationCategoryId.McpServers)?.[0]; + return candidate && isMcpServerCustomizationMigrationCandidate(candidate) ? candidate.id : undefined; + }; + await waitForState(model.state, state => !state.loading && getPromptVersion() === '/prompt-0.prompt.md' && getMcpVersion() === 'mcp-0'); + + blockPromptRefresh = true; + promptVersion = 1; + onDidChangeSlashCommands.fire(); + await promptRefreshStarted.p; + mcpVersion = 1; + mcpServers.set([], undefined); + await timeout(10); + releasePromptRefresh.complete(); + await waitForState(model.state, state => !state.loading && getPromptVersion() === '/prompt-1.prompt.md' && getMcpVersion() === 'mcp-1'); + + assert.deepStrictEqual({ + prompt: getPromptVersion(), + mcp: getMcpVersion(), + }, { + prompt: '/prompt-1.prompt.md', + mcp: 'mcp-1', + }); + }); + + test('records load errors without discarding the last successful state', async () => { + const session = URI.parse('agent-host-test:/session'); + let shouldFail = false; + const migrationService = new class extends TestMigrationService { + override async computeMigration(sessionResource: URI, type: FileCustomizationMigrationType): Promise; + override async computeMigration(sessionResource: URI, type: CustomizationMigrationType.McpServers): Promise; + override async computeMigration(sessionResource: URI, type: CustomizationMigrationType): Promise { + if (shouldFail) { + throw new Error('expected migration discovery failure'); + } + return super.computeMigration(sessionResource, type as FileCustomizationMigrationType); + } + }(); + const promptsService = store.add(new MockPromptsService()); + const harnessService = new class extends mock() { + override readonly activeSessionResource = observableValue('activeSessionResource', session); + override readonly activeHarness = observableValue('activeHarness', session.scheme); + override findHarnessById() { + return { id: session.scheme, label: 'Test', icon: Codicon.beaker }; + } + }(); + const model = store.add(new CustomizationMigrationModel( + migrationService, + harnessService, + promptsService, + { onDidChangeConfiguration: Event.None, getValue: () => true } as Partial as IConfigurationService, + { servers: observableValue('mcpServers', []) } as Partial as IMcpService, + { onDidChangeCustomizations: Event.None, getWorkingDirectories: () => [] } as Partial as IAgentHostCustomizationService, + )); + await waitForState(model.state, state => !state.loading && state.candidatesByCategory.size > 0); + shouldFail = true; + + const unexpectedErrors: Error[] = []; + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + try { + await model.refresh(); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + + assert.deepStrictEqual({ + error: model.state.get().loadError, + candidateCount: model.state.get().candidatesByCategory.get(CustomizationMigrationCategoryId.UserData)?.length, + unexpectedErrors: unexpectedErrors.map(error => error.message), + }, { + error: 'expected migration discovery failure', + candidateCount: 1, + unexpectedErrors: ['expected migration discovery failure'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts index c7ab321115955c..5422c173532a5d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationServiceImpl.test.ts @@ -4,12 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; -import { constObservable } from '../../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { CustomizationMigrationService } from '../../../browser/aiCustomization/customizationMigrationServiceImpl.js'; import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; @@ -17,7 +24,7 @@ import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostM import { SessionType } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { CustomizationMigrationType } from '../../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationType, McpServerMigrationFailureReason } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { IPromptPath, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -74,9 +81,92 @@ class TestCustomizationHarnessService extends mock } } +function createMcpFileService(servers: Record = {}): IFileService { + return { + readFile: async resource => ({ + resource, + name: resource.path.split('/').at(-1) ?? '', + mtime: 0, + ctime: 0, + size: 0, + etag: '', + isFile: true, + isDirectory: false, + isSymbolicLink: false, + readonly: false, + locked: false, + executable: false, + value: VSBuffer.fromString(JSON.stringify({ servers })), + }), + } as Partial as IFileService; +} + suite('CustomizationMigrationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + async function createMcpRevalidationContext(root: URI) { + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + const server = { + id: 'mcp.config.ws0.server', + name: 'server', + collectionId: 'mcp.config.ws0', + source: { + group: 'local' as const, + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace', + collectionUri: sourceUri, + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' as const }, + }; + const snapshot = observableValue('support', { + servers: [server], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }); + const activeClientService = { + acquireMcpServerSupportScope: () => ({ + support: snapshot, + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => { }, + }), + } as Partial as IAgentHostActiveClientService; + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"server"}}}')); + const service = new CustomizationMigrationService( + store.add(new TestPromptsService([])), + new TestCustomizationHarnessService(), + activeClientService, + new class extends mock() { + override getWorkingDirectories() { return [root.toString()]; } + }(), + fileService, + new NullLogService(), + ); + return { + fileService, + server, + service, + sessionResource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), + snapshot, + sourceUri, + targetUri, + }; + } + test('computes file and MCP migration candidates for Agent Host sessions', async () => { const root = URI.file('/workspace'); const promptsService = store.add(new TestPromptsService([ @@ -95,10 +185,10 @@ suite('CustomizationMigrationService', () => { name: 'Supported server', collectionId: 'test', source: { - group: undefined, - kind: AgentHostMcpServerSourceKind.UserProfile, - label: 'User', - collectionUri: undefined, + group: 'local', + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace', + collectionUri: URI.joinPath(root, '.vscode', 'mcp.json'), definitionLocation: undefined, remoteAuthority: null, extensionId: undefined, @@ -170,9 +260,11 @@ suite('CustomizationMigrationService', () => { }, } as Partial as IAgentHostActiveClientService; const agentHostCustomizationService = { - getWorkingDirectories: () => [root.fsPath], + getWorkingDirectories: () => [root.toString()], } as Partial as IAgentHostCustomizationService; - const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService, createMcpFileService({ + 'Supported server': { type: McpServerType.LOCAL, command: 'server' }, + }), new NullLogService()); const agentHostSessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }); const localSessionResource = URI.from({ scheme: SessionType.Local, path: '/session' }); @@ -187,6 +279,12 @@ suite('CustomizationMigrationService', () => { ...(migration.type === CustomizationMigrationType.McpServers ? { servers: migration.servers, + candidates: migration.candidates.map(candidate => ({ + id: candidate.id, + name: candidate.name, + source: candidate.sourceUri.path, + target: candidate.targetUri.path, + })), discoveryComplete: migration.discoveryComplete, coverage: migration.coverage, } @@ -227,6 +325,12 @@ suite('CustomizationMigrationService', () => { { id: 'supported', name: 'Supported server', supported: true }, { id: 'unsupported', name: 'Unsupported server', supported: false }, ], + candidates: [{ + id: 'supported', + name: 'Supported server', + source: '/workspace/.vscode/mcp.json', + target: '/workspace/.mcp.json', + }], discoveryComplete: false, coverage: { restrictedByMcpAccess: true, @@ -240,6 +344,7 @@ suite('CustomizationMigrationService', () => { { type: 'mcpServers', servers: [], + candidates: [], discoveryComplete: true, coverage: { restrictedByMcpAccess: false, @@ -247,7 +352,7 @@ suite('CustomizationMigrationService', () => { }, }, ], - hint: 'Found 3 customization files that are present but not used by Copilot and could be migrated. Found 1 MCP server that is not fully supported by Copilot.', + hint: 'Found 3 customization files that are present but not used by Copilot and could be migrated. Found 1 workspace MCP server that can be migrated for Copilot. Found 1 MCP server that is not fully supported by Copilot.', localHint: undefined, requestedTypes: [ PromptsType.agent, PromptsType.instructions, PromptsType.prompt, @@ -263,6 +368,291 @@ suite('CustomizationMigrationService', () => { }); }); + test('excludes workspace-file servers with envFile from migration candidates', async () => { + const root = URI.file('/workspace'); + const snapshot: IAgentHostMcpServerSupportSnapshot = { + servers: [{ + id: 'mcp.config.ws0.server', + name: 'server', + collectionId: 'mcp.config.ws0', + source: { + group: 'local', + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace', + collectionUri: URI.joinPath(root, '.vscode', 'mcp.json'), + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'partiallySupported', reasons: [AgentHostMcpSupportReason.EnvironmentFileIgnored] }, + }], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + let requestedRoots: readonly URI[] | undefined; + const activeClientService = { + acquireMcpServerSupportScope: (_sessionType: string, roots: readonly URI[] | undefined) => { + requestedRoots = roots; + return { + support: constObservable(snapshot), + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => { }, + }; + }, + } as Partial as IAgentHostActiveClientService; + const agentHostCustomizationService = new class extends mock() { + override getWorkingDirectories() { return [root.toString()]; } + }(); + const service = new CustomizationMigrationService( + store.add(new TestPromptsService([])), + new TestCustomizationHarnessService(), + activeClientService, + agentHostCustomizationService, + createMcpFileService({ server: { type: McpServerType.LOCAL, command: 'server', envFile: '/workspace/.env' } }), + new NullLogService(), + ); + + const migration = await service.computeMigration( + URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), + CustomizationMigrationType.McpServers, + ); + + assert.deepStrictEqual({ + roots: requestedRoots?.map(uri => uri.toString()), + servers: migration.servers, + candidates: migration.candidates.map(candidate => ({ + id: candidate.id, + source: candidate.sourceUri.toString(), + target: candidate.targetUri.toString(), + })), + }, { + roots: ['file:///workspace'], + servers: [{ id: 'mcp.config.ws0.server', name: 'server', supported: false }], + candidates: [], + }); + }); + + test('revalidates MCP candidates before migration', async () => { + const root = URI.file('/workspace-revalidation'); + const context = await createMcpRevalidationContext(root); + const migration = await context.service.computeMigration(context.sessionResource, CustomizationMigrationType.McpServers); + context.snapshot.set({ + ...context.snapshot.get(), + servers: [{ + ...context.server, + enablement: { enabled: false, state: AgentHostMcpServerEnablementState.DisabledWorkspace }, + }], + }, undefined); + + const result = await context.service.migrateMcpServers(context.sessionResource, migration.candidates); + + assert.deepStrictEqual({ + result: { + migratedCount: result.migratedCount, + failures: result.failures.map(failure => ({ name: failure.name, reason: failure.reason })), + }, + source: JSON.parse((await context.fileService.readFile(context.sourceUri)).value.toString()), + targetExists: await context.fileService.exists(context.targetUri), + }, { + result: { + migratedCount: 0, + failures: [{ name: 'server', reason: McpServerMigrationFailureReason.NoLongerEligible }], + }, + source: { servers: { server: { type: 'stdio', command: 'server' } } }, + targetExists: false, + }); + }); + + test('compares the source against the candidate confirmed by the user', async () => { + const context = await createMcpRevalidationContext(URI.file('/workspace-confirmed-source')); + const migration = await context.service.computeMigration(context.sessionResource, CustomizationMigrationType.McpServers); + await context.fileService.writeFile(context.sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"updated"}}}')); + + const result = await context.service.migrateMcpServers(context.sessionResource, migration.candidates); + + assert.deepStrictEqual({ + result: { + migratedCount: result.migratedCount, + failures: result.failures.map(failure => ({ name: failure.name, reason: failure.reason })), + }, + source: JSON.parse((await context.fileService.readFile(context.sourceUri)).value.toString()), + targetExists: await context.fileService.exists(context.targetUri), + }, { + result: { + migratedCount: 0, + failures: [{ name: 'server', reason: McpServerMigrationFailureReason.SourceChanged }], + }, + source: { servers: { server: { type: 'stdio', command: 'updated' } } }, + targetExists: false, + }); + }); + + test('does not migrate a different source that reuses a positional server ID', async () => { + const context = await createMcpRevalidationContext(URI.file('/workspace-original-source')); + const migration = await context.service.computeMigration(context.sessionResource, CustomizationMigrationType.McpServers); + const replacementRoot = URI.file('/workspace-replacement-source'); + const replacementSourceUri = URI.joinPath(replacementRoot, '.vscode', 'mcp.json'); + const replacementTargetUri = URI.joinPath(replacementRoot, '.mcp.json'); + await context.fileService.writeFile(replacementSourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"replacement"}}}')); + context.snapshot.set({ + ...context.snapshot.get(), + servers: [{ + ...context.server, + source: { + ...context.server.source, + collectionUri: replacementSourceUri, + }, + }], + }, undefined); + + const result = await context.service.migrateMcpServers(context.sessionResource, migration.candidates); + + assert.deepStrictEqual({ + result: { + migratedCount: result.migratedCount, + failures: result.failures.map(failure => ({ name: failure.name, reason: failure.reason })), + }, + originalSource: JSON.parse((await context.fileService.readFile(context.sourceUri)).value.toString()), + replacementSource: JSON.parse((await context.fileService.readFile(replacementSourceUri)).value.toString()), + originalTargetExists: await context.fileService.exists(context.targetUri), + replacementTargetExists: await context.fileService.exists(replacementTargetUri), + }, { + result: { + migratedCount: 0, + failures: [{ name: 'server', reason: McpServerMigrationFailureReason.NoLongerEligible }], + }, + originalSource: { servers: { server: { type: 'stdio', command: 'server' } } }, + replacementSource: { servers: { server: { type: 'stdio', command: 'replacement' } } }, + originalTargetExists: false, + replacementTargetExists: false, + }); + }); + + test('excludes workspace-file servers whose source transport cannot be preserved', async () => { + const root = URI.file('/workspace'); + const snapshot: IAgentHostMcpServerSupportSnapshot = { + servers: [{ + id: 'mcp.config.ws0.sse', + name: 'sse', + collectionId: 'mcp.config.ws0', + source: { + group: 'local', + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace', + collectionUri: URI.joinPath(root, '.vscode', 'mcp.json'), + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + const activeClientService = { + acquireMcpServerSupportScope: () => ({ + support: constObservable(snapshot), + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => { }, + }), + } as Partial as IAgentHostActiveClientService; + const agentHostCustomizationService = new class extends mock() { + override getWorkingDirectories() { return [root.toString()]; } + }(); + const service = new CustomizationMigrationService( + store.add(new TestPromptsService([])), + new TestCustomizationHarnessService(), + activeClientService, + agentHostCustomizationService, + createMcpFileService({ sse: { type: 'sse', url: 'https://example.com/sse' } }), + new NullLogService(), + ); + + const migration = await service.computeMigration( + URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), + CustomizationMigrationType.McpServers, + ); + + assert.deepStrictEqual(migration.candidates, []); + }); + + test('treats a missing MCP source file as having no migration candidates', async () => { + const root = URI.file('/workspace'); + const snapshot: IAgentHostMcpServerSupportSnapshot = { + servers: [{ + id: 'mcp.config.ws0.server', + name: 'server', + collectionId: 'mcp.config.ws0', + source: { + group: 'local', + kind: AgentHostMcpServerSourceKind.VscodeWorkspaceFolder, + label: 'Workspace', + collectionUri: URI.joinPath(root, '.vscode', 'mcp.json'), + definitionLocation: undefined, + remoteAuthority: null, + extensionId: undefined, + pluginUri: undefined, + }, + enablement: { enabled: true, state: AgentHostMcpServerEnablementState.EnabledWorkspace }, + applicability: AgentHostMcpServerApplicability.Applicable, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }], + discoveryComplete: true, + coverage: { + restrictedByMcpAccess: false, + restrictedByCustomizationPolicy: false, + }, + }; + const activeClientService = { + acquireMcpServerSupportScope: () => ({ + support: constObservable(snapshot), + isResolved: constObservable(true), + whenResolved: () => Promise.resolve(), + dispose: () => { }, + }), + } as Partial as IAgentHostActiveClientService; + const agentHostCustomizationService = new class extends mock() { + override getWorkingDirectories() { return [root.toString()]; } + }(); + const missingFileService = { + readFile: async () => { + throw new FileOperationError('missing', FileOperationResult.FILE_NOT_FOUND); + }, + } as Partial as IFileService; + const service = new CustomizationMigrationService( + store.add(new TestPromptsService([])), + new TestCustomizationHarnessService(), + activeClientService, + agentHostCustomizationService, + missingFileService, + new NullLogService(), + ); + + const migration = await service.computeMigration( + URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), + CustomizationMigrationType.McpServers, + ); + + assert.deepStrictEqual(migration.candidates, []); + }); + test('uses the session harness label in migration hints', async () => { const promptsService = store.add(new TestPromptsService([ { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace }, @@ -274,7 +664,7 @@ suite('CustomizationMigrationService', () => { const agentHostCustomizationService = new class extends mock() { override getWorkingDirectories() { return []; } }(); - const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService, createMcpFileService(), new NullLogService()); const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostClaude, path: '/session' })); @@ -321,7 +711,7 @@ suite('CustomizationMigrationService', () => { const agentHostCustomizationService = new class extends mock() { override getWorkingDirectories() { return []; } }(); - const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService); + const service = new CustomizationMigrationService(promptsService, harnessService, activeClientService, agentHostCustomizationService, createMcpFileService(), new NullLogService()); const hint = await service.computeMigrationHint(URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' })); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpServerMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpServerMigration.test.ts new file mode 100644 index 00000000000000..6c89036015e776 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpServerMigration.test.ts @@ -0,0 +1,380 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { parse } from '../../../../../../base/common/jsonc.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { IFileWriteOptions } from '../../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IMcpServerConfiguration, McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { McpServerMigration } from '../../../browser/aiCustomization/customizationMigration.js'; +import { CustomizationMigrationType, IMcpServerCustomizationMigrationCandidate, McpServerMigrationFailureReason } from '../../../common/promptSyntax/service/customizationMigrationService.js'; + +class SourceWriteFailingFileSystemProvider extends InMemoryFileSystemProvider { + failSourceWrite = false; + sourceUri: URI | undefined; + + override async writeFile(resource: URI, content: Uint8Array, options: IFileWriteOptions): Promise { + if (this.failSourceWrite && this.sourceUri && isEqual(resource, this.sourceUri)) { + throw new Error('Expected source write failure'); + } + await super.writeFile(resource, content, options); + } +} + +class TargetChangingFileSystemProvider extends InMemoryFileSystemProvider { + sourceUri: URI | undefined; + targetUri: URI | undefined; + changeTargetBeforeSourceWrite = false; + + override async writeFile(resource: URI, content: Uint8Array, options: IFileWriteOptions): Promise { + if (this.changeTargetBeforeSourceWrite && this.sourceUri && this.targetUri && isEqual(resource, this.sourceUri)) { + // Simulate another process replacing the target between the migration's target and source writes. + this.changeTargetBeforeSourceWrite = false; + await super.writeFile(this.targetUri, VSBuffer.fromString('{"mcpServers":{}}').buffer, { + create: true, + overwrite: true, + unlock: false, + atomic: false, + }); + } + await super.writeFile(resource, content, options); + } +} + +class DeletingBeforeExistsFileService extends FileService { + deleteBeforeExists: URI | undefined; + + override async exists(resource: URI): Promise { + if (this.deleteBeforeExists && isEqual(resource, this.deleteBeforeExists)) { + this.deleteBeforeExists = undefined; + await this.del(resource); + } + return super.exists(resource); + } +} + +suite('mcpServerMigration', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function candidate(root: URI, id: string, name: string, configuration: IMcpServerConfiguration = { type: McpServerType.LOCAL, command: 'source' }): IMcpServerCustomizationMigrationCandidate { + return { + type: CustomizationMigrationType.McpServers, + id, + name, + sourceUri: URI.joinPath(root, '.vscode', 'mcp.json'), + targetUri: URI.joinPath(root, '.mcp.json'), + configuration, + }; + } + + async function migrateMcpServers(candidates: readonly IMcpServerCustomizationMigrationCandidate[], fileService: FileService) { + const result = await new McpServerMigration(fileService).migrate(candidates); + return { + migratedCount: result.migratedCount, + failures: result.failures.map(failure => ({ + name: failure.name, + reason: failure.reason, + message: failure.error?.message, + })), + }; + } + + test('moves only selected servers while preserving other eligible and unsupported source entries', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString(`{ + // Keep this source comment. + "servers": { + "stdio": { "type": "stdio", "command": "node", "args": ["/workspace/server.js"] }, + "unselected": { "type": "stdio", "command": "node", "args": ["other.js"] }, + "unsupported": { "type": "stdio", "command": "\${input:command}" }, + "http": { "type": "http", "url": "https://example.com/mcp" } + } +}`)); + await fileService.writeFile(targetUri, VSBuffer.fromString(`{ + "mcpServers": { + "existing": { "type": "stdio", "command": "existing" } + } +}`)); + + const result = await migrateMcpServers([ + candidate(root, 'stdio-id', 'stdio', { type: McpServerType.LOCAL, command: 'node', args: ['/workspace/server.js'] }), + candidate(root, 'http-id', 'http', { type: McpServerType.REMOTE, url: 'https://example.com/mcp' }), + ], fileService); + const sourceContent = (await fileService.readFile(sourceUri)).value.toString(); + const targetContent = (await fileService.readFile(targetUri)).value.toString(); + + assert.deepStrictEqual({ + result, + source: parse(sourceContent), + target: parse(targetContent), + sourceCommentPreserved: sourceContent.includes('// Keep this source comment.'), + }, { + result: { migratedCount: 2, failures: [] }, + source: { + servers: { + unselected: { type: 'stdio', command: 'node', args: ['other.js'] }, + unsupported: { type: 'stdio', command: '${input:command}' }, + }, + }, + target: { + mcpServers: { + existing: { type: 'stdio', command: 'existing' }, + stdio: { type: 'stdio', command: 'node', args: ['/workspace/server.js'] }, + http: { type: 'http', url: 'https://example.com/mcp' }, + }, + }, + sourceCommentPreserved: true, + }); + }); + + test('does not overwrite a conflicting destination server', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-conflict'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + await fileService.writeFile(targetUri, VSBuffer.fromString('{"mcpServers":{"server":{"type":"stdio","command":"target"}}}')); + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + target: parse((await fileService.readFile(targetUri)).value.toString()), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.TargetConflict, message: undefined }] }, + source: { servers: { server: { type: 'stdio', command: 'source' } } }, + target: { mcpServers: { server: { type: 'stdio', command: 'target' } } }, + }); + }); + + test('does not remove a source server that changed after candidate discovery', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-changed-source'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"updated"}}}')); + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + targetExists: await fileService.exists(targetUri), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.SourceChanged, message: undefined }] }, + source: { servers: { server: { type: 'stdio', command: 'updated' } } }, + targetExists: false, + }); + }); + + test('rejects source behavior that root .mcp.json cannot preserve', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-unrepresentable'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"cwd":{"type":"stdio","command":"source","cwd":"/explicit"},"metadata":{"type":"http","url":"https://example.com","version":"1.0.0"},"oauth":{"type":"http","url":"https://example.com","oauth":{"enterpriseManaged":true}}}}')); + + const result = await migrateMcpServers([ + candidate(root, 'cwd-id', 'cwd', { type: McpServerType.LOCAL, command: 'source', cwd: '/explicit' }), + candidate(root, 'metadata-id', 'metadata', { type: McpServerType.REMOTE, url: 'https://example.com' }), + candidate(root, 'oauth-id', 'oauth', { type: McpServerType.REMOTE, url: 'https://example.com' }), + ], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + targetExists: await fileService.exists(targetUri), + }, { + result: { + migratedCount: 0, + failures: [ + { name: 'cwd', reason: McpServerMigrationFailureReason.UnrepresentableConfiguration, message: undefined }, + { name: 'metadata', reason: McpServerMigrationFailureReason.SourceChanged, message: undefined }, + { name: 'oauth', reason: McpServerMigrationFailureReason.SourceChanged, message: undefined }, + ], + }, + source: { + servers: { + cwd: { type: 'stdio', command: 'source', cwd: '/explicit' }, + metadata: { type: 'http', url: 'https://example.com', version: '1.0.0' }, + oauth: { type: 'http', url: 'https://example.com', oauth: { enterpriseManaged: true } }, + }, + }, + targetExists: false, + }); + }); + + test('treats JSON-equivalent destination configurations as duplicates', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-equivalent-target'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + await fileService.writeFile(targetUri, VSBuffer.fromString('{"mcpServers":{"server":{"type":"stdio","command":"source","args":[]}}}')); + + const result = await migrateMcpServers([ + candidate(root, 'server-id', 'server', { type: McpServerType.LOCAL, command: 'source', args: undefined }), + ], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + target: parse((await fileService.readFile(targetUri)).value.toString()), + }, { + result: { migratedCount: 1, failures: [] }, + source: { servers: {} }, + target: { mcpServers: { server: { type: 'stdio', command: 'source', args: [] } } }, + }); + }); + + test('does not recreate an existing target deleted during migration', async () => { + const fileService = store.add(new DeletingBeforeExistsFileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-deleted-target'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + await fileService.writeFile(targetUri, VSBuffer.fromString('{"mcpServers":{}}')); + fileService.deleteBeforeExists = targetUri; + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + targetExists: await fileService.exists(targetUri), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.WriteFailed, message: 'File was deleted during MCP migration: file:///workspace-deleted-target/.mcp.json' }] }, + source: { servers: { server: { type: 'stdio', command: 'source' } } }, + targetExists: false, + }); + }); + + test('does not recreate an existing source deleted during migration', async () => { + const fileService = store.add(new DeletingBeforeExistsFileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-deleted-source'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + fileService.deleteBeforeExists = sourceUri; + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + sourceExists: await fileService.exists(sourceUri), + target: parse((await fileService.readFile(targetUri)).value.toString()), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.RollbackFailed, message: 'Failed to migrate and roll back MCP servers from file:///workspace-deleted-source/.vscode/mcp.json.' }] }, + sourceExists: false, + target: { mcpServers: { server: { type: 'stdio', command: 'source' } } }, + }); + }); + + test('restores the source when an equivalent destination changes during migration', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new TargetChangingFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-concurrent-target'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + await fileService.writeFile(targetUri, VSBuffer.fromString('{"mcpServers":{"server":{"type":"stdio","command":"source"}}}')); + provider.sourceUri = sourceUri; + provider.targetUri = targetUri; + provider.changeTargetBeforeSourceWrite = true; + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + target: parse((await fileService.readFile(targetUri)).value.toString()), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.TargetChanged, message: `MCP server 'server' changed in file:///workspace-concurrent-target/.mcp.json during migration.` }] }, + source: { servers: { server: { type: 'stdio', command: 'source' } } }, + target: { mcpServers: {} }, + }); + }); + + test('rejects an existing target that is not strict .mcp.json', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new InMemoryFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-empty-target'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + await fileService.writeFile(targetUri, VSBuffer.fromString('// Workspace MCP servers\n')); + + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + const sourceContent = (await fileService.readFile(sourceUri)).value.toString(); + const targetContent = (await fileService.readFile(targetUri)).value.toString(); + + assert.deepStrictEqual({ + result, + source: parse(sourceContent), + commentPreserved: targetContent.includes('// Workspace MCP servers'), + }, { + result: { migratedCount: 0, failures: [{ name: 'server', reason: McpServerMigrationFailureReason.InvalidTarget, message: 'MCP configuration file:///workspace-empty-target/.mcp.json must contain strict JSON.' }] }, + source: { servers: { server: { type: 'stdio', command: 'source' } } }, + commentPreserved: true, + }); + }); + + test('retains a newly created target when updating the source fails', async () => { + const fileService = store.add(new FileService(new NullLogService())); + const provider = store.add(new SourceWriteFailingFileSystemProvider()); + store.add(fileService.registerProvider(Schemas.file, provider)); + const root = URI.file('/workspace-rollback'); + const sourceUri = URI.joinPath(root, '.vscode', 'mcp.json'); + const targetUri = URI.joinPath(root, '.mcp.json'); + await fileService.writeFile(sourceUri, VSBuffer.fromString('{"servers":{"server":{"type":"stdio","command":"source"}}}')); + provider.sourceUri = sourceUri; + provider.failSourceWrite = true; + const result = await migrateMcpServers([candidate(root, 'server-id', 'server')], fileService); + + assert.deepStrictEqual({ + result, + source: parse((await fileService.readFile(sourceUri)).value.toString()), + target: parse((await fileService.readFile(targetUri)).value.toString()), + }, { + result: { + migratedCount: 0, + failures: [{ + name: 'server', + reason: McpServerMigrationFailureReason.RollbackFailed, + message: 'Failed to migrate and roll back MCP servers from file:///workspace-rollback/.vscode/mcp.json.', + }], + }, + source: { servers: { server: { type: 'stdio', command: 'source' } } }, + target: { mcpServers: { server: { type: 'stdio', command: 'source' } } }, + }); + }); +}); 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 e0cddf34f3fbb7..de3d2e3ab87486 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -22,6 +22,7 @@ import { IModelService } from '../../../../../editor/common/services/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -874,14 +875,6 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const promptsService = createMockPromptsService(fixtureFiles, agentInstructions, fileContents, promptFilesDidChangeEmitter.event); reg.defineInstance(IPromptsService, promptsService); const agentHostCustomizationService = createMockAgentHostCustomizationService(options.activeSessionMcpServers); - reg.defineInstance(ICustomizationMigrationService, new CustomizationMigrationService( - promptsService, - harnessService, - new class extends mock() { - override acquireMcpServerSupportScope() { return undefined; } - }(), - agentHostCustomizationService, - )); reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { override readonly isSessionsWindow = isSessionsWindow; override readonly welcomePageFeatures = { @@ -924,7 +917,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; } }()); - reg.defineInstance(IFileService, new class extends mock() { + const fileService = new class extends mock() { override readonly onDidFilesChange = Event.None; override async exists(resource: URI) { return fileContents.has(resource) || createdFolders.has(resource); @@ -961,7 +954,18 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } promptFilesDidChangeEmitter.fire(); } - }()); + }(); + reg.defineInstance(IFileService, fileService); + reg.defineInstance(ICustomizationMigrationService, new CustomizationMigrationService( + promptsService, + harnessService, + new class extends mock() { + override acquireMcpServerSupportScope() { return undefined; } + }(), + agentHostCustomizationService, + fileService, + new NullLogService(), + )); reg.defineInstance(IPathService, new class extends mock() { override readonly defaultUriScheme = 'file'; override userHome(): URI;