diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index bb8d8507a40b4e..dba60e42af77e8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -6,6 +6,7 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; @@ -21,7 +22,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; -import { CONTEXT_MODELS_EDITOR, CONTEXT_MODELS_SEARCH_FOCUS, MANAGE_CHAT_COMMAND_ID } from '../../common/constants.js'; +import { ChatAIDisabledSettingId, CONTEXT_MODELS_EDITOR, CONTEXT_MODELS_SEARCH_FOCUS, MANAGE_CHAT_COMMAND_ID } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ModelsManagementEditor } from './chatManagementEditor.js'; import { ModelsManagementEditorInput } from './chatManagementEditorInput.js'; @@ -86,6 +87,7 @@ async function ensureChatExtensionEnabled(accessor: ServicesAccessor): Promise { - await extensionsWorkbenchService.setEnablement([chatExtension], EnablementState.EnabledGlobally); + // Enablement is derived from the setting, so it has to be cleared first. + if (configurationService.getValue(ChatAIDisabledSettingId) === true) { + await configurationService.updateValue(ChatAIDisabledSettingId, false); + } + if (!extensionEnablementService.isEnabled(local)) { + await extensionsWorkbenchService.setEnablement([chatExtension], EnablementState.EnabledGlobally); + } await extensionsWorkbenchService.updateRunningExtensions(localize('enableChatForByokReason', "Enabling AI features")); } ); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 1f79ad94894bfb..bd7f07872e5d33 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -48,7 +48,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/brow import { InEditorZenModeContext } from '../../../../common/contextkeys.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; -import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; +import { ExtensionRuntimeActionType, IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { UpdateTitleBarEditorVisibleContext } from '../../../update/common/update.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; @@ -780,7 +780,8 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHostService private readonly hostService: IHostService ) { super(); @@ -791,20 +792,21 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo this.registerListeners(); this.registerActions(); - - this.handleChatDisabled(false); } - private handleChatDisabled(fromEvent: boolean): void { - const chatDisabled = this.configurationService.inspect(ChatAIDisabledSettingId); - if (chatDisabled.value === true) { - this.maybeEnableOrDisableExtension(typeof chatDisabled.workspaceValue === 'boolean' ? EnablementState.DisabledWorkspace : EnablementState.DisabledGlobally); - if (fromEvent) { - this.maybeHideAuxiliaryBar(); - } - } else if (chatDisabled.value === false && fromEvent /* do not enable extensions unless its an explicit settings change */) { - this.maybeEnableOrDisableExtension(typeof chatDisabled.workspaceValue === 'boolean' ? EnablementState.EnabledWorkspace : EnablementState.EnabledGlobally); + private async handleChatDisabled(): Promise { + const chatDisabled = this.configurationService.getValue(ChatAIDisabledSettingId) === true; + if (chatDisabled) { + this.maybeHideAuxiliaryBar(); } + + // Enablement is derived, but the extension host still has to be told to pick the change up. + const defaultChatExtension = this.extensionsWorkbenchService.local.find(value => ExtensionIdentifier.equals(value.identifier.id, defaultChat.chatExtensionId)); + if (defaultChatExtension?.runtimeState?.action === ExtensionRuntimeActionType.ReloadWindow) { + return this.hostService.reload(); // a remote extension host cannot be restarted in place + } + + await this.extensionsWorkbenchService.updateRunningExtensions(chatDisabled ? localize('restartExtensionHost.reason.disable', "Disabling AI features") : localize('restartExtensionHost.reason.enable', "Enabling AI features")); } private async registerListeners(): Promise { @@ -815,7 +817,7 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo return; } - this.handleChatDisabled(true); + this.handleChatDisabled(); })); // Extension installation @@ -838,24 +840,6 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo })); } - private async maybeEnableOrDisableExtension(state: EnablementState.EnabledGlobally | EnablementState.EnabledWorkspace | EnablementState.DisabledGlobally | EnablementState.DisabledWorkspace): Promise { - const defaultChatExtension = this.extensionsWorkbenchService.local.find(value => ExtensionIdentifier.equals(value.identifier.id, defaultChat.chatExtensionId)); - if (!defaultChatExtension?.local) { - return; - } - - const workspace = state === EnablementState.EnabledWorkspace || state === EnablementState.DisabledWorkspace; - const canChange = workspace - ? this.extensionEnablementService.canChangeWorkspaceEnablement(defaultChatExtension.local) - : this.extensionEnablementService.canChangeEnablement(defaultChatExtension.local); - if (!canChange) { - return; - } - - await this.extensionsWorkbenchService.setEnablement([defaultChatExtension], state); - await this.extensionsWorkbenchService.updateRunningExtensions(state === EnablementState.EnabledGlobally || state === EnablementState.EnabledWorkspace ? localize('restartExtensionHost.reason.enable', "Enabling AI features") : localize('restartExtensionHost.reason.disable', "Disabling AI features")); - } - private maybeHideAuxiliaryBar(): void { const activeContainers = this.viewDescriptorService.getViewContainersByLocation(ViewContainerLocation.AuxiliaryBar).filter( container => this.viewDescriptorService.getViewContainerModel(container).activeViewDescriptors.length > 0 diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 83d083905d20c6..bf9d2f3a4dee6a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -1847,6 +1847,7 @@ export class EnableAIFeaturesInWorkspaceAction extends ExtensionAction { @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, ) { super(EnableAIFeaturesInWorkspaceAction.ID, EnableAIFeaturesInWorkspaceAction.LABEL, ExtensionAction.LABEL_ACTION_CLASS); this.tooltip = localize('enableAIInWorkspaceActionToolTip', "Enable AI features in this workspace"); @@ -1866,7 +1867,12 @@ export class EnableAIFeaturesInWorkspaceAction extends ExtensionAction { if (!ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { return; } - if (!this.extensionEnablementService.canChangeWorkspaceEnablement(this.extension.local)) { + if (this.contextService.getWorkbenchState() === WorkbenchState.EMPTY) { + return; + } + // `run` clears the setting first, so the state it owns must not gate this action. + if (this.extension.enablementState !== EnablementState.DisabledByAIFeaturesSetting + && !this.extensionEnablementService.canChangeWorkspaceEnablement(this.extension.local)) { return; } const inspect = this.configurationService.inspect(ChatAIDisabledSettingId); @@ -1885,12 +1891,20 @@ export class EnableAIFeaturesInWorkspaceAction extends ExtensionAction { } override async run(): Promise { - if (!this.extension) { + if (!this.extension?.local) { return; } - await this.extensionsWorkbenchService.setEnablement(this.extension, EnablementState.EnabledWorkspace); + if (this.configurationService.getValue(ChatAIDisabledSettingId) === true) { await this.configurationService.updateValue(ChatAIDisabledSettingId, false, ConfigurationTarget.WORKSPACE); + if (this.configurationService.getValue(ChatAIDisabledSettingId) === true) { + return; // a more specific value still disables AI, there is no persisted state to fix + } + } + + // A persisted disable outlives the setting, and setEnablement reports why if it cannot go. + if (!this.extensionEnablementService.isEnabled(this.extension.local)) { + await this.extensionsWorkbenchService.setEnablement(this.extension, EnablementState.EnabledWorkspace); } } } @@ -1935,31 +1949,40 @@ class DisableAIFeaturesInWorkspaceAction extends ExtensionAction { constructor( @IProductService private readonly productService: IProductService, - @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IExtensionService private readonly extensionService: IExtensionService, ) { super(DisableAIFeaturesInWorkspaceAction.ID, DisableAIFeaturesInWorkspaceAction.LABEL, ExtensionAction.LABEL_ACTION_CLASS); this.tooltip = localize('disableAIInWorkspaceActionToolTip', "Disable AI features in this workspace"); this.update(); this._register(this.extensionService.onDidChangeExtensions(() => this.update())); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatAIDisabledSettingId)) { + this.update(); + } + })); } update(): void { this.enabled = false; - if (this.extension && this.extension.local && ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { - this.enabled = this.extension.state === ExtensionState.Installed - && (this.extension.enablementState === EnablementState.EnabledGlobally || this.extension.enablementState === EnablementState.EnabledWorkspace) - && this.extensionEnablementService.canChangeWorkspaceEnablement(this.extension.local); + if (!this.extension?.local) { + return; } + if (!ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { + return; + } + if (this.contextService.getWorkbenchState() === WorkbenchState.EMPTY) { + return; + } + this.enabled = this.extension.state === ExtensionState.Installed + && this.configurationService.getValue(ChatAIDisabledSettingId) !== true + && this.extensionEnablementService.isEnabled(this.extension.local); } override async run(): Promise { - if (!this.extension) { - return; - } - await this.extensionsWorkbenchService.setEnablement(this.extension, EnablementState.DisabledWorkspace); - await this.extensionsWorkbenchService.updateRunningExtensions(localize('restartExtensionHost.reason.disable', "Disabling AI features")); + await this.configurationService.updateValue(ChatAIDisabledSettingId, true, ConfigurationTarget.WORKSPACE); } } diff --git a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts index 141241fdb70cdc..7f19f105e7c6f5 100644 --- a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts @@ -136,6 +136,13 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench } })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatAIDisabledSettingId)) { + this.reconcileChatExtensionDisablement(); + this._onEnablementChanged.fire(this.extensionsManager.extensions.filter(ext => ext.identifier.id.toLowerCase() === this._chatExtensionId)); + } + })); + // delay notification for extensions disabled until workbench restored if (this.allUserExtensionsDisabled) { this.lifecycleService.when(LifecyclePhase.Eventually).then(() => { @@ -150,6 +157,7 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench } this.ensureChatExtensionInitialDisabledState(); + this.reconcileChatExtensionDisablement(); } private ensureChatExtensionInitialDisabledState(): void { @@ -190,6 +198,45 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench } } + // A persisted disable for the chat extension is redundant wherever `chat.disableAIFeatures` + // already resolves to true, and it outlives the setting: a global entry reaches every window on + // this machine, a workspace entry survives the setting being cleared. Releases that implemented + // the setting by persisting enablement left exactly those entries behind, so drop them. + private reconcileChatExtensionDisablement(): void { + if (!this._chatExtensionId) { + return; + } + + const identifier = { id: this._chatExtensionId }; + const inspect = this.configurationService.inspect(ChatAIDisabledSettingId); + + if (inspect.workspaceValue === true && this._getWorkspaceDisabledExtensions().some(e => areSameExtensions(e, identifier))) { + this.logService.debug('Removing workspace disablement of builtin chat extension in favor of chat.disableAIFeatures'); + this._removeFromWorkspaceDisabledExtensions(identifier) + .catch(err => this.logService.error('Failed to remove workspace disablement of builtin chat extension', err)); + } + + // A workspace value can mask the one the global entry was written for, so that entry is + // reconciled against the user and application values rather than the resolved one. + if (inspect.userValue !== true && inspect.applicationValue !== true) { + return; + } + if (!this._isDisabledGlobally(identifier)) { + return; + } + + // The disable for a profile where chat setup never completed belongs to + // ensureChatExtensionInitialDisabledState and has to survive. + const context = (this.chatEntitlementService as ChatEntitlementService).context; + if (context && !context.value.state.completed) { + return; + } + + this.logService.debug('Removing global disablement of builtin chat extension in favor of chat.disableAIFeatures'); + this.globalExtensionEnablementService.enableExtension(identifier, SOURCE) + .catch(err => this.logService.error('Failed to remove global disablement of builtin chat extension', err)); + } + private get hasWorkspace(): boolean { return this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY; } @@ -278,6 +325,8 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench throw new Error(localize('cannot change enablement virtual workspace', "Cannot change enablement of {0} extension because it does not support virtual workspaces", extension.manifest.displayName || extension.identifier.id)); case EnablementState.DisabledByExtensionKind: throw new Error(localize('cannot change enablement extension kind', "Cannot change enablement of {0} extension because of its extension kind", extension.manifest.displayName || extension.identifier.id)); + case EnablementState.DisabledByAIFeaturesSetting: + throw new Error(localize('cannot change enablement ai features', "Cannot change enablement of {0} extension because AI features are disabled in settings", extension.manifest.displayName || extension.identifier.id)); case EnablementState.DisabledByAllowlist: throw new Error(localize('cannot change disallowed extension enablement', "Cannot change enablement of {0} extension because it is disallowed", extension.manifest.displayName || extension.identifier.id)); case EnablementState.DisabledByInvalidExtension: @@ -451,11 +500,11 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench return enablementState; } - // Ensure the chat extension is disabled in fresh profiles where chat setup is not completed. - // This is called here (in addition to the constructor) because on profile switch the + // Both are called here (in addition to the constructor) because on profile switch the // enablement service is not recreated, but the storage scope changes to the new profile. if (extension.identifier.id.toLowerCase() === this._chatExtensionId) { this.ensureChatExtensionInitialDisabledState(); + this.reconcileChatExtensionDisablement(); } enablementState = this._getUserEnablementState(extension.identifier); @@ -497,6 +546,10 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench enablementState = EnablementState.DisabledByEnvironment; } + else if (this._isDisabledByAIFeaturesSetting(extension)) { + enablementState = EnablementState.DisabledByAIFeaturesSetting; + } + else if (isEnabled && this._isDisabledByExtensionDependency(extension, extensions, workspaceType, computedEnablementStates)) { enablementState = EnablementState.DisabledByExtensionDependency; } @@ -691,6 +744,12 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench return !this.extensionManifestPropertiesService.canExecuteOnSessionsWindow(extension.manifest); } + private _isDisabledByAIFeaturesSetting(extension: IExtension): boolean { + return !!this._chatExtensionId + && extension.identifier.id.toLowerCase() === this._chatExtensionId + && this.configurationService.getValue(ChatAIDisabledSettingId) === true; + } + private _enableExtension(identifier: IExtensionIdentifier): Promise { this._removeFromWorkspaceDisabledExtensions(identifier); this._removeFromWorkspaceEnabledExtensions(identifier); diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts index de72cdf9671dbc..33cba037371389 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagement.ts @@ -109,6 +109,7 @@ export const enum EnablementState { DisabledByAllowlist, DisabledByExtensionDependency, DisabledByUnification, // Temporary TODO@benibenj remove when unification transition is complete + DisabledByAIFeaturesSetting, DisabledGlobally, DisabledWorkspace, EnabledGlobally, diff --git a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts index d0f47ea4ff2651..3f1225db62c388 100644 --- a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts @@ -15,7 +15,8 @@ import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } import { IExtensionContributions, ExtensionType, IExtension, IExtensionManifest, IExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { isUndefinedOrNull } from '../../../../../base/common/types.js'; import { areSameExtensions } from '../../../../../platform/extensionManagement/common/extensionManagementUtil.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationService, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; import { URI } from '../../../../../base/common/uri.js'; import { Schemas } from '../../../../../base/common/network.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -129,12 +130,35 @@ export class TestExtensionEnablementService extends ExtensionEnablementService { } } +class TestConfigurationServiceWithWorkspaceValues extends TestConfigurationService { + + private readonly workspaceValues = new Map(); + + setWorkspaceValue(key: string, value: unknown): void { + this.workspaceValues.set(key, value); + } + + override getValue(arg1?: string | IConfigurationOverrides, arg2?: IConfigurationOverrides): T | undefined { + if (typeof arg1 === 'string' && this.workspaceValues.has(arg1)) { + return this.workspaceValues.get(arg1) as T; + } + return super.getValue(arg1, arg2); + } + + override inspect(key: string): IConfigurationValue { + const userValue = super.getValue(key); + const workspaceValue = this.workspaceValues.has(key) ? this.workspaceValues.get(key) as T : undefined; + return { value: this.getValue(key), userValue, userLocalValue: userValue, workspaceValue }; + } +} + suite('ExtensionEnablementService Test', () => { const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; let testObject: TestExtensionEnablementService; + let testConfigurationService: TestConfigurationServiceWithWorkspaceValues; const didInstallEvent = new Emitter(); const didUninstallEvent = new Emitter(); @@ -147,7 +171,7 @@ suite('ExtensionEnablementService Test', () => { instantiationService = disposableStore.add(new TestInstantiationService()); instantiationService.stub(IFileService, disposableStore.add(new FileService(new NullLogService()))); instantiationService.stub(IProductService, TestProductService); - const testConfigurationService = new TestConfigurationService(); + testConfigurationService = new TestConfigurationServiceWithWorkspaceValues(); testConfigurationService.setUserConfiguration(AllowedExtensionsConfigKey, { '*': true, 'unallowed': false }); instantiationService.stub(IConfigurationService, testConfigurationService); instantiationService.stub(IWorkspaceContextService, new TestContextService()); @@ -1217,6 +1241,156 @@ suite('ExtensionEnablementService Test', () => { assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledGlobally); }); + test('test chat extension is disabled when chat.disableAIFeatures is true', async () => { + const chatExtension = aChatExtension(); + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + assert.ok(!testObject.isEnabled(chatExtension)); + assert.strictEqual(testObject.getEnablementState(aLocalExtension('pub.a')), EnablementState.EnabledGlobally); + }); + + test('test chat extension enablement cannot be changed while chat.disableAIFeatures is true', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + + assert.strictEqual(testObject.canChangeEnablement(chatExtension), false); + + let error: Error | undefined; + try { + await testObject.setEnablement([chatExtension], EnablementState.EnabledGlobally); + } catch (e) { + error = e as Error; + } + assert.ok(error, 'enablement must not be changeable while the setting owns it'); + }); + + test('test chat extension enablement change is announced when chat.disableAIFeatures changes', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.EnabledGlobally); + + const target = sinon.spy(); + disposableStore.add(testObject.onEnablementChanged(target)); + + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testConfigurationService.onDidChangeConfigurationEmitter.fire(anAIFeaturesConfigurationChangeEvent()); + + assert.strictEqual(target.args[0][0].length, 1); + assert.deepStrictEqual((target.args[0][0][0]).identifier, chatExtension.identifier); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + }); + + test('test global disablement of chat extension is dropped on startup when chat.disableAIFeatures is true', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + await testObject.setEnablement([chatExtension], EnablementState.DisabledGlobally); + assert.ok(testObject.isDisabledGlobally(chatExtension)); + + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + + assert.ok(!testObject.isDisabledGlobally(chatExtension)); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + }); + + test('test global disablement of chat extension is dropped when chat.disableAIFeatures turns true later', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + await testObject.setEnablement([chatExtension], EnablementState.DisabledGlobally); + assert.ok(testObject.isDisabledGlobally(chatExtension)); + + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testConfigurationService.onDidChangeConfigurationEmitter.fire(anAIFeaturesConfigurationChangeEvent()); + + assert.ok(!testObject.isDisabledGlobally(chatExtension)); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + }); + + test('test global disablement of chat extension is kept when chat.disableAIFeatures is false', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + await testObject.setEnablement([chatExtension], EnablementState.DisabledGlobally); + + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + + assert.ok(testObject.isDisabledGlobally(chatExtension)); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledGlobally); + }); + + test('test global disablement of chat extension is dropped on profile switch', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + await testObject.setEnablement([chatExtension], EnablementState.DisabledGlobally); + assert.ok(testObject.isDisabledGlobally(chatExtension)); + + // A profile switch swaps the storage scope without recreating the service or announcing a + // configuration change, so the next enablement lookup is what reconciles the new profile + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + assert.ok(!testObject.isDisabledGlobally(chatExtension)); + }); + + test('test workspace disablement of chat extension is dropped when chat.disableAIFeatures is set in the workspace', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + await testObject.setEnablement([chatExtension], EnablementState.DisabledWorkspace); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledWorkspace); + + testConfigurationService.setWorkspaceValue(ChatAIDisabledSettingId, true); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + + testConfigurationService.setWorkspaceValue(ChatAIDisabledSettingId, false); + testConfigurationService.onDidChangeConfigurationEmitter.fire(anAIFeaturesConfigurationChangeEvent()); + + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.EnabledGlobally); + }); + + test('test global disablement of chat extension is dropped when a workspace value masks the user value', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + await testObject.setEnablement([chatExtension], EnablementState.DisabledGlobally); + assert.ok(testObject.isDisabledGlobally(chatExtension)); + + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testConfigurationService.setWorkspaceValue(ChatAIDisabledSettingId, false); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); + await testObject.waitUntilInitialized(); + + assert.ok(!testObject.isDisabledGlobally(chatExtension)); + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.EnabledGlobally); + }); + + test('test global disablement for incomplete chat setup survives chat.disableAIFeatures', async () => { + const chatExtension = aChatExtension(); + installed.push(chatExtension); + const storageService = instantiationService.get(IStorageService); + storageService.store('builtinChatExtensionEnablementMigration', false, StorageScope.PROFILE, StorageTarget.MACHINE); + + const chatEntitlementService = new TestChatEntitlementService(); + chatEntitlementService.context = new Lazy(() => ({ state: { completed: false }, onDidChange: Event.None })) as unknown as Lazy; + + testConfigurationService.setUserConfiguration(ChatAIDisabledSettingId, true); + testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService, chatEntitlementService)); + await testObject.waitUntilInitialized(); + + assert.strictEqual(testObject.getEnablementState(chatExtension), EnablementState.DisabledByAIFeaturesSetting); + assert.ok(testObject.isDisabledGlobally(chatExtension)); + }); + test('test extension is disabled by allowed list', async () => { const target = aLocalExtension2('unallowed.extension'); assert.strictEqual(testObject.getEnablementState(target), EnablementState.DisabledByAllowlist); @@ -1346,6 +1520,14 @@ export function anExtensionManagementServerService(localExtensionManagementServe }; } +function aChatExtension(): ILocalExtension { + return aLocalExtension(productService.defaultChatAgent!.chatExtensionId, undefined, ExtensionType.System); +} + +function anAIFeaturesConfigurationChangeEvent(): IConfigurationChangeEvent { + return { affectsConfiguration: (key: string) => key === ChatAIDisabledSettingId }; +} + function aLocalExtension(id: string, contributes?: IExtensionContributions, type?: ExtensionType): ILocalExtension { return aLocalExtension2(id, contributes ? { contributes } : {}, isUndefinedOrNull(type) ? {} : { type }); }